diff --git a/.env.example b/.env.example index 3fd053b..04c17da 100644 --- a/.env.example +++ b/.env.example @@ -13,11 +13,12 @@ GERBEUR_LISTEN_HOST=0.0.0.0 # Port the API server listens on GERBEUR_PORT=8000 -# Comma-separated list of origins allowed to make cross-origin requests. -# In dev: Vite's dev server URL (check actual host/port in Vite output). -# In prod (same container): your public domain. +# Comma-separated list of extra origins allowed to reach the API/WS cross-origin. +# The server's own BASE_URL is always allowed automatically. +# In dev: add Vite's dev server URL (check actual host/port in Vite output). +# In prod with a separate frontend host: add that public frontend origin here. # Example: http://localhost:3000,http://127.0.0.1:3000 -GERBEUR_ALLOWED_ORIGINS=http://localhost:3000 +GERBEUR_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # Secret key used to sign JWTs. Generate with: openssl rand -hex 32 GERBEUR_JWT_SECRET= diff --git a/README.md b/README.md index 267e9eb..fca9d16 100644 --- a/README.md +++ b/README.md @@ -38,18 +38,18 @@ Open [http://localhost:3000](http://localhost:3000). On first run a default `adm See [`.env.example`](.env.example) for the full list with descriptions. Key variables: -| Variable | Description | Default | -| ------------------------- | ---------------------------------------------------------------------------- | ----------------------- | -| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — | -| `GERBEUR_PORT` | API server port | `8000` | -| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated list of allowed CORS origins | `http://localhost:3000` | -| `VITE_API_HOSTNAME` | Override API hostname in the frontend bundle (see [Production](#production)) | unset | +| Variable | Description | Default | +| ------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------- | +| `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — | +| `GERBEUR_PORT` | API server port | `8000` | +| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated list of extra allowed frontend origins; the server's own `BASE_URL` is always allowed | `http://localhost:3000` | +| `VITE_API_HOSTNAME` | Override API hostname in the frontend bundle (see [Production](#production)) | unset | ## Production ### Docker (recommended) -The standard deployment runs API and frontend in a single container. The API server (Oak) serves the compiled frontend as static files, so both share the same origin — no CORS configuration needed, no `VITE_API_*` build args needed. +The standard deployment runs API and frontend in a single container. The API server (Oak) serves the compiled frontend as static files, so both share the same origin — no `VITE_API_*` build args needed. The server's own `BASE_URL` is always allowed for HTTP/WebSocket requests automatically. ```sh docker build -t gerbeur . @@ -59,7 +59,6 @@ docker run -d \ -v gerbeur-db:/app/api/sql \ -v gerbeur-uploads:/app/api/uploads \ -e GERBEUR_JWT_SECRET=$(openssl rand -hex 32) \ - -e GERBEUR_ALLOWED_ORIGINS=https://example.com \ -e GERBEUR_PROTOCOL=https \ -e GERBEUR_HOSTNAME=example.com \ -e GERBEUR_PORT=8000 \ @@ -73,7 +72,7 @@ The two volumes are required for persistence: #### Separate API and frontend (optional) -If you need to run the API on a different host than the frontend, pass the API location as build args so it gets baked into the frontend bundle: +If you need to run the API on a different host than the frontend, pass the API location as build args so it gets baked into the frontend bundle. In that setup, add the frontend origin to `GERBEUR_ALLOWED_ORIGINS` so cross-origin HTTP/WebSocket requests are accepted: ```sh docker build \ diff --git a/api/config.ts b/api/config.ts index 91f953a..b733036 100644 --- a/api/config.ts +++ b/api/config.ts @@ -9,6 +9,11 @@ export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`; const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? "http://localhost:3000"; -export const ALLOWED_ORIGINS: string[] = rawOrigins - ? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean) - : []; +export const ALLOWED_ORIGINS: string[] = Array.from(new Set([ + BASE_URL, + ...( + rawOrigins + ? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean) + : [] + ), +])); diff --git a/api/lib/upload.ts b/api/lib/upload.ts index 8d6eb53..5e4ba88 100644 --- a/api/lib/upload.ts +++ b/api/lib/upload.ts @@ -5,6 +5,7 @@ export const UPLOADS_DIR = "api/uploads"; export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`; export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`; export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`; +export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`; export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB diff --git a/api/main.ts b/api/main.ts index 1b63c06..4956569 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1,6 +1,7 @@ import { Application } from "@oak/oak"; import { oakCors } from "@tajpouria/cors"; +import attachmentsRouter from "./routes/attachments.ts"; import dumpsRouter from "./routes/dumps.ts"; import filesRouter from "./routes/files.ts"; import usersRouter from "./routes/users.ts"; @@ -42,6 +43,10 @@ app.use( filesRouter.routes(), filesRouter.allowedMethods(), ); +app.use( + attachmentsRouter.routes(), + attachmentsRouter.allowedMethods(), +); app.use( usersRouter.routes(), usersRouter.allowedMethods(), diff --git a/api/model/db.ts b/api/model/db.ts index d05769e..578c34b 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -18,6 +18,20 @@ db.prepare( `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`, ).run(); +// Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour +const orphanedAttachments = db.prepare( + `SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-1 hour');`, +).all() as { id: string }[]; +if (orphanedAttachments.length > 0) { + const { ATTACHMENTS_DIR } = await import("../lib/upload.ts"); + for (const { id } of orphanedAttachments) { + await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {}); + } + db.prepare( + `DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-1 hour');`, + ).run(); +} + // Create default admin user if no users exist const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as { count: number; diff --git a/api/routes/attachments.ts b/api/routes/attachments.ts new file mode 100644 index 0000000..45655b2 --- /dev/null +++ b/api/routes/attachments.ts @@ -0,0 +1,68 @@ +import { Router } from "@oak/oak"; +import { authMiddleware } from "../middleware/auth.ts"; +import { + createAttachment, + getAttachment, +} from "../services/attachment-service.ts"; +import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { + ATTACHMENTS_DIR, + serveUploadedFile, + validateImageUpload, +} from "../lib/upload.ts"; + +const router = new Router(); + +router.post("/api/attachments", authMiddleware, async (ctx) => { + const contentType = ctx.request.headers.get("content-type") ?? ""; + if (!contentType.includes("multipart/form-data")) { + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + "Expected multipart/form-data", + ); + } + + const body = await ctx.request.body.formData(); + const file = body.get("file"); + + if (!(file instanceof File)) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file"); + } + + const data = new Uint8Array(await file.arrayBuffer()); + const mime = validateImageUpload(data); + + const attachmentId = crypto.randomUUID(); + await Deno.mkdir(ATTACHMENTS_DIR, { recursive: true }); + await Deno.writeFile(`${ATTACHMENTS_DIR}/${attachmentId}`, data); + try { + createAttachment(attachmentId, mime); + } catch (err) { + await Deno.remove(`${ATTACHMENTS_DIR}/${attachmentId}`).catch(() => {}); + throw err; + } + + ctx.response.status = 201; + ctx.response.body = { success: true, data: { id: attachmentId } }; +}); + +router.get("/api/attachments/:attachmentId", async (ctx) => { + const { attachmentId } = ctx.params; + + let attachment; + try { + attachment = getAttachment(attachmentId); + } catch { + ctx.response.status = 404; + return; + } + + await serveUploadedFile( + ctx, + `${ATTACHMENTS_DIR}/${attachmentId}`, + attachment.mime, + ); +}); + +export default router; diff --git a/api/services/attachment-service.ts b/api/services/attachment-service.ts new file mode 100644 index 0000000..013aefe --- /dev/null +++ b/api/services/attachment-service.ts @@ -0,0 +1,42 @@ +import { db } from "../model/db.ts"; +import { APIErrorCode, APIException } from "../model/interfaces.ts"; + +export function createAttachment(id: string, mime: string): void { + db.prepare( + "INSERT INTO attachments (id, mime, created_at) VALUES (?, ?, ?)", + ).run(id, mime, new Date().toISOString()); +} + +export function getAttachment(attachmentId: string): { mime: string } { + const row = db.prepare( + "SELECT mime FROM attachments WHERE id = ?", + ).get(attachmentId) as { mime: string } | undefined; + + if (!row) { + throw new APIException( + APIErrorCode.NOT_FOUND, + 404, + "Attachment not found", + ); + } + + return { mime: row.mime }; +} + +// UUID pattern used to extract attachment IDs from markdown/text bodies. +const ATTACHMENT_RE = + /\/api\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g; + +/** + * Parse `text` for attachment URLs and bind any unowned attachments to + * `resourceId`. Only updates rows where resource_id IS NULL so a user cannot + * claim attachments that already belong to another resource. + */ +export function linkAttachments(text: string, resourceId: string): void { + const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]); + for (const id of ids) { + db.prepare( + "UPDATE attachments SET resource_id = ? WHERE id = ? AND resource_id IS NULL", + ).run(resourceId, id); + } +} diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts index 6ec4951..d76d565 100644 --- a/api/services/comment-service.ts +++ b/api/services/comment-service.ts @@ -6,6 +6,7 @@ import { import { type SQLOutputValue } from "node:sqlite"; import { commentRowToApi, db, isCommentRow } from "../model/db.ts"; import { notifyMentions } from "./notification-service.ts"; +import { linkAttachments } from "./attachment-service.ts"; const SELECT_COLS = `c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, @@ -66,6 +67,7 @@ export function createComment( dumpId, ) as { title: string } | undefined; notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId); + linkAttachments(body, id); return comment; } @@ -113,6 +115,7 @@ export function updateComment( dumpRow?.title ?? "", row.dump_id, ); + linkAttachments(body, commentId); return { comment: fetchComment(commentId), dumpId: row.dump_id, diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 229a261..f55448f 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -18,6 +18,7 @@ import { } from "./notification-service.ts"; import { makeSlug, UUID_RE } from "../lib/slugify.ts"; import { DUMPS_DIR } from "../lib/upload.ts"; +import { linkAttachments } from "./attachment-service.ts"; const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB @@ -109,6 +110,7 @@ export async function createUrlDump( } if (request.comment) { notifyMentions(userId, request.comment, "dump", dumpId, title); + linkAttachments(request.comment, dumpId); } return dump; } @@ -185,7 +187,10 @@ export async function createFileDump( broadcastNewDump(dump); notifyUserFollowersNewDump(userId, dumpId, file.name); } - if (comment) notifyMentions(userId, comment, "dump", dumpId, file.name); + if (comment) { + notifyMentions(userId, comment, "dump", dumpId, file.name); + linkAttachments(comment, dumpId); + } return dump; } @@ -282,6 +287,7 @@ export async function updateDump( dumpId, updatedDump.title, ); + linkAttachments(updatedDump.comment, dumpId); } return updatedDump; } @@ -344,6 +350,7 @@ export async function updateDump( dumpId, updatedDump.title, ); + linkAttachments(updatedDump.comment, dumpId); } return updatedDump; } @@ -401,7 +408,10 @@ export async function replaceFileDump( throw err; } - if (comment) notifyMentions(dump.userId, comment, "dump", dumpId, file.name); + if (comment) { + notifyMentions(dump.userId, comment, "dump", dumpId, file.name); + linkAttachments(comment, dumpId); + } return { ...dump, title: file.name, diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index 0f68241..576db7f 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -26,6 +26,7 @@ import { notifyPlaylistFollowersNewDump, } from "./notification-service.ts"; import { makeSlug, UUID_RE } from "../lib/slugify.ts"; +import { linkAttachments } from "./attachment-service.ts"; const DUMP_SELECT_COLS = "id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private"; @@ -74,6 +75,7 @@ export function createPlaylist( }; if (req.description) { notifyMentions(userId, req.description, "playlist", id, req.title); + linkAttachments(req.description, id); } broadcastPlaylistCreated(playlist); return playlist; @@ -184,6 +186,7 @@ export function updatePlaylist( playlist.id, newTitle, ); + linkAttachments(newDescription, playlist.id); } broadcastPlaylistUpdated(updated); return updated; diff --git a/api/services/user-service.ts b/api/services/user-service.ts index f02e201..f149fc2 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -9,6 +9,7 @@ import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts"; import { disconnectUser } from "./ws-service.ts"; import { hashPassword } from "../lib/jwt.ts"; +import { linkAttachments } from "./attachment-service.ts"; const USER_SELECT = `SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, @@ -147,6 +148,10 @@ export async function updateUser( throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); } + if (updatedUser.description) { + linkAttachments(updatedUser.description, userId); + } + return updatedUser; } diff --git a/api/services/vote-service.ts b/api/services/vote-service.ts index 5a5cbd3..a409a4e 100644 --- a/api/services/vote-service.ts +++ b/api/services/vote-service.ts @@ -3,6 +3,7 @@ import { db } from "../model/db.ts"; import { notifyDumpOwnerUpvote } from "./notification-service.ts"; export function castVote(dumpId: string, userId: string): number { + let voteCount: number; try { db.exec("BEGIN;"); db.prepare( @@ -15,10 +16,11 @@ export function castVote(dumpId: string, userId: string): number { `SELECT vote_count FROM dumps WHERE id = ?;`, ).get(dumpId) as { vote_count: number } | undefined; db.exec("COMMIT;"); - notifyDumpOwnerUpvote(userId, dumpId); - return row?.vote_count ?? 0; + voteCount = row?.vote_count ?? 0; } catch (err) { - db.exec("ROLLBACK;"); + try { + db.exec("ROLLBACK;"); + } catch { /* ignore if no active transaction */ } if (err instanceof Error && err.message.includes("UNIQUE constraint")) { throw new APIException( APIErrorCode.VALIDATION_ERROR, @@ -28,6 +30,11 @@ export function castVote(dumpId: string, userId: string): number { } throw err; } + // Notification is best-effort — must not prevent vote_ack from being sent + try { + notifyDumpOwnerUpvote(userId, dumpId); + } catch { /* ignore */ } + return voteCount; } export function removeVote(dumpId: string, userId: string): number { diff --git a/api/sql/schema.sql b/api/sql/schema.sql index db501ab..2fb6afd 100644 --- a/api/sql/schema.sql +++ b/api/sql/schema.sql @@ -116,6 +116,15 @@ CREATE TABLE invites ( FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE attachments ( + id TEXT PRIMARY KEY, + resource_id TEXT, + mime TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_attachments_resource ON attachments(resource_id); + CREATE TABLE notifications ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, diff --git a/src/App.css b/src/App.css index 4fb644c..da13840 100644 --- a/src/App.css +++ b/src/App.css @@ -59,6 +59,12 @@ line-height: 1.25; } +.md img { + max-width: 100%; + height: auto; + display: block; +} + /* Compact / card mode: strip vertical spacing */ .md--inline p, .md--inline ul, @@ -1459,6 +1465,23 @@ body.has-player .fab-new { flex-wrap: wrap; } +.app-header-status { + margin: 1rem auto 0 auto; + max-width: 860px; + padding: 0.75rem 1rem; + border: 1px solid color-mix(in srgb, var(--color-danger) 30%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--color-danger-bg) 92%, white 8%); + color: var(--color-text); + line-height: 1.5; + overflow-wrap: anywhere; + word-break: break-word; +} + +.app-header-status strong { + font-size: 0.95rem; +} + .page-error-wrap { margin: 2rem auto; max-width: 480px; @@ -3427,6 +3450,12 @@ body.has-player .fab-new { width: 100%; box-sizing: border-box; } + +.mention-textarea-wrap--dragover textarea { + outline: 2px dashed var(--color-accent, #6c8ebf); + outline-offset: -2px; +} + .mention-dropdown { position: absolute; top: 100%; diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index f2fee43..b44149c 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,6 +1,7 @@ import { type ReactNode, useState } from "react"; import { Link, useNavigate } from "react-router"; import { useAuth } from "../hooks/useAuth.ts"; +import { useWS } from "../hooks/useWS.ts"; import { DumpCreateModal } from "./DumpCreateModal.tsx"; import { NotificationBell } from "./NotificationBell.tsx"; @@ -8,6 +9,7 @@ export function AppHeader( { centerSlot, disableNew }: { centerSlot?: ReactNode; disableNew?: boolean }, ) { const { user } = useAuth(); + const { wsStatus, wsErrorMessage } = useWS(); const navigate = useNavigate(); const [createModalOpen, setCreateModalOpen] = useState(false); @@ -60,6 +62,12 @@ export function AppHeader( + {wsStatus === "disconnected" && wsErrorMessage && ( +
+ Live updates unavailable. {wsErrorMessage} +
+ )} + {createModalOpen && ( setCreateModalOpen(false)} /> )} diff --git a/src/components/TextEditor.tsx b/src/components/TextEditor.tsx index 63ace1e..b537241 100644 --- a/src/components/TextEditor.tsx +++ b/src/components/TextEditor.tsx @@ -1,8 +1,17 @@ -import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, +} from "react"; import { EmojiPicker } from "frimousse"; import { MentionDropdown } from "./MentionDropdown.tsx"; import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts"; import { useEmojiTrigger } from "../hooks/useEmojiTrigger.ts"; +import { useAuth } from "../hooks/useAuth.ts"; +import { API_URL } from "../config/api.ts"; export interface TextEditorHandle { focus(): void; @@ -40,6 +49,14 @@ export const TextEditor = forwardRef( const textareaRef = useRef(null); const emojiViewportRef = useRef(null); const emojiSearchRef = useRef(null); + const valueRef = useRef(value); + const [uploading, setUploading] = useState(false); + const [dragOver, setDragOver] = useState(false); + const { authFetch } = useAuth(); + + useEffect(() => { + valueRef.current = value; + }, [value]); useImperativeHandle(ref, () => ({ focus: () => textareaRef.current?.focus(), @@ -76,8 +93,89 @@ export const TextEditor = forwardRef( el.style.height = `${el.scrollHeight}px`; }, [value, autoResize]); + const insertAtCursor = useCallback((text: string) => { + const el = textareaRef.current; + if (!el) return; + const start = el.selectionStart ?? valueRef.current.length; + const end = el.selectionEnd ?? valueRef.current.length; + const newValue = valueRef.current.slice(0, start) + + text + + valueRef.current.slice(end); + onChange(newValue); + requestAnimationFrame(() => { + if (!textareaRef.current) return; + const pos = start + text.length; + textareaRef.current.selectionStart = pos; + textareaRef.current.selectionEnd = pos; + }); + }, [onChange]); + + const uploadImage = useCallback(async (file: File) => { + const formData = new FormData(); + formData.append("file", file); + setUploading(true); + try { + const res = await authFetch(`${API_URL}/api/attachments`, { + method: "POST", + body: formData, + }); + const json = await res.json(); + if (json.success) { + insertAtCursor(`![](${API_URL}/api/attachments/${json.data.id})`); + } + } catch { + // silently ignore — user can retry + } finally { + setUploading(false); + textareaRef.current?.focus(); + } + }, [authFetch, insertAtCursor]); + + const handlePaste = useCallback( + async (e: React.ClipboardEvent) => { + const imageItem = Array.from(e.clipboardData.items).find( + (item) => item.kind === "file" && item.type.startsWith("image/"), + ); + if (!imageItem) return; + e.preventDefault(); + const file = imageItem.getAsFile(); + if (file) await uploadImage(file); + }, + [uploadImage], + ); + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const files = Array.from(e.dataTransfer.files).filter((f) => + f.type.startsWith("image/") + ); + for (const file of files) { + await uploadImage(file); + } + }, + [uploadImage], + ); + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + const hasImage = Array.from(e.dataTransfer.items).some( + (item) => item.kind === "file" && item.type.startsWith("image/"), + ); + if (!hasImage) return; + e.preventDefault(); + setDragOver(true); + }, + [], + ); + + const handleDragLeave = useCallback(() => setDragOver(false), []); + return ( -
+