Files
gerbeur/api/services/playlist-service.ts
khannurien 25601edaf8
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
v3: the backend gets a test suite, and the bugs it turned up are fixed — deno task test runs 186 tests over api/ in about 18s, against a throwaway database and uploads directory (GERBEUR_DB_PATH and GERBEUR_UPLOADS_DIR now override the defaults; api/test/setup.ts sets them before any app module loads), with requests sent through the whole middleware stack in-process via app.handle, outbound fetches and DNS stubbed, and the socket tests on a real Oak listener since an upgraded request has no response for handle() to return. It covers auth (token purposes, revocation after a password change, live roles, login rate limiting, invites claimed once even under parallel registrations, password resets), dumps end to end (create, privacy, by-url matching on the canonical key, backlinks, categories, edits, file replacement, thumbnails, refresh, delete), file serving headers and ranges, attachments, avatars, comments and likes, playlists, follows and feeds, notifications and mentions, categories, search, chat cursors and read markers, link previews and the SSRF guard on every redirect hop, the Bandcamp and YouTube resolvers, migrations (a fresh schema is left untouched; the data rewrites in 0004/0005 and 0010–0013 do what they say), CORS, OG tags, static caching, and the WebSocket protocol. Fixed along the way: an unknown category id on a new dump returned 400 but kept the dump row, and for a file dump deleted the file while keeping the row; an edit or file replacement with one was half-applied before the 400 — categories are now checked before anything is written. A suffix range (bytes=-3) was read as an end offset and got a 416 instead of the last three bytes. Reordering a playlist accepted a list naming a member twice. verifyPassword threw on a stored hash without a salt instead of answering false. And a request with no Accept-Encoding, which RFC 9110 lets a server treat as accepting anything, was sent the brotli sibling; curl and bots now get the plain file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:31:05 +00:00

428 lines
12 KiB
TypeScript

