v3: added site-wide categories, added admin category management, various visual fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m52s

This commit is contained in:
khannurien
2026-06-28 16:23:44 +00:00
parent c8c7b05c25
commit fae25f3e6c
44 changed files with 2144 additions and 399 deletions

View File

@@ -86,6 +86,8 @@ export const VALIDATION = {
PLAYLIST_DESCRIPTION_MAX: 2000,
COMMENT_BODY_MAX: 5000,
USER_DESCRIPTION_MAX: 2000,
CATEGORY_NAME_MAX: 50,
CATEGORY_SLUG_MAX: 50,
} as const;
// SEO/OG

View File

@@ -4,6 +4,7 @@ import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts";
import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts";
import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts";
import { up as up0006Categories } from "./migrations/0006_categories.ts";
interface Migration {
name: string;
@@ -19,6 +20,7 @@ const MIGRATIONS: Migration[] = [
{ name: "0003_dump_backlinks", up: up0003DumpBacklinks },
{ name: "0004_user_roles", up: up0004UserRoles },
{ name: "0005_drop_is_admin", up: up0005DropIsAdmin },
{ name: "0006_categories", up: up0006Categories },
];
export function runMigrations(db: DatabaseSync): void {

View File

@@ -0,0 +1,39 @@
import type { DatabaseSync } from "node:sqlite";
// Idempotent: safe to run against a fresh db (already created from schema.sql
// with the categories tables) or an existing one that predates them.
export function up(db: DatabaseSync): void {
const tables = new Set(
(db.prepare(
`SELECT name FROM sqlite_master WHERE type = 'table';`,
).all() as { name: string }[]).map((r) => r.name),
);
if (!tables.has("categories")) {
db.exec(
`CREATE TABLE categories (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT
);`,
);
}
if (!tables.has("dump_categories")) {
db.exec(
`CREATE TABLE dump_categories (
dump_id TEXT NOT NULL,
category_id TEXT NOT NULL,
PRIMARY KEY (dump_id, category_id),
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);`,
);
db.exec(
`CREATE INDEX idx_dump_categories_category ON dump_categories(category_id);`,
);
}
}

View File

@@ -63,6 +63,23 @@ CREATE TABLE playlist_dumps (
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE
);
CREATE TABLE categories (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT
);
CREATE TABLE dump_categories (
dump_id TEXT NOT NULL,
category_id TEXT NOT NULL,
PRIMARY KEY (dump_id, category_id),
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
);
CREATE TABLE comments (
id TEXT PRIMARY KEY,
dump_id TEXT NOT NULL,
@@ -108,6 +125,7 @@ CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
CREATE INDEX idx_comment_likes_user ON comment_likes(user_id);
CREATE INDEX idx_dump_backlinks_to ON dump_backlinks(to_dump_id);
CREATE INDEX idx_dump_backlinks_from ON dump_backlinks(from_dump_id);
CREATE INDEX idx_dump_categories_category ON dump_categories(category_id);
CREATE TABLE follows (
id TEXT PRIMARY KEY,

View File

@@ -6,13 +6,15 @@ export type Permission =
| "dump:moderate"
| "comment:moderate"
| "playlist:moderate"
| "user:manage";
| "user:manage"
| "category:manage";
const ALL_PERMISSIONS: readonly Permission[] = [
"dump:moderate",
"comment:moderate",
"playlist:moderate",
"user:manage",
"category:manage",
];
// Roles are fixed in code. `admin` is a superset of every permission (current

33
api/lib/reserved-slugs.ts Normal file
View File

@@ -0,0 +1,33 @@
// Top-level path segments that already have meaning in the SPA router
// (see src/App.tsx) plus a few reserved for infrastructure. Category slugs
// live at the URL root (e.g. /music), so they must never collide with these.
export const RESERVED_SLUGS: readonly string[] = [
"~",
"dumps",
"users",
"playlists",
"search",
"login",
"register",
"notifications",
"reset-password",
"manage",
"api",
"assets",
];
// Lowercase alphanumerics, hyphen-separated, no leading/trailing/double hyphen.
export const CATEGORY_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function isValidSlugFormat(slug: string): boolean {
return CATEGORY_SLUG_RE.test(slug);
}
export function isReservedSlug(slug: string): boolean {
return RESERVED_SLUGS.includes(slug);
}
/** A slug is available when it is well-formed and not reserved. */
export function isSlugAvailable(slug: string): boolean {
return isValidSlugFormat(slug) && !isReservedSlug(slug);
}

View File

@@ -15,6 +15,7 @@ import followsRouter from "./routes/follows.ts";
import notificationsRouter from "./routes/notifications.ts";
import invitesRouter from "./routes/invites.ts";
import searchRouter from "./routes/search.ts";
import categoriesRouter from "./routes/categories.ts";
import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts";
import { errorMiddleware } from "./middleware/error.ts";
@@ -93,6 +94,10 @@ app.use(
searchRouter.routes(),
searchRouter.allowedMethods(),
);
app.use(
categoriesRouter.routes(),
categoriesRouter.allowedMethods(),
);
app.use(routeStaticFilesFrom([
`${Deno.cwd()}/dist`,
`${Deno.cwd()}/public`,

View File

@@ -1,6 +1,7 @@
import { randomBytes, scryptSync } from "node:crypto";
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
import {
type Category,
type Comment,
Dump,
isRole,
@@ -352,6 +353,40 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
};
}
// ── Categories ────────────────────────────────────────────────────────────────
export interface CategoryRow {
id: string;
slug: string;
name: string;
position: number;
created_at: string;
updated_at: string | null;
[key: string]: SQLOutputValue;
}
export function isCategoryRow(obj: unknown): obj is CategoryRow {
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"slug" in obj && typeof obj.slug === "string" &&
"name" in obj && typeof obj.name === "string" &&
"position" in obj && typeof obj.position === "number" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null);
}
export function categoryRowToApi(row: CategoryRow): Category {
return {
id: row.id,
slug: row.slug,
name: row.name,
position: row.position,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
};
}
export interface FollowRow {
id: string;
follower_id: string;

View File

@@ -33,6 +33,54 @@ export interface Dump {
commentCount: number;
isPrivate: boolean;
thumbnailMime?: string;
categoryIds?: string[];
}
/**
* Categories
*/
export interface Category {
id: string;
slug: string;
name: string;
position: number;
createdAt: Date;
updatedAt?: Date;
}
export interface CreateCategoryRequest {
slug: string;
name: string;
position?: number;
}
export interface UpdateCategoryRequest {
slug?: string;
name?: string;
position?: number;
}
export function isCreateCategoryRequest(
obj: unknown,
): obj is CreateCategoryRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if (typeof o.slug !== "string") return false;
if (typeof o.name !== "string") return false;
if ("position" in o && typeof o.position !== "number") return false;
return true;
}
export function isUpdateCategoryRequest(
obj: unknown,
): obj is UpdateCategoryRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
if ("slug" in o && typeof o.slug !== "string") return false;
if ("name" in o && typeof o.name !== "string") return false;
if ("position" in o && typeof o.position !== "number") return false;
return true;
}
/**
@@ -390,10 +438,15 @@ export function isReorderPlaylistRequest(
* Request DTOs
*/
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string");
}
export interface CreateUrlDumpRequest {
url: string;
comment?: string;
isPrivate?: boolean;
categoryIds?: string[];
}
export function isCreateUrlDumpRequest(
@@ -412,6 +465,7 @@ export function isCreateUrlDumpRequest(
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false;
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
return true;
}
@@ -420,6 +474,7 @@ export interface UpdateDumpRequest {
title?: string;
comment?: string;
isPrivate?: boolean;
categoryIds?: string[];
}
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
@@ -439,6 +494,7 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
) return false;
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
if ("categoryIds" in o && !isStringArray(o.categoryIds)) return false;
return true;
}

117
api/routes/categories.ts Normal file
View File

@@ -0,0 +1,117 @@
import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
type APIResponse,
type Category,
type Dump,
isCreateCategoryRequest,
isUpdateCategoryRequest,
type PaginatedData,
} from "../model/interfaces.ts";
import { authMiddleware, requirePermission } from "../middleware/auth.ts";
import { parseOptionalAuth } from "../lib/auth.ts";
import { parsePagination } from "../lib/pagination.ts";
import {
createCategory,
deleteCategory,
getCategoryBySlug,
listCategories,
listDumpsByCategory,
updateCategory,
} from "../services/category-service.ts";
const router = new Router({ prefix: "/api/categories" });
router.get("/", (ctx) => {
const responseBody: APIResponse<Category[]> = {
success: true,
data: listCategories(),
};
ctx.response.body = responseBody;
});
router.get("/:slug", (ctx) => {
const category = getCategoryBySlug(ctx.params.slug);
const responseBody: APIResponse<Category> = {
success: true,
data: category,
};
ctx.response.body = responseBody;
});
router.get("/:slug/dumps", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = listDumpsByCategory(
ctx.params.slug,
page,
limit,
requestingUserId,
);
const responseBody: APIResponse<PaginatedData<Dump>> = {
success: true,
data: { items, total, hasMore: page * limit < total },
};
ctx.response.body = responseBody;
});
router.post(
"/",
authMiddleware,
requirePermission("category:manage"),
async (ctx) => {
const body = await ctx.request.body.json();
if (!isCreateCategoryRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid category data",
);
}
const category = createCategory(body);
const responseBody: APIResponse<Category> = {
success: true,
data: category,
};
ctx.response.status = 201;
ctx.response.body = responseBody;
},
);
router.put(
"/:id",
authMiddleware,
requirePermission("category:manage"),
async (ctx) => {
const body = await ctx.request.body.json();
if (!isUpdateCategoryRequest(body)) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
400,
"Invalid category data",
);
}
const category = updateCategory(ctx.params.id, body);
const responseBody: APIResponse<Category> = {
success: true,
data: category,
};
ctx.response.body = responseBody;
},
);
router.delete(
"/:id",
authMiddleware,
requirePermission("category:manage"),
(ctx) => {
deleteCategory(ctx.params.id);
const responseBody: APIResponse<null> = { success: true, data: null };
ctx.response.body = responseBody;
},
);
export default router;

