diff --git a/api/model/db.ts b/api/model/db.ts index b8ef507..c529234 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -1,3 +1,4 @@ +import { randomBytes, scryptSync } from "node:crypto"; import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; import { type Comment, @@ -17,6 +18,20 @@ db.prepare( `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`, ).run(); +// Create default admin user if no users exist +const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as { + count: number; +}; +if (userCount.count === 0) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync("admin", salt, 64).toString("hex"); + const passwordHash = `${hash}.${salt}`; + db.prepare( + `INSERT INTO users (id, username, password_hash, is_admin, created_at) VALUES (?, 'admin', ?, 1, datetime('now'))`, + ).run(crypto.randomUUID(), passwordHash); + console.log("Created default admin user (username: admin, password: admin)"); +} + /** * Database Row Types */ @@ -70,6 +85,9 @@ export function isDumpRow(obj: Record): obj is DumpRow { (typeof obj.comment === "string" || obj.comment === null) && "user_id" in obj && typeof obj.user_id === "string" && "created_at" in obj && typeof obj.created_at === "string" && + "updated_at" in obj && + (typeof obj.updated_at === "string" || obj.updated_at === null) && + "slug" in obj && (typeof obj.slug === "string" || obj.slug === null) && "url" in obj && (typeof obj.url === "string" || obj.url === null) && "rich_content" in obj && (typeof obj.rich_content === "string" || obj.rich_content === null) && @@ -92,10 +110,14 @@ export function isUserRow(obj: Record): obj is UserRow { "password_hash" in obj && typeof obj.password_hash === "string" && "is_admin" in obj && typeof obj.is_admin === "number" && "created_at" in obj && typeof obj.created_at === "string" && + "updated_at" in obj && + (typeof obj.updated_at === "string" || obj.updated_at === null) && "avatar_mime" in obj && (typeof obj.avatar_mime === "string" || obj.avatar_mime === null) && "description" in obj && - (typeof obj.description === "string" || obj.description === null); + (typeof obj.description === "string" || obj.description === null) && + "invited_by" in obj && + (typeof obj.invited_by === "string" || obj.invited_by === null); } /** @@ -108,11 +130,11 @@ export function dumpRowToApi(row: DumpRow): Dump { kind: row.kind as "url" | "file", title: row.title, slug: row.slug ?? undefined, + url: row.url ?? undefined, comment: row.comment ?? undefined, userId: row.user_id, createdAt: new Date(row.created_at), updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, - url: row.url ?? undefined, richContent: row.rich_content ? (JSON.parse(row.rich_content) as RichContent) : undefined, @@ -201,6 +223,7 @@ export function isCommentRow( (typeof obj.parent_id === "string" || obj.parent_id === null) && typeof obj.body === "string" && typeof obj.created_at === "string" && + (typeof obj.updated_at === "string" || obj.updated_at === null) && typeof obj.deleted === "number" && typeof obj.author_username === "string" && (typeof obj.author_avatar_mime === "string" || @@ -241,8 +264,12 @@ export function isPlaylistRow( return !!obj && typeof obj.id === "string" && typeof obj.user_id === "string" && typeof obj.title === "string" && + (typeof obj.slug === "string" || obj.slug === null) && + (typeof obj.description === "string" || obj.description === null) && typeof obj.is_public === "number" && - typeof obj.created_at === "string"; + typeof obj.created_at === "string" && + (typeof obj.updated_at === "string" || obj.updated_at === null) && + (typeof obj.image_mime === "string" || obj.image_mime === null); } export function playlistRowToApi(row: PlaylistRow): Playlist { @@ -307,7 +334,8 @@ export function isNotificationRow( typeof obj.type === "string" && typeof obj.data === "string" && typeof obj.read === "number" && - typeof obj.created_at === "string"; + typeof obj.created_at === "string" && + (typeof obj.source_key === "string" || obj.source_key === null); } export function notificationRowToApi(row: NotificationRow): Notification { diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index a2aff0a..232c281 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -89,16 +89,33 @@ export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest { export function isRegisterUserRequest( obj: unknown, ): obj is RegisterUserRequest { + return validateRegisterUserRequest(obj) === null; +} + +/** Returns a human-readable error string, or null if the request is valid. */ +export function validateRegisterUserRequest(obj: unknown): string | null { if ( !obj || typeof obj !== "object" || !("username" in obj) || typeof obj.username !== "string" || !("password" in obj) || typeof obj.password !== "string" || !("inviteToken" in obj) || typeof obj.inviteToken !== "string" - ) return false; + ) return "Invalid request"; const { username, password } = obj as RegisterUserRequest; - return /^[a-zA-Z0-9_]{1,32}$/.test(username) && - password.length >= VALIDATION.PASSWORD_MIN && - password.length <= VALIDATION.PASSWORD_MAX; + if ( + !new RegExp( + `^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`, + ) + .test(username) + ) { + return `Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`; + } + if (password.length < VALIDATION.PASSWORD_MIN) { + return `Password must be at least ${VALIDATION.PASSWORD_MIN} characters`; + } + if (password.length > VALIDATION.PASSWORD_MAX) { + return `Password must be at most ${VALIDATION.PASSWORD_MAX} characters`; + } + return null; } export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { @@ -310,7 +327,10 @@ export function isCreatePlaylistRequest( !("isPublic" in obj) || typeof obj.isPublic !== "boolean" ) return false; const o = obj as Record; - if ((o.title as string).length === 0 || (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX) return false; + if ( + (o.title as string).length === 0 || + (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX + ) return false; if ( "description" in o && typeof o.description !== "string" && o.description !== null @@ -329,7 +349,10 @@ export function isUpdatePlaylistRequest( const o = obj as Record; if ("title" in o) { if (typeof o.title !== "string") return false; - if ((o.title as string).length === 0 || (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX) return false; + if ( + (o.title as string).length === 0 || + (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX + ) return false; } if ( "description" in o && typeof o.description !== "string" && diff --git a/api/routes/users.ts b/api/routes/users.ts index b9cea55..8e823a0 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -4,9 +4,9 @@ import { APIErrorCode, APIException, isLoginUserRequest, - isRegisterUserRequest, isUpdateUserRequest, type PaginatedData, + validateRegisterUserRequest, } from "../model/interfaces.ts"; import { createJWT, verifyPassword } from "../lib/jwt.ts"; @@ -36,12 +36,9 @@ const router = new Router({ prefix: "/api/users" }); router.post("/register", async (ctx) => { const body = await ctx.request.body.json(); - if (!isRegisterUserRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid request", - ); + const registerError = validateRegisterUserRequest(body); + if (registerError) { + throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError); } // Validate invite — throws 404/409 if bad diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 87cbf3c..93b14b2 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -49,13 +49,13 @@ function titleFromUrl(url: string): string { } const BASE_COLS = - "id, kind, title, slug, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private"; + "id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private"; const SELECT_COLS = `${BASE_COLS}, (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`; const SELECT_COLS_ALIASED = - "d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," + + "d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," + " (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count"; export async function createUrlDump( diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index cfde2e8..31a2a5a 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -20,7 +20,7 @@ import { // Mirrors dump-service SELECT_COLS_ALIASED — kept local to avoid circular imports const SELECT_COLS_ALIASED = - "d.id, d.kind, d.title, d.comment, d.user_id, d.created_at, d.url, d.rich_content, " + + "d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, " + "d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," + " (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count"; diff --git a/api/sql/schema.sql b/api/sql/schema.sql index f498094..db501ab 100644 --- a/api/sql/schema.sql +++ b/api/sql/schema.sql @@ -7,6 +7,7 @@ CREATE TABLE dumps ( created_at TEXT NOT NULL, updated_at TEXT, url TEXT, + slug TEXT, rich_content TEXT, file_name TEXT, file_mime TEXT, @@ -41,6 +42,7 @@ CREATE TABLE playlists ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, + slug TEXT, description TEXT, is_public INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index cfadeb1..3395a59 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -17,7 +17,9 @@ function preprocessMentions(text: string): string { } // Static components object — defined once at module scope to avoid recreation on every render -const MARKDOWN_COMPONENTS: React.ComponentProps["components"] = { +const MARKDOWN_COMPONENTS: React.ComponentProps< + typeof ReactMarkdown +>["components"] = { a: ({ href, children: linkChildren }) => { if (href?.startsWith("/users/")) { return {linkChildren}; diff --git a/src/contexts/FollowProvider.tsx b/src/contexts/FollowProvider.tsx index db018d5..be5f3a8 100644 --- a/src/contexts/FollowProvider.tsx +++ b/src/contexts/FollowProvider.tsx @@ -1,4 +1,10 @@ -import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { FollowContext, type FollowContextValue } from "./FollowContext.ts"; import { API_URL } from "../config/api.ts"; import { useAuth } from "../hooks/useAuth.ts"; diff --git a/src/contexts/WSProvider.tsx b/src/contexts/WSProvider.tsx index 53029c5..418bf93 100644 --- a/src/contexts/WSProvider.tsx +++ b/src/contexts/WSProvider.tsx @@ -63,7 +63,12 @@ function isStringArray(val: unknown): val is string[] { function isVotesUpdatePayload( msg: Record, -): msg is { dumpId: string; voteCount: number; voterId: string; action: "cast" | "remove" } { +): msg is { + dumpId: string; + voteCount: number; + voterId: string; + action: "cast" | "remove"; +} { return typeof msg.dumpId === "string" && typeof msg.voteCount === "number" && typeof msg.voterId === "string" && @@ -164,7 +169,9 @@ export function WSProvider({ children, token, userId }: WSProviderProps) { case "welcome": { backoff = 500; // reset backoff on successful connect - if (!isOnlineUserArray(msg.users) || !isStringArray(msg.myVotes)) break; + if (!isOnlineUserArray(msg.users) || !isStringArray(msg.myVotes)) { + break; + } setOnlineUsers(msg.users); setMyVotes(new Set(msg.myVotes)); setUnreadNotificationCount( @@ -320,7 +327,9 @@ export function WSProvider({ children, token, userId }: WSProviderProps) { } case "notification_created": { - if (!msg.notification || typeof msg.notification !== "object") break; + if (!msg.notification || typeof msg.notification !== "object") { + break; + } const notification = deserializeNotification( msg.notification as RawNotification, ); diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index 64b5e30..f1a1ce8 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -148,7 +148,10 @@ export function Dump() { // Compare against the loaded dump's actual ID. const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null; useEffect(() => { - if (!lastCommentEvent || !loadedDumpId || lastCommentEvent.dumpId !== loadedDumpId) return; + if ( + !lastCommentEvent || !loadedDumpId || + lastCommentEvent.dumpId !== loadedDumpId + ) return; if (lastCommentEvent.type === "created" && lastCommentEvent.comment) { setComments((prev) => { if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) { diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 04e0376..54d3471 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -255,9 +255,12 @@ export function Index() { loadingMore: false, }); } else { - fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`, { - headers: { Authorization: `Bearer ${token}` }, - }) + fetch( + `${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`, + { + headers: { Authorization: `Bearer ${token}` }, + }, + ) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index b613c1b..f165717 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -217,7 +217,9 @@ export function Notifications() { useEffect(() => { // 1. Fetch with original read state so unread items are highlighted // 2. Only after displaying, mark all read on the server - authFetch(`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`) + authFetch( + `${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`, + ) .then((r) => r.json()) .then((body) => { if (!body.success) throw new Error("Failed to load"); diff --git a/src/pages/UserDumps.tsx b/src/pages/UserDumps.tsx index a797326..d0c32e4 100644 --- a/src/pages/UserDumps.tsx +++ b/src/pages/UserDumps.tsx @@ -10,7 +10,11 @@ import { Link, useParams } from "react-router"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { friendlyFetchError } from "../utils/apiError.ts"; import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts"; -import { deserializeDump, deserializePublicUser, hydrateDump } from "../model.ts"; +import { + deserializeDump, + deserializePublicUser, + hydrateDump, +} from "../model.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { useDumpListSync } from "../hooks/useDumpListSync.ts"; diff --git a/src/pages/UserPlaylists.tsx b/src/pages/UserPlaylists.tsx index 191b78c..cb6dcab 100644 --- a/src/pages/UserPlaylists.tsx +++ b/src/pages/UserPlaylists.tsx @@ -15,7 +15,11 @@ import type { PublicUser, RawPlaylist, } from "../model.ts"; -import { deserializePlaylist, deserializePublicUser, hydratePlaylist } from "../model.ts"; +import { + deserializePlaylist, + deserializePublicUser, + hydratePlaylist, +} from "../model.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts"; diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index c6b82ca..56b3425 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -882,7 +882,9 @@ function UpvotedDumpList( // be called in the same effect body — guaranteeing a single render where the // dump is always in visibleDumps (with or without fading class). This prevents // the DOM node from being unmounted/remounted, which would break CSS transitions. - const [votedIds, setVotedIds] = useState(() => new Set(dumps.map((d) => d.id))); + const [votedIds, setVotedIds] = useState(() => + new Set(dumps.map((d) => d.id)) + ); const prevMyVotesRef = useRef | null>(null); // Own profile: sync votedIds with myVotes; start/cancel fading in same batch. @@ -895,8 +897,8 @@ function UpvotedDumpList( } const prev = prevMyVotesRef.current; setVotedIds(new Set(wsMyVotes)); - for (const id of prev) { if (!wsMyVotes.has(id)) startFading(id); } - for (const id of wsMyVotes) { if (!prev.has(id)) cancelFading(id); } + for (const id of prev) if (!wsMyVotes.has(id)) startFading(id); + for (const id of wsMyVotes) if (!prev.has(id)) cancelFading(id); prevMyVotesRef.current = new Set(wsMyVotes); }, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]); @@ -906,7 +908,11 @@ function UpvotedDumpList( const { dumpId, voterId, action } = lastVoteEvent; if (voterId !== profileUserId) return; if (action === "remove") { - setVotedIds((prev) => { const n = new Set(prev); n.delete(dumpId); return n; }); + setVotedIds((prev) => { + const n = new Set(prev); + n.delete(dumpId); + return n; + }); startFading(dumpId); } else { setVotedIds((prev) => new Set([...prev, dumpId])); @@ -914,7 +920,9 @@ function UpvotedDumpList( } }, [lastVoteEvent, profileUserId, isOwnProfile, startFading, cancelFading]); - const visibleDumps = dumps.filter((d) => votedIds.has(d.id) || d.id in fading); + const visibleDumps = dumps.filter((d) => + votedIds.has(d.id) || d.id in fading + ); return (
diff --git a/src/pages/UserRegister.tsx b/src/pages/UserRegister.tsx index e6e9432..d282228 100644 --- a/src/pages/UserRegister.tsx +++ b/src/pages/UserRegister.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import type { SubmitEvent } from "react"; import { Link, useNavigate, useSearchParams } from "react-router"; -import { API_URL } from "../config/api.ts"; +import { API_URL, VALIDATION } from "../config/api.ts"; import { deserializeAuthResponse } from "../model.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { PageShell } from "../components/PageShell.tsx"; @@ -109,14 +109,18 @@ export function UserRegister() { type="text" placeholder="Username" required + pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`} + title={`${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`} disabled={formState.status === "submitting"} autoFocus />