Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions assets/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,26 @@ select {
border: 1px solid var(--ui-border);
}

.ui-upload-preview__file {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
border-radius: 4px;
border: 1px solid var(--ui-border);
background: var(--ui-surface-muted);
font-size: 0.75rem;
color: var(--ui-text-soft);
max-width: 160px;
height: 48px;
}

.ui-upload-preview__filename {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.ui-upload-preview__remove {
position: absolute;
top: -6px;
Expand All @@ -1122,6 +1142,27 @@ select {
cursor: pointer;
}

/* File attachment links in messages */
.ui-markdown a[href^="/uploads/"] {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.6rem;
border-radius: 6px;
border: 1px solid var(--ui-border);
background: var(--ui-surface-muted);
color: var(--ui-text);
font-size: 0.85rem;
text-decoration: none;
transition: background 0.15s;
margin: 0.15rem 0;
}

.ui-markdown a[href^="/uploads/"]:hover {
background: var(--ui-surface-hover, var(--ui-border));
text-decoration: none;
}

/* Upload button in composer */
.ui-chat-composer__upload-btn {
display: inline-flex;
Expand Down
8 changes: 8 additions & 0 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,14 @@ const Hooks = {
return
}

// File download links — open in new tab
const fileLink = event.target.closest('.ui-markdown a[href^="/uploads/"]')
if (fileLink && this.el.contains(fileLink) && !fileLink.querySelector("img")) {
event.preventDefault()
window.open(fileLink.href, "_blank")
return
}

const mention = event.target.closest(".ui-mention[data-agent-name]")
if (!mention || !this.el.contains(mention)) return

Expand Down
8 changes: 4 additions & 4 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,12 @@ config :hive, HiveWeb.Endpoint,
web_console_logger: true,
patterns: [
# Static assets, except user uploads
~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$"E,
~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$",
# Gettext translations
~r"priv/gettext/.*\.po$"E,
~r"priv/gettext/.*\.po$",
# Router, Controllers, LiveViews and LiveComponents
~r"lib/hive_web/router\.ex$"E,
~r"lib/hive_web/(controllers|live|components)/.*\.(ex|heex)$"E
~r"lib/hive_web/router\.ex$",
~r"lib/hive_web/(controllers|live|components)/.*\.(ex|heex)$"
]
]

Expand Down
171 changes: 171 additions & 0 deletions e2e/file-upload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { test, expect, Page } from "@playwright/test";
import { waitForLiveView } from "./helpers";
import * as path from "path";
import * as fs from "fs";
import * as os from "os";

/**
* Create a temporary file with the given content and extension.
* Returns the file path. Caller is responsible for cleanup.
*/
function createTempFile(name: string, content: string | Buffer): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hive-e2e-"));
const filePath = path.join(dir, name);
fs.writeFileSync(filePath, content);
return filePath;
}

/**
* Clean up a temp file and its directory. Silently ignores errors.
*/
function cleanupTempFile(filePath: string): void {
try {
fs.unlinkSync(filePath);
fs.rmdirSync(path.dirname(filePath));
} catch {
// ignore cleanup errors
}
}

/**
* Set files on the LiveView upload input and trigger the LV upload hook.
* Playwright's setInputFiles() alone doesn't fire LiveView's internal
* file tracking — we need to re-dispatch a change event so the
* Phoenix.LiveFileUpload hook calls trackFiles().
*/
async function triggerLiveViewUpload(
page: Page,
files: string | string[],
): Promise<void> {
const fileInput = page.locator("input[data-phx-upload-ref]");
await fileInput.setInputFiles(files);
await page.evaluate(() => {
const input = document.querySelector(
'input[data-phx-upload-ref]',
) as HTMLInputElement;
if (input) {
input.dispatchEvent(new Event("change", { bubbles: true }));
}
});
// Give LiveView time to process the upload tracking
await page.waitForTimeout(500);
}

test.describe("File upload", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForLiveView(page);

// Select a topic so the composer is active
const topicItem = page.locator(".ui-topic-item").first();
if (await topicItem.isVisible({ timeout: 2000 }).catch(() => false)) {
await topicItem.click();
await page.waitForTimeout(500);
}
});

test("upload button is visible with correct title", async ({ page }) => {
const uploadBtn = page.locator(".ui-chat-composer__upload-btn");
await expect(uploadBtn).toBeVisible();
await expect(uploadBtn).toHaveAttribute("title", "Attach file");
});

test("image upload shows thumbnail preview", async ({ page }) => {
// Create a tiny valid PNG (1x1 red pixel)
const pngData = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12P4z8BQDwAEgAF/QualzQAAAABJRU5ErkJggg==",
"base64",
);
const filePath = createTempFile("test-image.png", pngData);

try {
await triggerLiveViewUpload(page, filePath);

// Should show image thumbnail preview
const previews = page.locator(".ui-upload-previews");
await expect(previews).toBeVisible();

const thumb = page.locator(".ui-upload-preview__thumb");
await expect(thumb).toBeVisible();
} finally {
cleanupTempFile(filePath);
}
});

test("non-image file upload shows filename preview", async ({ page }) => {
const filePath = createTempFile(
"test-document.pdf",
"%PDF-1.4 test content for upload testing",
);

try {
await triggerLiveViewUpload(page, filePath);

// Should show file icon + filename preview (not image thumbnail)
const previews = page.locator(".ui-upload-previews");
await expect(previews).toBeVisible();

const filePreview = page.locator(".ui-upload-preview__file");
await expect(filePreview).toBeVisible();

const filename = page.locator(".ui-upload-preview__filename");
await expect(filename).toContainText("test-document.pdf");
} finally {
cleanupTempFile(filePath);
}
});