View File

@@ -33,6 +33,19 @@ import { getRelatedDumps } from "../services/backlink-service.ts";
const router = new Router({ prefix: "/api/dumps" });
// Categories arrive on the multipart create path as a JSON-encoded string field.
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
if (typeof value !== "string" || !value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed.filter((v): v is string => typeof v === "string")
: [];
} catch {
return [];
}
}
router.post(
"/",
authMiddleware,
@@ -63,6 +76,7 @@ router.post(
userId,
isPrivate,
typeof title === "string" && title ? title : undefined,
parseCategoryIds(formData.get("categoryIds")),
);
} else {
const body = await ctx.request.body.json();
@@ -156,6 +170,9 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
file,
typeof comment === "string" && comment ? comment : undefined,
typeof title === "string" && title ? title : undefined,
formData.has("categoryIds")
? parseCategoryIds(formData.get("categoryIds"))
: undefined,
);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;

View File

@@ -16,6 +16,20 @@ import {
unfollowPlaylist,
unfollowUser,
} from "../services/follow-service.ts";
import { getCategoryIdBySlug } from "../services/category-service.ts";
// Resolves the optional ?category=<slug> filter. Returns the matching category
// id, or "missing" when a slug was given but matches nothing (so the feed comes
// back empty rather than unfiltered), or undefined when no filter was requested.
function resolveCategoryFilter(
params: URLSearchParams,
): string | "missing" | undefined {
const slug = params.get("category");
if (!slug) return undefined;
return getCategoryIdBySlug(slug) ?? "missing";
}
const EMPTY_FEED: { items: Dump[]; total: number } = { items: [], total: 0 };
const router = new Router({ prefix: "/api/follows" });
@@ -31,11 +45,10 @@ router.get("/status", authMiddleware, (ctx) => {
// GET /api/follows/feed/users?page=&limit=
router.get("/feed/users", authMiddleware, (ctx) => {
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedUsersDumpFeed(
ctx.state.user.userId,
page,
limit,
);
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
const { items, total } = categoryId === "missing"
? EMPTY_FEED
: getFollowedUsersDumpFeed(ctx.state.user.userId, page, limit, categoryId);
const data: PaginatedData<Dump> = {
items,
total,
@@ -48,11 +61,15 @@ router.get("/feed/users", authMiddleware, (ctx) => {
// GET /api/follows/feed/playlists?page=&limit=
router.get("/feed/playlists", authMiddleware, (ctx) => {
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedPlaylistsDumpFeed(
ctx.state.user.userId,
page,
limit,
);
const categoryId = resolveCategoryFilter(ctx.request.url.searchParams);
const { items, total } = categoryId === "missing"
? EMPTY_FEED
: getFollowedPlaylistsDumpFeed(
ctx.state.user.userId,
page,
limit,
categoryId,
);
const data: PaginatedData<Dump> = {
items,
total,

View File

@@ -0,0 +1,269 @@
import {
APIErrorCode,
APIException,
type Category,
type CreateCategoryRequest,
type Dump,
type UpdateCategoryRequest,
} from "../model/interfaces.ts";
import {
categoryRowToApi,
db,
DUMP_SELECT_COLUMNS_ALIASED as SELECT_COLS_ALIASED,
dumpRowToApi,
isCategoryRow,
isDumpRow,
} from "../model/db.ts";
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
import { VALIDATION } from "../config.ts";
function validateSlug(slug: string): void {
if (!isValidSlugFormat(slug)) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"Slug must be lowercase letters, numbers and hyphens",
);
}
if (slug.length > VALIDATION.CATEGORY_SLUG_MAX) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
`Slug must be at most ${VALIDATION.CATEGORY_SLUG_MAX} characters`,
);
}
if (isReservedSlug(slug)) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
`Slug "${slug}" is reserved`,
);
}
}
function requireName(name: string): string {
const trimmed = name.trim();
if (!trimmed) {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Name is required");
}
if (trimmed.length > VALIDATION.CATEGORY_NAME_MAX) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
`Name must be at most ${VALIDATION.CATEGORY_NAME_MAX} characters`,
);
}
return trimmed;
}
// Maps SQLite UNIQUE violations to a friendly 409 naming the offending field.
function uniqueConflict(err: unknown, slug: string, name: string): never {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("categories.name")) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
`Name "${name}" is already in use`,
);
}
if (msg.includes("UNIQUE") || msg.includes("unique")) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
`Slug "${slug}" is already in use`,
);
}
throw err as Error;
}
export function getCategoryById(id: string): Category {
const row = db.prepare(`SELECT * FROM categories WHERE id = ?;`).get(id);
if (!row || !isCategoryRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found");
}
return categoryRowToApi(row);
}
export function getCategoryBySlug(slug: string): Category {
const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug);
if (!row || !isCategoryRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found");
}
return categoryRowToApi(row);
}
/** Resolve a slug to its category id, or undefined if no such category. */
export function getCategoryIdBySlug(slug: string): string | undefined {
const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug);
return row && typeof (row as { id?: unknown }).id === "string"
? (row as { id: string }).id
: undefined;
}
export function listCategories(): Category[] {
const rows = db.prepare(
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
).all();
return rows.filter(isCategoryRow).map(categoryRowToApi);
}
export function createCategory(req: CreateCategoryRequest): Category {
validateSlug(req.slug);
const name = requireName(req.name);
const id = crypto.randomUUID();
const createdAt = new Date();
try {
db.prepare(
`INSERT INTO categories (id, slug, name, position, created_at)
VALUES (?, ?, ?, ?, ?);`,
).run(
id,
req.slug,
name,
req.position ?? 0,
createdAt.toISOString(),
);
} catch (err) {
uniqueConflict(err, req.slug, name);
}
return {
id,
slug: req.slug,
name,
position: req.position ?? 0,
createdAt,
};
}
export function updateCategory(
id: string,
req: UpdateCategoryRequest,
): Category {
const existing = getCategoryById(id);
const newSlug = req.slug ?? existing.slug;
if (req.slug !== undefined && req.slug !== existing.slug) {
validateSlug(req.slug);
}
const newName = req.name !== undefined ? requireName(req.name) : existing.name;
const newPosition = req.position ?? existing.position;
const now = new Date();
try {
db.prepare(
`UPDATE categories SET slug = ?, name = ?, position = ?, updated_at = ? WHERE id = ?;`,
).run(
newSlug,
newName,
newPosition,
now.toISOString(),
id,
);
} catch (err) {
uniqueConflict(err, newSlug, newName);
}
return {
...existing,
slug: newSlug,
name: newName,
position: newPosition,
updatedAt: now,
};
}
export function deleteCategory(id: string): void {
const result = db.prepare(`DELETE FROM categories WHERE id = ?;`).run(id);
if (result.changes === 0) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Category not found");
}
}
/**
* Replace the set of categories a dump belongs to. Unknown category ids are
* rejected. An empty list clears all category assignments.
*/
export function setDumpCategories(
dumpId: string,
categoryIds: string[],
): void {
const unique = [...new Set(categoryIds)];
if (unique.length > 0) {
const placeholders = unique.map(() => "?").join(", ");
const found = db.prepare(
`SELECT COUNT(*) as count FROM categories WHERE id IN (${placeholders});`,
).get(...unique) as { count: number } | undefined;
if ((found?.count ?? 0) !== unique.length) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"One or more categories do not exist",
);
}
}
db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId);
const insert = db.prepare(
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
);
for (const categoryId of unique) {
insert.run(dumpId, categoryId);
}
}
/** Fill `categoryIds` on each dump with a single batched lookup. */
export function attachCategoryIds(dumps: Dump[]): void {
if (dumps.length === 0) return;
const ids = dumps.map((d) => d.id);
const placeholders = ids.map(() => "?").join(", ");
const rows = db.prepare(
`SELECT dump_id, category_id FROM dump_categories WHERE dump_id IN (${placeholders});`,
).all(...ids) as Array<{ dump_id: string; category_id: string }>;
const byDump = new Map<string, string[]>();
for (const { dump_id, category_id } of rows) {
const list = byDump.get(dump_id) ?? [];
list.push(category_id);
byDump.set(dump_id, list);
}
for (const dump of dumps) {
dump.categoryIds = byDump.get(dump.id) ?? [];
}
}
export function listDumpsByCategory(
slug: string,
page: number,
limit: number,
requestingUserId?: string,
): { items: Dump[]; total: number } {
const category = getCategoryBySlug(slug);
const offset = (page - 1) * limit;
const privacyClause = requestingUserId
? "(d.is_private = 0 OR d.user_id = ?)"
: "d.is_private = 0";
const privacyParams = requestingUserId ? [requestingUserId] : [];
const rows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED}
FROM dumps d
INNER JOIN dump_categories dc ON dc.dump_id = d.id
WHERE dc.category_id = ? AND ${privacyClause}
ORDER BY d.created_at DESC LIMIT ? OFFSET ?;`,
).all(category.id, ...privacyParams, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count
FROM dumps d
INNER JOIN dump_categories dc ON dc.dump_id = d.id
WHERE dc.category_id = ? AND ${privacyClause};`,
).get(category.id, ...privacyParams) as { count: number } | undefined;
const items = rows.filter(isDumpRow).map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}

