diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1286add --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +.env.* +api/sql/gerbeur.db +api/uploads/ diff --git a/.env.example b/.env.example index 128a152..3fd053b 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,31 @@ +# ── API server ──────────────────────────────────────────────────────────────── + +# Protocol the API server listens on (http or https) GERBEUR_PROTOCOL=http + +# Public hostname for the API (used in generated URLs, e.g. OG image tags) GERBEUR_HOSTNAME=localhost + +# Network interface Oak binds to. Default: 0.0.0.0 (all interfaces, required for Docker). +# Set to 127.0.0.1 to restrict to loopback only. +GERBEUR_LISTEN_HOST=0.0.0.0 + +# Port the API server listens on GERBEUR_PORT=8000 -JWT_SECRET= + +# 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. +# Example: http://localhost:3000,http://127.0.0.1:3000 +GERBEUR_ALLOWED_ORIGINS=http://localhost:3000 + +# Secret key used to sign JWTs. Generate with: openssl rand -hex 32 +GERBEUR_JWT_SECRET= + +# ── Frontend (Vite) ─────────────────────────────────────────────────────────── +# These must be prefixed with VITE_ to be exposed to the client bundle. +# They tell the frontend where the API server is reachable from the browser. VITE_API_PROTOCOL=http -VITE_SERVER_HOST=localhost -VITE_SERVER_PORT=8000 \ No newline at end of file +VITE_API_HOSTNAME=localhost +VITE_API_PORT=8000 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3678c8d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# ── Stage 1: build Vite frontend ───────────────────────────────────────────── +FROM denoland/deno:2.7.5 AS builder + +WORKDIR /app + +COPY deno.json deno.lock package.json ./ +COPY tsconfig*.json vite.config.ts ./ +RUN deno install + +COPY index.html ./ +COPY public/ ./public/ +COPY src/ ./src/ + +# In same-origin deployments (API serves the frontend), no build args are needed +# — the frontend uses relative URLs at runtime. Only set VITE_API_HOSTNAME if +# the API lives on a different host than the frontend (e.g. a separate API server). +ARG VITE_API_PROTOCOL +ARG VITE_API_HOSTNAME +ARG VITE_API_PORT +ENV VITE_API_PROTOCOL=$VITE_API_PROTOCOL \ + VITE_API_HOSTNAME=$VITE_API_HOSTNAME \ + VITE_API_PORT=$VITE_API_PORT + +RUN deno task build + +# ── Stage 2: runtime ────────────────────────────────────────────────────────── +FROM denoland/deno:2.7.5 + +WORKDIR /app + +COPY deno.json deno.lock ./ +RUN deno install + +COPY api/ ./api/ +COPY --from=builder /app/dist/ ./dist/ +COPY public/ ./public/ + +# Persistent data: database and user uploads must be mounted as volumes. +VOLUME ["/app/api/sql", "/app/api/uploads"] + +EXPOSE 8000 + +CMD ["sh", "-c", "deno run -A api/sql/init.ts && exec deno run -A api/main.ts"] diff --git a/README.md b/README.md index 7dbf7eb..267e9eb 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,119 @@ -# React + TypeScript + Vite +# gerbeur -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +A small invite-only social platform for sharing links and files. Users can post URLs and media (YouTube, SoundCloud, Bandcamp, images, …), vote, comment, follow each other, and build playlists. A real-time WebSocket layer handles live presence, vote counts, and notifications. -Currently, two official plugins are available: +## Stack -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +| Layer | Technology | +| -------- | ------------------------------------------------- | +| Runtime | [Deno](https://deno.com) 2.x | +| API | [Oak](https://jsr.io/@oak/oak) (HTTP + WebSocket) | +| Database | SQLite via `node:sqlite` | +| Frontend | React 19 + Vite 8 | -## React Compiler +## Development -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +### Prerequisites -## Expanding the ESLint configuration +- Deno 2.x -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +### Setup -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```sh +cp .env.example .env +# Edit .env: set GERBEUR_JWT_SECRET to the output of: openssl rand -hex 32 ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +### Run -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```sh +deno task dev +``` + +This starts both the API server (port 8000, with file watching) and the Vite dev server (port 3000) concurrently. + +Open [http://localhost:3000](http://localhost:3000). On first run a default `admin` / `admin` account is created — change the password immediately. + +### Environment variables + +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 | + +## 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. + +```sh +docker build -t gerbeur . + +docker run -d \ + -p 8000:8000 \ + -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 \ + --name gerbeur \ + gerbeur +``` + +The two volumes are required for persistence: +- `gerbeur-db` — SQLite database (`api/sql/gerbeur.db`), initialized automatically on first run +- `gerbeur-uploads` — user-uploaded files (`api/uploads/`) + +#### 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: + +```sh +docker build \ + --build-arg VITE_API_PROTOCOL=https \ + --build-arg VITE_API_HOSTNAME=api.example.com \ + --build-arg VITE_API_PORT=443 \ + -t gerbeur . +``` + +### Reverse proxy + +Put a reverse proxy (nginx, Caddy, …) in front of the container to handle TLS. Forward everything to port 8000. Example Caddyfile: + +``` +example.com { + reverse_proxy localhost:8000 +} +``` + +## Project structure + +``` +api/ + main.ts # Entry point — Oak application, middleware, routes + config.ts # Environment variables + middleware/ # errorMiddleware, authMiddleware + routes/ # HTTP routes + WebSocket + services/ # Business logic + model/ # Database schema, row types, type guards + lib/ # JWT, pagination, upload helpers, … + sql/ + schema.sql # Database schema + init.ts # First-run database initialisation + gerbeur.db # SQLite database (not committed) + uploads/ # User-uploaded files (not committed) +src/ # React frontend (Vite) + config/api.ts # API base URL, validation constants + pages/ # Route-level components + components/ # Shared UI components + contexts/ # Auth, WebSocket, player, follows + hooks/ # Data fetching and UI hooks +public/ # Static assets (favicon, icon sprite) ``` diff --git a/api/config.ts b/api/config.ts index af864e4..91f953a 100644 --- a/api/config.ts +++ b/api/config.ts @@ -1,4 +1,14 @@ export const PROTOCOL = Deno.env.get("GERBEUR_PROTOCOL") || "http"; export const HOSTNAME = Deno.env.get("GERBEUR_HOSTNAME") || "localhost"; export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000; +// GERBEUR_LISTEN_HOST controls the network interface Oak binds to. +// Defaults to 0.0.0.0 so Docker port-forwarding works out of the box. +// Set to 127.0.0.1 to restrict to loopback only. +export const LISTEN_HOST = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0"; 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) + : []; diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 121e4d6..78fa59c 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -8,10 +8,10 @@ import { isInvitePayload, } from "../model/interfaces.ts"; -const jwtSecret = Deno.env.get("JWT_SECRET"); +const jwtSecret = Deno.env.get("GERBEUR_JWT_SECRET"); if (!jwtSecret) { throw new Error( - "JWT_SECRET environment variable is required. Generate one with: openssl rand -hex 32", + "GERBEUR_JWT_SECRET environment variable is required. Generate one with: openssl rand -hex 32", ); } const JWT_KEY = new TextEncoder().encode(jwtSecret); diff --git a/api/lib/static.ts b/api/lib/static.ts index 999ccad..7746e6d 100644 --- a/api/lib/static.ts +++ b/api/lib/static.ts @@ -1,10 +1,13 @@ -import { Context, Next } from "@oak/oak"; +import { Context, Next, send } from "@oak/oak"; -export default function routeStaticFilesFrom(staticPaths: string[]) { +export function routeStaticFilesFrom(staticPaths: string[]) { return async (context: Context>, next: Next) => { for (const path of staticPaths) { try { - await context.send({ root: path, index: "index.html" }); + await send(context, context.request.url.pathname, { + root: path, + index: "index.html", + }); return; } catch { continue; diff --git a/api/main.ts b/api/main.ts index 2c6d126..1b63c06 100644 --- a/api/main.ts +++ b/api/main.ts @@ -13,16 +13,27 @@ import followsRouter from "./routes/follows.ts"; import notificationsRouter from "./routes/notifications.ts"; import invitesRouter from "./routes/invites.ts"; -import { BASE_URL, HOSTNAME, PORT } from "./config.ts"; +import { ALLOWED_ORIGINS, BASE_URL, LISTEN_HOST, PORT } from "./config.ts"; import { errorMiddleware } from "./middleware/error.ts"; -import routeStaticFilesFrom from "./lib/static.ts"; -import { DUMPS_DIR, UPLOADS_DIR } from "./lib/upload.ts"; -import { UUID_RE } from "./lib/slugify.ts"; +import { ogMiddleware } from "./middleware/og.ts"; +import { routeStaticFilesFrom } from "./lib/static.ts"; const app = new Application(); +const cors = oakCors({ origin: ALLOWED_ORIGINS }); + app.use(errorMiddleware); -app.use(oakCors()); +app.use(ogMiddleware); +// Only invoke oakCors when the origin is present and allowed. +// The library calls next() without await when origin is falsy, which +// corrupts the response in Oak's async middleware chain. +app.use(async (ctx, next) => { + const origin = ctx.request.headers.get("origin"); + if (origin && ALLOWED_ORIGINS.includes(origin)) { + return await cors(ctx, next); + } + await next(); +}); app.use( dumpsRouter.routes(), dumpsRouter.allowedMethods(), @@ -82,22 +93,8 @@ app.addEventListener( (e) => console.log(`Uncaught error: ${e.message}`), ); -// Migrate dump files from uploads root to uploads/dumps subfolder -async function migrateDumpFiles() { - await Deno.mkdir(DUMPS_DIR, { recursive: true }); - for await (const entry of Deno.readDir(UPLOADS_DIR)) { - if (entry.isFile && UUID_RE.test(entry.name)) { - await Deno.rename( - `${UPLOADS_DIR}/${entry.name}`, - `${DUMPS_DIR}/${entry.name}`, - ).catch(() => {}); - } - } -} - if (import.meta.main) { - await migrateDumpFiles(); - await app.listen({ hostname: HOSTNAME, port: PORT }); + await app.listen({ hostname: LISTEN_HOST, port: PORT }); } export { app }; diff --git a/api/middleware/auth.ts b/api/middleware/auth.ts index 3d00912..0371d4a 100644 --- a/api/middleware/auth.ts +++ b/api/middleware/auth.ts @@ -5,6 +5,7 @@ import { APIException, type AuthPayload, } from "../model/interfaces.ts"; +import { getUserById } from "../services/user-service.ts"; export interface AuthContext extends Context { state: AuthState; @@ -32,6 +33,12 @@ export async function authMiddleware(ctx: AuthContext, next: Next) { throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Invalid token"); } + try { + getUserById(payload.userId); + } catch { + throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "User not found"); + } + ctx.state.user = payload; await next(); diff --git a/api/middleware/og.ts b/api/middleware/og.ts new file mode 100644 index 0000000..9f97a65 --- /dev/null +++ b/api/middleware/og.ts @@ -0,0 +1,146 @@ +import { Context, Next } from "@oak/oak"; +import { getDump } from "../services/dump-service.ts"; +import { getUserByUsername } from "../services/user-service.ts"; +import { getPlaylistById } from "../services/playlist-service.ts"; + +interface OGMeta { + title: string; + description?: string; + imageUrl?: string; + url: string; +} + +const SITE_NAME = "gerbeur"; + +function escapeAttr(s: string): string { + return s + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function buildTags(meta: OGMeta): string { + const card = meta.imageUrl ? "summary_large_image" : "summary"; + const tags = [ + `${escapeAttr(meta.title)}`, + ``, + ``, + ``, + ``, + ``, + ]; + if (meta.description) { + tags.push( + ``, + ``, + ); + } + if (meta.imageUrl) { + tags.push( + ``, + ); + } + return tags.join("\n "); +} + +function inject(html: string, meta: OGMeta): string { + return html + .replace(/[^<]*<\/title>/, "") + .replace("</head>", ` ${buildTags(meta)}\n </head>`); +} + +let cachedHtml: string | null | undefined; // undefined = not yet loaded, null = not found + +async function loadIndexHtml(): Promise<string | null> { + if (cachedHtml !== undefined) return cachedHtml; + for ( + const path of [`${Deno.cwd()}/dist/index.html`, `${Deno.cwd()}/index.html`] + ) { + try { + cachedHtml = await Deno.readTextFile(path); + return cachedHtml; + } catch { + continue; + } + } + cachedHtml = null; + return null; +} + +const DUMP_RE = /^\/dumps\/([^/]+)$/; +const USER_RE = /^\/users\/([^/]+)$/; +const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; + +export async function ogMiddleware(ctx: Context, next: Next) { + const { pathname } = ctx.request.url; + + if (pathname.startsWith("/api/") || pathname.includes(".")) { + return await next(); + } + + const origin = ctx.request.url.origin; + const pageUrl = `${origin}${pathname}`; + + let meta: OGMeta | null = null; + + const dumpMatch = pathname.match(DUMP_RE); + const userMatch = pathname.match(USER_RE); + const playlistMatch = pathname.match(PLAYLIST_RE); + + if (dumpMatch) { + try { + const dump = getDump(dumpMatch[1]); + let imageUrl: string | undefined; + if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) { + imageUrl = `${origin}/api/files/${dump.id}`; + } else if (dump.richContent?.thumbnailUrl) { + imageUrl = dump.richContent.thumbnailUrl; + } + meta = { + title: dump.title, + description: dump.comment, + imageUrl, + url: pageUrl, + }; + } catch { /* not found or private — serve default */ } + } else if (userMatch) { + try { + const user = getUserByUsername(userMatch[1]); + const imageUrl = user.avatarMime + ? `${origin}/api/avatars/${user.id}` + : undefined; + meta = { + title: user.username, + description: user.description, + imageUrl, + url: pageUrl, + }; + } catch { /* not found */ } + } else if (playlistMatch) { + try { + const playlist = getPlaylistById(playlistMatch[1]); + if (playlist.isPublic) { + const imageUrl = playlist.imageMime + ? `${origin}/api/playlists/${playlist.id}/image` + : undefined; + meta = { + title: playlist.title, + description: playlist.description, + imageUrl, + url: pageUrl, + }; + } + } catch { /* not found or private */ } + } + + if (!meta) return await next(); + + const html = await loadIndexHtml(); + if (!html) return await next(); + + ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); + ctx.response.body = inject(html, meta); +} diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 572d862..eeb57c1 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -558,6 +558,10 @@ export interface ErrorMessage { message?: string; } +export interface ForceLogoutMessage { + type: "force_logout"; +} + export type ServerToClientMessage = | PingMessage | WelcomeMessage @@ -576,7 +580,8 @@ export type ServerToClientMessage = | CommentUpdatedMessage | CommentDeletedMessage | NotificationCreatedMessage - | ErrorMessage; + | ErrorMessage + | ForceLogoutMessage; /** * Follows diff --git a/api/routes/ws.ts b/api/routes/ws.ts index 91c9e1d..d4877a3 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -1,4 +1,5 @@ import { Router } from "@oak/oak"; +import { ALLOWED_ORIGINS } from "../config.ts"; import { verifyJWT } from "../lib/jwt.ts"; import { broadcastPresence, @@ -24,8 +25,7 @@ import { const router = new Router(); function isAllowedOrigin(origin: string): boolean { - if (!origin) return false; - return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin); + return ALLOWED_ORIGINS.includes(origin); } router.get("/ws", async (ctx) => { @@ -45,12 +45,9 @@ router.get("/ws", async (ctx) => { const socket = ctx.upgrade(); - let avatarMime: string | undefined; - if (authPayload) { - try { - avatarMime = getUserById(authPayload.userId).avatarMime; - } catch { /* user not found */ } - } + const avatarMime = authPayload + ? getUserById(authPayload.userId).avatarMime + : undefined; const client: WsClient = { socket, diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index 7c4e433..0f68241 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -34,7 +34,7 @@ const PLAYLIST_SELECT = `p.*, u.username as owner_username, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count FROM playlists p LEFT JOIN users u ON u.id = p.user_id`; -function getPlaylistById(idOrSlug: string): Playlist { +export function getPlaylistById(idOrSlug: string): Playlist { const row = UUID_RE.test(idOrSlug) ? db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`).get(idOrSlug) : db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.slug = ?;`).get(idOrSlug); diff --git a/api/services/providers/self.ts b/api/services/providers/self.ts new file mode 100644 index 0000000..b5c5d5f --- /dev/null +++ b/api/services/providers/self.ts @@ -0,0 +1,83 @@ +import type { RichContent } from "../../model/interfaces.ts"; +import type { RichContentProvider } from "../rich-content-service.ts"; +import { getDump } from "../dump-service.ts"; +import { getUserByUsername } from "../user-service.ts"; +import { getPlaylistById } from "../playlist-service.ts"; +import { BASE_URL } from "../../config.ts"; + +const DUMP_RE = /^\/dumps\/([^/]+)$/; +const USER_RE = /^\/users\/([^/]+)$/; +const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; + +export const selfProvider: RichContentProvider = { + name: "self", + + matches(url: string): boolean { + try { + const { pathname } = new URL(url); + return DUMP_RE.test(pathname) || + USER_RE.test(pathname) || + PLAYLIST_RE.test(pathname); + } catch { + return false; + } + }, + + fetch(url: string): Promise<RichContent> { + const { pathname } = new URL(url); + + const dumpMatch = pathname.match(DUMP_RE); + if (dumpMatch) { + const dump = getDump(dumpMatch[1]); + let thumbnailUrl: string | undefined; + if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) { + thumbnailUrl = `${BASE_URL}/api/files/${dump.id}`; + } else if (dump.richContent?.thumbnailUrl) { + thumbnailUrl = dump.richContent.thumbnailUrl; + } + return Promise.resolve({ + type: "generic", + url, + siteName: "gerbeur", + title: dump.title, + description: dump.comment, + thumbnailUrl, + }); + } + + const userMatch = pathname.match(USER_RE); + if (userMatch) { + const user = getUserByUsername(userMatch[1]); + const thumbnailUrl = user.avatarMime + ? `${BASE_URL}/api/avatars/${user.id}` + : undefined; + return Promise.resolve({ + type: "generic", + url, + siteName: "gerbeur", + title: user.username, + description: user.description, + thumbnailUrl, + }); + } + + const playlistMatch = pathname.match(PLAYLIST_RE); + if (playlistMatch) { + const playlist = getPlaylistById(playlistMatch[1]); + const thumbnailUrl = playlist.imageMime + ? `${BASE_URL}/api/playlists/${playlist.id}/image` + : undefined; + return Promise.resolve({ + type: "generic", + url, + siteName: "gerbeur", + title: playlist.title, + description: playlist.description, + thumbnailUrl, + }); + } + + // Should not reach here if matches() is correct + return Promise.resolve({ type: "generic", url }); + }, +}; diff --git a/api/services/rich-content-service.ts b/api/services/rich-content-service.ts index 0c3b58a..6682332 100644 --- a/api/services/rich-content-service.ts +++ b/api/services/rich-content-service.ts @@ -2,6 +2,7 @@ import type { RichContent } from "../model/interfaces.ts"; import { youtubeProvider } from "./providers/youtube.ts"; import { bandcampProvider } from "./providers/bandcamp.ts"; import { soundcloudProvider } from "./providers/soundcloud.ts"; +import { selfProvider } from "./providers/self.ts"; import { genericProvider } from "./providers/generic.ts"; export interface RichContentProvider { @@ -12,12 +13,14 @@ export interface RichContentProvider { /** * Register providers in priority order. The first match wins. + * `selfProvider` resolves gerbeur URLs directly from the DB (no HTTP round-trip). * `genericProvider` must stay last — it always matches. */ const providers: RichContentProvider[] = [ youtubeProvider, bandcampProvider, soundcloudProvider, + selfProvider, genericProvider, ]; @@ -86,11 +89,17 @@ function isPrivateHost(hostname: string): boolean { return /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname); } +const SELF_PATH_RE = /^\/(dumps|users|playlists)\/[^/]+$/; + export function isValidHttpUrl(raw: string): boolean { try { const u = new URL(raw); if (u.protocol !== "http:" && u.protocol !== "https:") return false; - if (isPrivateHost(u.hostname)) return false; + // Allow private hosts for self-referential gerbeur URLs — they are + // resolved directly from the DB by selfProvider, no outbound HTTP needed. + if (isPrivateHost(u.hostname) && !SELF_PATH_RE.test(u.pathname)) { + return false; + } return true; } catch { return false; diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 4140a1b..f02e201 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -6,6 +6,7 @@ import { type User, } from "../model/interfaces.ts"; import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts"; +import { disconnectUser } from "./ws-service.ts"; import { hashPassword } from "../lib/jwt.ts"; @@ -160,6 +161,8 @@ export function updateUserAvatar(userId: string, mime: string): void { } export function deleteUser(userId: string): void { + disconnectUser(userId); + const result = db.prepare( `DELETE FROM users WHERE id = ?;`, ).run(userId); diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 8396429..ebf76a7 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -66,6 +66,15 @@ export function sendToUser(userId: string, data: ServerToClientMessage): void { } } +export function disconnectUser(userId: string): void { + for (const client of clients) { + if (client.userId === userId) { + send(client.socket, { type: "force_logout" }); + client.socket.close(1000, "Account deleted"); + } + } +} + export function broadcastPresence(): void { const users = getOnlineUsers(); for (const client of clients) { diff --git a/api/sql/init.ts b/api/sql/init.ts new file mode 100644 index 0000000..10a94b9 --- /dev/null +++ b/api/sql/init.ts @@ -0,0 +1,15 @@ +import { DatabaseSync } from "node:sqlite"; + +const DB_FILE = "api/sql/gerbeur.db"; + +try { + await Deno.stat(DB_FILE); + console.log("Database already exists, skipping initialization."); +} catch { + console.log("Initializing database from schema..."); + const schema = Deno.readTextFileSync("api/sql/schema.sql"); + const db = new DatabaseSync(DB_FILE); + db.exec(schema); + db.close(); + console.log("Database initialized."); +} diff --git a/deno.json b/deno.json index c8fa91d..b8ac649 100644 --- a/deno.json +++ b/deno.json @@ -3,7 +3,7 @@ "dev": "deno run --env-file -A npm:vite & deno run -A server:start", "build": "deno run --env-file -A npm:vite build", "server:start": "deno run --env-file -A --watch api/main.ts", - "serve": "deno run --env-file -A build && deno run -A server:start" + "serve": "deno run build && deno run -A server:start" }, "nodeModulesDir": "auto", "compilerOptions": { diff --git a/src/App.tsx b/src/App.tsx index 787e34e..c6131a3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,9 +24,9 @@ import { GlobalPlayer } from "./components/GlobalPlayer.tsx"; import "./App.css"; function AppRoutes() { - const { token, user } = useAuth(); + const { token, user, logout } = useAuth(); return ( - <WSProvider token={token} userId={user?.id ?? null}> + <WSProvider token={token} userId={user?.id ?? null} onForceLogout={logout}> <FollowProvider> <BrowserRouter> <Routes> diff --git a/src/components/JournalCard.tsx b/src/components/JournalCard.tsx index 21ffe8b..d1aecad 100644 --- a/src/components/JournalCard.tsx +++ b/src/components/JournalCard.tsx @@ -29,7 +29,8 @@ export function JournalCard( ) { const navigate = useNavigate(); const { play } = useContext(PlayerContext); - const unread = !isOwner && isRecent(dump.createdAt) && !isDumpVisited(dump.id); + const unread = !isOwner && isRecent(dump.createdAt) && + !isDumpVisited(dump.id); function handleNavigate() { markDumpVisited(dump.id); @@ -73,8 +74,7 @@ export function JournalCard( </Tooltip> {dump.commentCount > 0 && ( <span> - {dump.commentCount}{" "} - {dump.commentCount === 1 ? "comment" : "comments"} + {dump.commentCount} {dump.commentCount === 1 ? "comment" : "comments"} </span> )} {dump.isPrivate && isOwner && ( @@ -145,7 +145,10 @@ export function JournalCard( if (tier === "medium") { return ( - <li className="journal-card journal-card--medium" onClick={handleNavigate}> + <li + className="journal-card journal-card--medium" + onClick={handleNavigate} + > <div className="journal-card-inner"> <div className="journal-card-icon"> {thumbnailUrl diff --git a/src/config/api.ts b/src/config/api.ts index 9bc8907..df41295 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -2,12 +2,25 @@ // include type declarations from the package vite/client /// <reference types="vite/client" /> -const apiProtocol = import.meta.env.VITE_API_PROTOCOL || "http"; -const serverHost = import.meta.env.VITE_SERVER_HOST || "localhost"; -const serverPort = import.meta.env.VITE_SERVER_PORT || "8000"; +// In dev (Vite dev server), the frontend and API run on different ports, so we +// need an absolute URL. VITE_API_* vars can override the defaults. +// +// In prod (same container), the frontend is served by the API server itself, so +// both share the same origin. We use relative URLs ("") so no build-time +// configuration is needed and the image works on any domain. +const apiHostname = import.meta.env.VITE_API_HOSTNAME; -export const API_URL = `${apiProtocol}://${serverHost}:${serverPort}`; -export const WS_URL = API_URL.replace(/^http/, "ws"); +export const API_URL: string = apiHostname + ? `${import.meta.env.VITE_API_PROTOCOL || "http"}://${apiHostname}:${ + import.meta.env.VITE_API_PORT || "8000" + }` + : import.meta.env.DEV + ? "http://localhost:8000" + : ""; + +export const WS_URL: string = API_URL + ? API_URL.replace(/^http/, "ws") + : `${location.protocol.replace("http", "ws")}//${location.host}`; export const DEFAULT_PAGE_SIZE = 20; export const NOTIFICATIONS_PAGE_SIZE = 30; diff --git a/src/contexts/WSProvider.tsx b/src/contexts/WSProvider.tsx index 98425c4..caba2b2 100644 --- a/src/contexts/WSProvider.tsx +++ b/src/contexts/WSProvider.tsx @@ -35,6 +35,7 @@ interface WSProviderProps { children: ReactNode; token: string | null; userId: string | null; + onForceLogout?: () => void; } const MAX_BACKOFF = 30_000; @@ -54,7 +55,9 @@ function parseWSMessage(data: string): IncomingWSMessage | null { } } -export function WSProvider({ children, token, userId }: WSProviderProps) { +export function WSProvider( + { children, token, userId, onForceLogout }: WSProviderProps, +) { const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]); const [voteCounts, setVoteCounts] = useState<Record<string, number>>({}); const [myVotes, setMyVotes] = useState<Set<string>>(new Set()); @@ -264,6 +267,10 @@ export function WSProvider({ children, token, userId }: WSProviderProps) { break; } + case "force_logout": + onForceLogout?.(); + break; + case "error": // On error, revert any pending optimistic update for the affected dump // (the revert timeout will handle it) diff --git a/src/model.ts b/src/model.ts index 35bf346..65de01f 100644 --- a/src/model.ts +++ b/src/model.ts @@ -396,6 +396,10 @@ export interface WSErrorMessage { message?: string; } +export interface WSForceLogoutMessage { + type: "force_logout"; +} + export type IncomingWSMessage = | WSPingMessage | WSWelcomeMessage @@ -414,7 +418,8 @@ export type IncomingWSMessage = | WSCommentUpdatedMessage | WSCommentDeletedMessage | WSNotificationCreatedMessage - | WSErrorMessage; + | WSErrorMessage + | WSForceLogoutMessage; /** * WebSocket messages — client → server (outgoing) diff --git a/src/pages/index/FollowedFeed.tsx b/src/pages/index/FollowedFeed.tsx index 61da58c..6034ac0 100644 --- a/src/pages/index/FollowedFeed.tsx +++ b/src/pages/index/FollowedFeed.tsx @@ -90,7 +90,7 @@ function FollowedSubFeed({ dump={dump} voteCount={voteCounts[dump.id] ?? dump.voteCount} voted={myVotes.has(dump.id)} - canVote={true} + canVote castVote={castVote} removeVote={removeVote} isOwner={user.id === dump.userId} @@ -217,7 +217,7 @@ export function FollowedFeed({ }) ); } - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [token]); // Scroll save @@ -324,9 +324,7 @@ export function FollowedFeed({ </button> <button type="button" - className={`feed-sort-btn${ - section === "playlists" ? " active" : "" - }`} + className={`feed-sort-btn${section === "playlists" ? " active" : ""}`} onClick={() => setSection("playlists")} > From playlists diff --git a/src/pages/index/JournalFeed.tsx b/src/pages/index/JournalFeed.tsx index 080980e..be37077 100644 --- a/src/pages/index/JournalFeed.tsx +++ b/src/pages/index/JournalFeed.tsx @@ -1,6 +1,9 @@ import { useMemo } from "react"; import { ErrorCard } from "../../components/ErrorCard.tsx"; -import { JournalCard, type JournalTier } from "../../components/JournalCard.tsx"; +import { + JournalCard, + type JournalTier, +} from "../../components/JournalCard.tsx"; import { hotScore } from "../../utils/hotScore.ts"; import type { MainFeedProps } from "./types.ts";