test("can cancel a file upload before sending", async ({ page }) => {
const filePath = createTempFile("cancel-me.txt", "temporary file");

try {
await triggerLiveViewUpload(page, filePath);

const previews = page.locator(".ui-upload-previews");
await expect(previews).toBeVisible();

// Click remove button
const removeBtn = page.locator(".ui-upload-preview__remove");
await removeBtn.click();

// Previews should be gone
await expect(
page.locator(".ui-upload-preview__file"),
).not.toBeVisible();
} finally {
cleanupTempFile(filePath);
}
});

test("multiple file types can be attached simultaneously", async ({
page,
}) => {
const pngData = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADklEQVQI12P4z8BQDwAEgAF/QualzQAAAABJRU5ErkJggg==",
"base64",
);
const imgPath = createTempFile("photo.png", pngData);
const txtPath = createTempFile("notes.txt", "some notes");
const csvPath = createTempFile("data.csv", "name,age\nAlice,30");

try {
await triggerLiveViewUpload(page, [imgPath, txtPath, csvPath]);

// Should show 3 previews: 1 image thumb + 2 file icons
const allPreviews = page.locator(".ui-upload-preview");
await expect(allPreviews).toHaveCount(3);

// Image gets a thumbnail
const thumb = page.locator(".ui-upload-preview__thumb");
await expect(thumb).toHaveCount(1);

// Non-images get file previews
const filePreviews = page.locator(".ui-upload-preview__file");
await expect(filePreviews).toHaveCount(2);
} finally {
cleanupTempFile(imgPath);
cleanupTempFile(txtPath);
cleanupTempFile(csvPath);
}
});
});
102 changes: 90 additions & 12 deletions lib/hive/media.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,113 @@ defmodule Hive.Media do
@moduledoc """
Saves uploaded media files to disk and returns public URLs.
Files are stored in `priv/static/uploads/` and served via Plug.Static.
Supports images and general file uploads (documents, archives, code, etc.).
"""

@upload_dir Path.join([:code.priv_dir(:hive) |> to_string(), "static", "uploads"])
@max_size 5_000_000
@allowed_types ~w(image/png image/jpeg image/gif image/webp)
@max_size 10_000_000

@allowed_types ~w(
image/png image/jpeg image/gif image/webp image/svg+xml
application/pdf
application/zip application/gzip application/x-tar
application/x-7z-compressed application/x-rar-compressed
application/json application/xml
application/msword application/vnd.openxmlformats-officedocument.wordprocessingml.document
application/vnd.ms-excel application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
application/vnd.ms-powerpoint application/vnd.openxmlformats-officedocument.presentationml.presentation
text/plain text/csv text/html text/css text/javascript text/markdown text/xml
audio/mpeg audio/wav audio/ogg
video/mp4 video/webm
application/octet-stream
)

@image_types ~w(image/png image/jpeg image/gif image/webp image/svg+xml)

@ext_map %{
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
"image/svg+xml" => "svg",
"application/pdf" => "pdf",
"application/zip" => "zip",
"application/gzip" => "gz",
"application/x-tar" => "tar",
"application/x-7z-compressed" => "7z",
"application/x-rar-compressed" => "rar",
"application/json" => "json",
"application/xml" => "xml",
"application/msword" => "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
"application/vnd.ms-excel" => "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx",
"application/vnd.ms-powerpoint" => "ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => "pptx",
"text/plain" => "txt",
"text/csv" => "csv",
"text/html" => "html",
"text/css" => "css",
"text/javascript" => "js",
"text/markdown" => "md",
"text/xml" => "xml",
"audio/mpeg" => "mp3",
"audio/wav" => "wav",
"audio/ogg" => "ogg",
"video/mp4" => "mp4",
"video/webm" => "webm",
"application/octet-stream" => "bin"
}

def upload_dir, do: @upload_dir

def ensure_upload_dir, do: File.mkdir_p!(@upload_dir)

def save(data, media_type) when media_type in @allowed_types do
def max_size, do: @max_size

def allowed_types, do: @allowed_types

def image_type?(media_type), do: media_type in @image_types

@doc """
Saves file data to disk. Returns `{:ok, url}` or `{:error, reason}`.
Optionally accepts an original filename to preserve the extension.
"""
def save(data, media_type, opts \\ [])

def save(data, media_type, opts) when is_binary(data) and is_binary(media_type) do
if byte_size(data) > @max_size do
{:error, "file too large (max 5MB)"}
{:error, "file too large (max #{div(@max_size, 1_000_000)}MB)"}
else
id = Base.hex_encode32(:crypto.strong_rand_bytes(10), case: :lower, padding: false)
ext = ext_for(media_type)
ext = ext_for(media_type, opts[:filename])
filename = "#{id}.#{ext}"
path = Path.join(@upload_dir, filename)
ensure_upload_dir()
File.write!(path, data)
{:ok, "/uploads/#{filename}"}
end
end

def save(_data, _media_type) do
{:error, "unsupported media type (allowed: png, jpeg, gif, webp)"}
end
@doc """
Returns the file extension for a MIME type.
Falls back to extracting from the original filename if provided.
"""
def ext_for(media_type, filename \\ nil) do
case Map.get(@ext_map, media_type) do
nil ->
# Fallback: try to extract from original filename, or use "bin"
if filename do
case Path.extname(filename) do
"." <> ext -> ext
_ -> "bin"
end
else
"bin"
end

defp ext_for("image/png"), do: "png"
defp ext_for("image/jpeg"), do: "jpg"
defp ext_for("image/gif"), do: "gif"
defp ext_for("image/webp"), do: "webp"
ext ->
ext
end
end
end
Loading
Loading