View File

@@ -33,6 +33,7 @@ import {
} from "../config.ts";
import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts";
import { attachCategoryIds, setDumpCategories } from "./category-service.ts";
function isAllowedMime(mime: string): boolean {
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
@@ -78,6 +79,9 @@ export async function createUrlDump(
isPrivate ? 1 : 0,
);
const categoryIds = [...new Set(request.categoryIds ?? [])];
setDumpCategories(dumpId, categoryIds);
const dump: Dump = {
id: dumpId,
kind: "url",
@@ -91,6 +95,7 @@ export async function createUrlDump(
voteCount: 0,
commentCount: 0,
isPrivate,
categoryIds,
};
if (!isPrivate) {
broadcastNewDump(dump);
@@ -110,6 +115,7 @@ export async function createFileDump(
userId: string,
isPrivate = false,
title?: string,
categoryIds: string[] = [],
): Promise<Dump> {
if (!isAllowedMime(file.type)) {
throw new APIException(
@@ -130,6 +136,7 @@ export async function createFileDump(
const createdAt = new Date();
const finalTitle = title?.trim() || file.name;
const slug = makeSlug(finalTitle, dumpId);
const dedupedCategoryIds = [...new Set(categoryIds)];
await Deno.mkdir(DUMPS_DIR, { recursive: true });
const data = new Uint8Array(await file.arrayBuffer());
@@ -153,6 +160,7 @@ export async function createFileDump(
file.size,
isPrivate ? 1 : 0,
);
setDumpCategories(dumpId, dedupedCategoryIds);
} catch (err) {
// Roll back the file if DB insert fails
await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {});
@@ -173,6 +181,7 @@ export async function createFileDump(
voteCount: 0,
commentCount: 0,
isPrivate,
categoryIds: dedupedCategoryIds,
};
if (!isPrivate) {
broadcastNewDump(dump);
@@ -205,6 +214,7 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
if (dump.isPrivate && dump.userId !== requestingUserId) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
attachCategoryIds([dump]);
return dump;
}
@@ -229,10 +239,9 @@ export function searchDumps(
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
).get(requestingUserId, ...searchParams) as { count: number } | undefined;
return {
items: rows.filter(isDumpRow).map(dumpRowToApi),
total: totalRow?.count ?? 0,
};
const items = rows.filter(isDumpRow).map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
} else {
const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
@@ -240,10 +249,9 @@ export function searchDumps(
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
).get(...searchParams) as { count: number } | undefined;
return {
items: rows.filter(isDumpRow).map(dumpRowToApi),
total: totalRow?.count ?? 0,
};
const items = rows.filter(isDumpRow).map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}
}
@@ -277,7 +285,9 @@ export function listDumps(
);
}
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}
export async function updateDump(
@@ -314,6 +324,13 @@ export async function updateDump(
now.toISOString(),
dumpId,
);
if (request.categoryIds !== undefined) {
const ids = [...new Set(request.categoryIds)];
setDumpCategories(dumpId, ids);
updatedDump.categoryIds = ids;
} else {
attachCategoryIds([updatedDump]);
}
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
if (updatedDump.comment) {
@@ -382,6 +399,14 @@ export async function updateDump(
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
if (request.categoryIds !== undefined) {
const ids = [...new Set(request.categoryIds)];
setDumpCategories(dumpId, ids);
updatedDump.categoryIds = ids;
} else {
attachCategoryIds([updatedDump]);
}
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
if (updatedDump.comment) {
@@ -403,6 +428,7 @@ export async function replaceFileDump(
file: File,
comment: string | undefined,
title?: string,
categoryIds?: string[],
): Promise<Dump> {
if (!isAllowedMime(file.type)) {
throw new APIException(
@@ -455,6 +481,14 @@ export async function replaceFileDump(
throw err;
}
if (categoryIds !== undefined) {
const ids = [...new Set(categoryIds)];
setDumpCategories(dumpId, ids);
dump.categoryIds = ids;
} else {
attachCategoryIds([dump]);
}
if (comment) {
notifyMentions(dump.userId, comment, "dump", dumpId, finalTitle);
linkAttachments(comment, dumpId);
@@ -493,7 +527,9 @@ export function getDumpsByUser(
"Malformed dump data",
);
}
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}
export function getVotedDumpsByUser(
@@ -544,7 +580,9 @@ export function getVotedDumpsByUser(
"Malformed dump data",
);
}
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
const items = rows.map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}
export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {

View File

@@ -21,6 +21,7 @@ import {
playlistRowToApi,
userRowToApi,
} from "../model/db.ts";
import { attachCategoryIds } from "./category-service.ts";
// ── Follow / unfollow a user ──────────────────────────────────────────────────
@@ -139,26 +140,35 @@ export function getFollowedUsersDumpFeed(
followerId: string,
page: number,
limit: number,
categoryId?: string,
): { items: Dump[]; total: number } {
const offset = (page - 1) * limit;
// Optional category filter: join the membership table and constrain to it.
const catJoin = categoryId
? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?"
: "";
const catParams = categoryId ? [categoryId] : [];
const rawRows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED}
FROM dumps d
INNER JOIN follows f ON f.followed_user_id = d.user_id
${catJoin}
WHERE f.follower_id = ?
AND d.is_private = 0
ORDER BY d.created_at DESC
LIMIT ? OFFSET ?;`,
).all(followerId, limit, offset);
).all(...catParams, followerId, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count
FROM dumps d
INNER JOIN follows f ON f.followed_user_id = d.user_id
${catJoin}
WHERE f.follower_id = ?
AND d.is_private = 0;`,
).get(followerId) as { count: number } | undefined;
).get(...catParams, followerId) as { count: number } | undefined;
const userFeedRows = rawRows as Parameters<typeof isDumpRow>[0][];
if (!userFeedRows.every(isDumpRow)) {
@@ -168,7 +178,9 @@ export function getFollowedUsersDumpFeed(
"Malformed dump data",
);
}
return { items: userFeedRows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
const items = userFeedRows.map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}
// ── Followed-playlists dump feed ──────────────────────────────────────────────
@@ -177,22 +189,29 @@ export function getFollowedPlaylistsDumpFeed(
followerId: string,
page: number,
limit: number,
categoryId?: string,
): { items: Dump[]; total: number } {
const offset = (page - 1) * limit;
const catJoin = categoryId
? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?"
: "";
const catParams = categoryId ? [categoryId] : [];
const rawRows = db.prepare(
`SELECT ${SELECT_COLS_ALIASED}
FROM dumps d
INNER JOIN playlist_dumps pd ON pd.dump_id = d.id
INNER JOIN playlists p ON p.id = pd.playlist_id
INNER JOIN follows f ON f.followed_playlist_id = p.id
${catJoin}
WHERE f.follower_id = ?
AND p.is_public = 1
AND d.is_private = 0
GROUP BY d.id
ORDER BY MAX(pd.added_at) DESC
LIMIT ? OFFSET ?;`,
).all(followerId, limit, offset);
).all(...catParams, followerId, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(DISTINCT d.id) as count
@@ -200,10 +219,11 @@ export function getFollowedPlaylistsDumpFeed(
INNER JOIN playlist_dumps pd ON pd.dump_id = d.id
INNER JOIN playlists p ON p.id = pd.playlist_id
INNER JOIN follows f ON f.followed_playlist_id = p.id
${catJoin}
WHERE f.follower_id = ?
AND p.is_public = 1
AND d.is_private = 0;`,
).get(followerId) as { count: number } | undefined;
).get(...catParams, followerId) as { count: number } | undefined;
if (!rawRows.every(isDumpRow)) {
throw new APIException(
@@ -212,10 +232,9 @@ export function getFollowedPlaylistsDumpFeed(
"Malformed dump data",
);
}
return {
items: rawRows.map(dumpRowToApi),
total: totalRow?.count ?? 0,
};
const items = rawRows.map(dumpRowToApi);
attachCategoryIds(items);
return { items, total: totalRow?.count ?? 0 };
}
// ── Followed playlists (as playlist objects) ──────────────────────────────────