import {
APIErrorCode,
APIException,
type CreatePlaylistRequest,
type Dump,
type Playlist,
type PlaylistMembership,
type PlaylistWithDumps,
type UpdatePlaylistRequest,
} from "../model/interfaces.ts";
import {
db,
DUMP_COLUMNS_ALIASED,
dumpRowToApi,
isDumpRow,
isPlaylistRow,
playlistRowToApi,
} from "../model/db.ts";
import {
broadcastPlaylistCreated,
broadcastPlaylistDeleted,
broadcastPlaylistDumpsUpdated,
broadcastPlaylistUpdated,
} from "./ws-service.ts";
import {
notifyMentions,
notifyPlaylistFollowersNewDump,
} from "./notification-service.ts";
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
import { linkAttachments } from "./attachment-service.ts";
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`;
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);
if (!row || !isPlaylistRow(row)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found");
}
return playlistRowToApi(row);
}
export function createPlaylist(
req: CreatePlaylistRequest,
userId: string,
): Playlist {
const id = crypto.randomUUID();
const createdAt = new Date();
const slug = makeSlug(req.title, id);
db.prepare(
`INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?);`,
).run(
id,
userId,
req.title,
slug,
req.description ?? null,
req.isPublic ? 1 : 0,
createdAt.toISOString(),
);
const playlist: Playlist = {
id,
userId,
title: req.title,
slug,
description: req.description,
isPublic: req.isPublic,
createdAt,
};
if (req.description) {
notifyMentions(userId, req.description, "playlist", id, req.title);
linkAttachments(req.description, id);
}
broadcastPlaylistCreated(playlist);
return playlist;
}
export function getPlaylist(
playlistId: string,
requestingUserId: string | null,
): PlaylistWithDumps {
const playlist = getPlaylistById(playlistId);
if (!playlist.isPublic && requestingUserId !== playlist.userId) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found");
}
const isOwner = requestingUserId === playlist.userId;
// For public playlists (or when viewed by non-owner), filter out private dumps
const rows = db.prepare(
`SELECT ${DUMP_COLUMNS_ALIASED},
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count
FROM dumps d
INNER JOIN playlist_dumps pd ON d.id = pd.dump_id
WHERE pd.playlist_id = ?
AND (d.is_private = 0 OR d.user_id = ?)
ORDER BY pd.position ASC;`,
).all(playlist.id, requestingUserId ?? "");
const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi);
// Owners always see their own private dumps; strip them for non-owners regardless
const visibleDumps = isOwner ? dumps : dumps.filter((d) => !d.isPrivate);
return { ...playlist, dumps: visibleDumps };
}
export function searchPlaylists(
query: string,
limit: number,
requestingUserId?: string,
): Playlist[] {
if (!query.trim()) return [];
const pattern = `%${query}%`;
const searchClause = `(p.title LIKE ? OR p.description LIKE ?)`;
const rows = requestingUserId
? db.prepare(
`SELECT ${PLAYLIST_SELECT} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
).all(requestingUserId, pattern, pattern, limit)
: db.prepare(
`SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
).all(pattern, pattern, limit);
return rows.filter(isPlaylistRow).map(playlistRowToApi);
}
export function listPlaylistsByUser(
userId: string,
requestingUserId: string | null,
page: number,
limit: number,
): { items: Playlist[]; total: number } {
const isOwner = requestingUserId === userId;
const offset = (page - 1) * limit;
const countSql = isOwner
? `SELECT COUNT(*) as count FROM playlists WHERE user_id = ?;`
: `SELECT COUNT(*) as count FROM playlists WHERE user_id = ? AND is_public = 1;`;
const sql = isOwner
? `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`
: `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`;
const totalRow = db.prepare(countSql).get(userId) as
| { count: number }
| undefined;
const rows = db.prepare(sql).all(userId, limit, offset);
return {
items: rows.filter(isPlaylistRow).map(playlistRowToApi),
total: totalRow?.count ?? 0,
};
}
export function updatePlaylist(
playlistId: string,
req: UpdatePlaylistRequest,
requestingUserId: string,
canModerate: boolean,
): Playlist {
const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
}
const newTitle = req.title ?? playlist.title;
const newDescription = "description" in req
? (req.description ?? null)
: (playlist.description ?? null);
const newIsPublic = req.isPublic !== undefined
? req.isPublic
: playlist.isPublic;
const now = new Date();
const newSlug = makeSlug(newTitle, playlist.id);
db.prepare(
`UPDATE playlists SET title = ?, slug = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`,
).run(
newTitle,
newSlug,
newDescription,
newIsPublic ? 1 : 0,
now.toISOString(),
playlist.id,
);
const updated: Playlist = {
...playlist,
title: newTitle,
slug: newSlug,
description: newDescription ?? undefined,
isPublic: newIsPublic,
updatedAt: now,
};
if (newDescription) {
notifyMentions(
requestingUserId,
newDescription,
"playlist",
playlist.id,
newTitle,
);
linkAttachments(newDescription, playlist.id);
}
broadcastPlaylistUpdated(updated, playlist.isPublic);
return updated;
}
export function deletePlaylist(
playlistId: string,
requestingUserId: string,
canModerate: boolean,
): void {
const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
}
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic);
}
export function setPlaylistImage(
playlistId: string,
imageMime: string,
requestingUserId: string,
canModerate: boolean,
): Playlist {
const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
}
db.prepare(`UPDATE playlists SET image_mime = ? WHERE id = ?;`).run(
imageMime,
playlist.id,
);
const updated = getPlaylistById(playlist.id);
broadcastPlaylistUpdated(updated, playlist.isPublic);
return updated;
}
export function addDumpToPlaylist(
playlistId: string,
dumpId: string,
requestingUserId: string,
canModerate: boolean,
): void {
const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
}
// Only a dump the requester can see — someone else's private dump would
// otherwise land in the playlist and its title in followers' notifications.
const dump = db.prepare(
`SELECT title, is_private, user_id FROM dumps WHERE id = ?;`,
).get(dumpId) as
| { title: string; is_private: number; user_id: string }
| undefined;
if (!dump || (dump.is_private && dump.user_id !== requestingUserId)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
}
const minRow = db.prepare(
`SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`,
).get(playlist.id) as { min_pos: number | null } | undefined;
const nextPos = (minRow?.min_pos ?? 1) - 1;
const addedAt = new Date().toISOString();
try {
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, ?, ?);`,
).run(playlist.id, dumpId, nextPos, addedAt);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("UNIQUE") || msg.includes("unique")) {
throw new APIException(
APIErrorCode.VALIDATION_ERROR,
409,
"Dump already in playlist",
);
}
throw err;
}
const dumpIds = getCurrentDumpIds(playlist.id);
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
// Followers can't open a private dump, even the owner's own.
if (playlist.isPublic && !dump.is_private) {
notifyPlaylistFollowersNewDump(
playlist.id,
playlist.title,
dumpId,
dump.title,
);
}
}
export function removeDumpFromPlaylist(
playlistId: string,
dumpId: string,
requestingUserId: string,
canModerate: boolean,
): void {
const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
}
db.prepare(
`DELETE FROM playlist_dumps WHERE playlist_id = ? AND dump_id = ?;`,
).run(playlist.id, dumpId);
const dumpIds = getCurrentDumpIds(playlist.id);
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
}
export function reorderPlaylist(
playlistId: string,
dumpIds: string[],
requestingUserId: string,
canModerate: boolean,
): void {
const playlist = getPlaylistById(playlistId);
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
}
const currentIds = getCurrentDumpIds(playlist.id);
const currentSet = new Set(currentIds);
const newSet = new Set(dumpIds);
if (
dumpIds.length !== newSet.size ||
currentSet.size !== newSet.size ||
!currentIds.every((id) => newSet.has(id))
) {
throw new APIException(
APIErrorCode.BAD_REQUEST,
400,
"dumpIds must match current playlist members exactly",
);
}
const update = db.prepare(
`UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`,
);
for (let i = 0; i < dumpIds.length; i++) {
update.run(i, playlist.id, dumpIds[i]);
}
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
}
export function getPlaylistMembershipsForDump(
dumpId: string,
userId: string,
): PlaylistMembership[] {
const rows = db.prepare(
`SELECT p.*, pd.dump_id IS NOT NULL as has_dump
FROM playlists p
LEFT JOIN playlist_dumps pd ON pd.playlist_id = p.id AND pd.dump_id = ?
WHERE p.user_id = ?
ORDER BY p.created_at DESC;`,
).all(dumpId, userId);
return rows.map((row) => {
if (!isPlaylistRow(row)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed playlist data",
);
}
return {
playlist: playlistRowToApi(row),
hasDump: Boolean(row.has_dump),
};
});
}
/**
* The playlists a dump appears in — the "in collections" counterpart to
* backlinks. Privacy-filtered like `getRelatedDumps`: public playlists for
* everyone, plus the requesting user's own private ones. Capped at 20 — a
* small, bounded list of supporting context.
*/
export function getPlaylistsForDump(
dumpId: string,
requestingUserId?: string,
): Playlist[] {
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT}
INNER JOIN playlist_dumps pd ON pd.playlist_id = p.id
WHERE pd.dump_id = ? AND (p.is_public = 1 OR p.user_id = ?)
ORDER BY p.created_at DESC LIMIT 20;`,
).all(dumpId, requestingUserId ?? "");
return rows.filter(isPlaylistRow).map(playlistRowToApi);
}
export function getPlaylistImageInfo(
idOrSlug: string,
): { id: string; imageMime: string } | undefined {
const playlist = getPlaylistById(idOrSlug);
return playlist.imageMime
? { id: playlist.id, imageMime: playlist.imageMime }
: undefined;
}
function getCurrentDumpIds(playlistId: string): string[] {
const rows = db.prepare(
`SELECT dump_id FROM playlist_dumps WHERE playlist_id = ? ORDER BY position ASC;`,
).all(playlistId) as Array<{ dump_id: string }>;
return rows.map((r) => r.dump_id);
}