v3: dependencies upgraded and deno audit clean, every API response typed end to end, no explicit any left — and the site search stops handing out password hashes — deno audit reported 19 advisories (11 high); none remain. nodemailer 9 → 10.0.10 closes four of them (a quadratic address parser, two recipient-domain allow-list bypasses, resolveContent ignoring disableFileAccess/disableUrlAccess); 10's one break is Node ≥ 20, and 10.0.11 is a day too young for the minimum release age. react-router 8.0.1 → 8.4.0 closes the RSC-mode CSRF bypass. Refreshing the lockfile clears the transitive ones that came in through Vite, ESLint and Lingui: postcss, nanoid, picomatch, browserslist, brace-expansion and baseline-browser-mapping. Everything else moves to its latest too — Lingui 6.8 (its SWC plugin 6.7), React 19.3, Vite 8.3.1, react-hook-form 7.89, marked, jose, ESLint 10.11, typescript-eslint 8.70, the @types — and frimousse 0.3 → 0.4, whose declarations only add (custom emoji data resolvers, a cache). TypeScript stays on 6.0: typescript-eslint supports < 6.1, so 7.0 would stop the lint. allowScripts names @swc/core 1.16.2 and drops esbuild, which Vite 8 no longer installs.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Search: GET /api/search sent each matching user's full row — password hash and email included — to anyone, signed in or not, because searchUsersGlobal returned User[] and the route passed it through. It returns profiles only now, stripped where the rows are read, and the search test fails on the old code. Types alone wouldn't have caught it (a User is structurally a PublicUser), so the strip is explicit and tested.
Typed routes. Every JSON response is declared as APIResponse<T>, the way the typed routes already did, with the missing shapes named in api/model/interfaces.ts: PublicUser (no hash, no email) and SelfUser (your own profile, with email) plus toPublicUser/toSelfUser in place of ten hand-written destructurings, AuthTokens, AuthResponse, MediaTokenResponse, InviteResponse, AttachmentResponse, SearchResults, LinkPreview, UserSuggestion and Invitee. Request bodies are `unknown` and narrowed by the existing guards, or by a new fieldOf(obj, key) where a route reads one field — no more reading off Oak's `any` or casting to Record<string, unknown>. The password-reset routes answer `{ success: true, data: null }` rather than a bare `{ success: true }`, so every success fits the envelope; the frontend only reads `success`.
Socket protocol: routes/ws.ts had its own send() taking Record<string, unknown>, so nothing checked what it sent. It takes ServerToClientMessage now, which turned up two mismatches: `pong` was missing from the union although the server answers every client ping with one, and vote_ack carried a `success: true` no type declares and no client reads. The union has PongMessage and vote_ack lost the field.
No explicit any. The YouTube playlist parse reads ytInitialData as `unknown` through a small at(node, …path) that checks each step, instead of `type Json = any`. The backend test helpers type the response body as the server's envelope: request<T>() and get/post/put/patch/del<T>() name what `data` holds, res.data throws with the server's message when the call didn't succeed and res.error when it didn't fail, and each test says which API type it expects, so a test reading a field the route doesn't send no longer compiles. Assertions that a field is absent are `assertFalse("passwordHash" in …)` rather than reading a field the type says isn't there. The socket tests' messages are ServerToClientMessage, with next()/all() keyed by type so each returns that message's shape.
No double casts in src/ either. window.YT and onYouTubeIframeAPIReady are declared on Window; the test's fake player is a class forwarding to the spies the tests inspect. WSContext, FollowContext and PlayerContext export their defaults (defaultWSValue, defaultFollowValue, defaultPlayerValue), which the tests import instead of reading React's private _currentValue. handleMentionKeyDown takes a MentionKey — the key fields it reads — so the test builds a plain object; useUserDumpFeed declares sentinelRef as the callback ref it is. In src/test/setup.ts the IntersectionObserver fake implements IntersectionObserver, and report(isIntersecting, top?) builds complete entries in place of partial ones cast in five files; the Animation fake implements Animation; the canvas getContext stub goes in through defineProperty. The XHR fake's last request is optional behind a getter that fails if none was made, the keyframes are checked with matchers, and the roleless legacy session is built by leaving the role out. One test changes meaning slightly: "leaving the view…" relied on entries with no position at all, and now leaves and re-enters at the same height, which is what it describes. The one cast left is in api/test/ws.test.ts: deno.json's "dom" lib hides Deno's WebSocket overload that takes handshake headers, and dropping that lib would break the editor's view of src/; it's commented and names the real signature.
Also: the notifications test answered a Response-typed promise with a plain object, which failed `tsc -b`; it uses the fetch stub's deferred() now. ESLint didn't read the `deno-lint-ignore no-explicit-any` comments, so `eslint .` failed on three files; those anys are gone. `deno fmt` over api/ and src/ (not the compiled src/locales/*.js); the reflow left all 332 message ids intact, and the .po files only have their source line references updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,7 +45,11 @@ function parseIPv6(s: string): number[] | null {
|
||||
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
||||
const fill = 8 - head.length - rest.length;
|
||||
if (halves.length === 1 ? fill !== 0 : fill < 1) return null;
|
||||
const groups = [...head, ...Array(halves.length === 2 ? fill : 0).fill("0"), ...rest];
|
||||
const groups = [
|
||||
...head,
|
||||
...Array(halves.length === 2 ? fill : 0).fill("0"),
|
||||
...rest,
|
||||
];
|
||||
if (!groups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) return null;
|
||||
return groups.flatMap((g) => {
|
||||
const n = parseInt(g, 16);
|
||||
@@ -62,7 +66,8 @@ function isPrivateIPv6(b: number[]): boolean {
|
||||
if (zeroPrefix(12)) { // ::, ::1 and the deprecated ::a.b.c.d
|
||||
return true;
|
||||
}
|
||||
if ( // 64:ff9b::/96 NAT64
|
||||
if (
|
||||
// 64:ff9b::/96 NAT64
|
||||
b[0] === 0 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b &&
|
||||
b.slice(4, 12).every((x) => x === 0)
|
||||
) return isPrivateIPv4(b.slice(12));
|
||||
|
||||
@@ -27,7 +27,9 @@ export function emojiToCodepoint(emoji: string): string {
|
||||
let wasmReady: Promise<unknown> | undefined;
|
||||
function ensureWasm(): Promise<unknown> {
|
||||
if (!wasmReady) {
|
||||
const wasmUrl = new URL(import.meta.resolve("@resvg/resvg-wasm/index_bg.wasm"));
|
||||
const wasmUrl = new URL(
|
||||
import.meta.resolve("@resvg/resvg-wasm/index_bg.wasm"),
|
||||
);
|
||||
wasmReady = Deno.readFile(wasmUrl).then((bytes) => initWasm(bytes));
|
||||
}
|
||||
return wasmReady;
|
||||
@@ -62,7 +64,10 @@ async function fetchGlyph(emoji: string): Promise<string> {
|
||||
}
|
||||
|
||||
function renderPng(svg: string, size: number, background?: string): Uint8Array {
|
||||
const resvg = new Resvg(svg, { fitTo: { mode: "width", value: size }, background });
|
||||
const resvg = new Resvg(svg, {
|
||||
fitTo: { mode: "width", value: size },
|
||||
background,
|
||||
});
|
||||
return resvg.render().asPng();
|
||||
}
|
||||
|
||||
@@ -152,10 +157,16 @@ export async function generateSiteIcons(
|
||||
await Deno.writeFile(out(`favicon-${size}x${size}.png`), pngs.get(size)!);
|
||||
}
|
||||
// Apple touch icon gets a solid background — iOS doesn't honor transparency.
|
||||
await Deno.writeFile(out("apple-touch-icon.png"), renderPng(svg, APPLE_SIZE, bgColor));
|
||||
await Deno.writeFile(
|
||||
out("apple-touch-icon.png"),
|
||||
renderPng(svg, APPLE_SIZE, bgColor),
|
||||
);
|
||||
await Deno.writeFile(
|
||||
out("favicon.ico"),
|
||||
buildIco(ICO_SIZES.map((size) => ({ size, png: pngs.get(size)! }))),
|
||||
);
|
||||
await Deno.writeTextFile(out("manifest.webmanifest"), buildManifest(siteName, bgColor));
|
||||
await Deno.writeTextFile(
|
||||
out("manifest.webmanifest"),
|
||||
buildManifest(siteName, bgColor),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Dump,
|
||||
isRole,
|
||||
type Notification,
|
||||
type NotificationData,
|
||||
type NotificationType,
|
||||
type Playlist,
|
||||
type RichContent,
|
||||
@@ -494,7 +495,7 @@ export function notificationRowToApi(row: NotificationRow): Notification {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
type: row.type as NotificationType,
|
||||
data: JSON.parse(row.data),
|
||||
data: JSON.parse(row.data) as NotificationData,
|
||||
read: Boolean(row.read),
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
|
||||
@@ -118,6 +118,38 @@ export interface User {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** A user as anyone may see them: no password hash, no email. */
|
||||
export type PublicUser = Omit<User, "passwordHash" | "email">;
|
||||
|
||||
/** A user as they see themselves: everything but the password hash. */
|
||||
export type SelfUser = Omit<User, "passwordHash">;
|
||||
|
||||
export function toPublicUser(
|
||||
{ passwordHash: _, email: _email, ...user }: User,
|
||||
): PublicUser {
|
||||
return user;
|
||||
}
|
||||
|
||||
export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser {
|
||||
return user;
|
||||
}
|
||||
|
||||
/** A match for the @mention autocomplete. */
|
||||
export interface UserSuggestion {
|
||||
id: string;
|
||||
username: string;
|
||||
avatarMime: string | null;
|
||||
}
|
||||
|
||||
/** Someone a user invited, directly or down the line. */
|
||||
export interface Invitee {
|
||||
id: string;
|
||||
username: string;
|
||||
avatarMime?: string;
|
||||
invitedById: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface LoginUserRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -144,6 +176,13 @@ export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest {
|
||||
"password" in obj && typeof obj.password === "string";
|
||||
}
|
||||
|
||||
/** `obj[key]` when `obj` is an object that has it, undefined otherwise. */
|
||||
export function fieldOf(obj: unknown, key: string): unknown {
|
||||
return obj !== null && typeof obj === "object" && key in obj
|
||||
? (obj as Record<string, unknown>)[key]
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function isRegisterUserRequest(
|
||||
obj: unknown,
|
||||
): obj is RegisterUserRequest {
|
||||
@@ -210,9 +249,24 @@ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
/** The session token, plus the media-only one for file and thumbnail URLs. */
|
||||
export interface AuthTokens {
|
||||
token: string;
|
||||
user: User;
|
||||
mediaToken: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse extends AuthTokens {
|
||||
user: SelfUser;
|
||||
}
|
||||
|
||||
export type MediaTokenResponse = Pick<AuthTokens, "mediaToken">;
|
||||
|
||||
export interface InviteResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface AttachmentResponse {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthPayload {
|
||||
@@ -288,6 +342,22 @@ export interface PaginatedData<T> {
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
dumps: PaginatedData<Dump>;
|
||||
users: PublicUser[];
|
||||
playlists: Playlist[];
|
||||
}
|
||||
|
||||
/**
|
||||
* `reached` is separate because a failed fetch still yields a usable stub
|
||||
* (hostname only): without it the create form can't tell "this page has no
|
||||
* preview" from "this link is dead".
|
||||
*/
|
||||
export interface LinkPreview {
|
||||
reached: boolean;
|
||||
richContent: RichContent | null;
|
||||
}
|
||||
|
||||
export class APIException extends Error {
|
||||
readonly code: APIErrorCode;
|
||||
readonly status: number;
|
||||
@@ -747,7 +817,7 @@ export interface PlaylistDumpsUpdatedMessage {
|
||||
|
||||
export interface UserUpdatedMessage {
|
||||
type: "user_updated";
|
||||
user: Omit<User, "passwordHash" | "email">;
|
||||
user: PublicUser;
|
||||
}
|
||||
|
||||
export interface CommentCreatedMessage {
|
||||
@@ -822,6 +892,7 @@ export interface ChatReadUpdateMessage {
|
||||
|
||||
export type ServerToClientMessage =
|
||||
| PingMessage
|
||||
| PongMessage
|
||||
| WelcomeMessage
|
||||
| PresenceUpdateMessage
|
||||
| VotesUpdateMessage
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
createAttachment,
|
||||
getAttachment,
|
||||
} from "../services/attachment-service.ts";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type APIResponse,
|
||||
type AttachmentResponse,
|
||||
} from "../model/interfaces.ts";
|
||||
import {
|
||||
ATTACHMENTS_DIR,
|
||||
serveUploadedFile,
|
||||
@@ -43,8 +48,12 @@ router.post("/api/attachments", authMiddleware, async (ctx) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const responseBody: APIResponse<AttachmentResponse> = {
|
||||
success: true,
|
||||
data: { id: attachmentId },
|
||||
};
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { success: true, data: { id: attachmentId } };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/api/attachments/:attachmentId", async (ctx) => {
|
||||
|
||||
@@ -5,7 +5,14 @@ import {
|
||||
broadcastUserUpdated,
|
||||
updateClientAvatar,
|
||||
} from "../services/ws-service.ts";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type APIResponse,
|
||||
type SelfUser,
|
||||
toPublicUser,
|
||||
toSelfUser,
|
||||
} from "../model/interfaces.ts";
|
||||
import {
|
||||
AVATARS_DIR,
|
||||
serveUploadedFile,
|
||||
@@ -47,14 +54,16 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
}
|
||||
updateClientAvatar(authPayload.userId, mime);
|
||||
|
||||
const { passwordHash: _, ...publicUser } = getUserById(authPayload.userId);
|
||||
const user = getUserById(authPayload.userId);
|
||||
// Let every client refresh this user's avatar wherever it's shown (feeds,
|
||||
// comments, OP, profiles) — not just the presence row. updated_at, bumped on
|
||||
// the avatar write, doubles as the cache-busting version.
|
||||
const { email: _email, ...broadcastUser } = publicUser;
|
||||
broadcastUserUpdated(broadcastUser);
|
||||
ctx.response.status = 200;
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
broadcastUserUpdated(toPublicUser(user));
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
success: true,
|
||||
data: toSelfUser(user),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/api/avatars/:userId", async (ctx) => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import type { APIResponse } from "../model/interfaces.ts";
|
||||
import { resolveBandcamp } from "../services/bandcamp-stream-service.ts";
|
||||
import type { Tralbum } from "../services/bandcamp-tralbum.ts";
|
||||
|
||||
const bandcampRouter = new Router();
|
||||
|
||||
@@ -15,12 +17,15 @@ bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
const force = ctx.request.url.searchParams.get("force") === "1";
|
||||
|
||||
const album = await resolveBandcamp(url, { force });
|
||||
const responseBody: APIResponse<Tralbum> = {
|
||||
success: true,
|
||||
data: await resolveBandcamp(url, { force }),
|
||||
};
|
||||
|
||||
// Private: the signed URLs in the body are per-fetch, so they must not be
|
||||
// held in a shared cache. Well inside the 24h signature life either way.
|
||||
ctx.response.headers.set("Cache-Control", "private, max-age=3600");
|
||||
ctx.response.body = { success: true, data: album };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default bandcampRouter;
|
||||
|
||||
@@ -63,7 +63,7 @@ router.post(
|
||||
authMiddleware,
|
||||
requirePermission("category:manage"),
|
||||
async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isCreateCategoryRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -86,7 +86,7 @@ router.put(
|
||||
authMiddleware,
|
||||
requirePermission("category:manage"),
|
||||
async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdateCategoryRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
type Comment,
|
||||
isCreateCommentRequest,
|
||||
isUpdateCommentRequest,
|
||||
type PaginatedData,
|
||||
type PublicUser,
|
||||
toPublicUser,
|
||||
} from "../model/interfaces.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
@@ -44,7 +47,7 @@ router.get("/dumps/:dumpId/comments", async (ctx) => {
|
||||
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
||||
const userId = ctx.state.user.userId;
|
||||
const dump = getDump(ctx.params.dumpId, userId);
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isCreateCommentRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -68,7 +71,7 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
||||
router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
||||
const userId = ctx.state.user.userId;
|
||||
const canModerate = can(ctx.state.user, "comment:moderate");
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdateCommentRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -98,14 +101,15 @@ router.get("/comments/:commentId/likers", async (ctx) => {
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<PublicUser>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
|
||||
items: items.map(toPublicUser),
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
},
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// DELETE /api/comments/:commentId — auth required
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
isUpdateDumpRequest,
|
||||
type PaginatedData,
|
||||
type Playlist,
|
||||
type PublicUser,
|
||||
toPublicUser,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
@@ -45,7 +47,7 @@ const router = new Router({ prefix: "/api/dumps" });
|
||||
function parseCategoryIds(value: FormDataEntryValue | null): string[] {
|
||||
if (typeof value !== "string" || !value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
@@ -87,7 +89,7 @@ router.post(
|
||||
parseCategoryIds(formData.get("categoryIds")),
|
||||
);
|
||||
} else {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
if (!isCreateUrlDumpRequest(body)) {
|
||||
throw new APIException(
|
||||
@@ -129,14 +131,15 @@ router.get("/:dumpId/voters", async (ctx) => {
|
||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, total } = getDumpVoters(dump.id, page, limit);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<PublicUser>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
|
||||
items: items.map(toPublicUser),
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
},
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
router.get("/:dumpId/related", async (ctx) => {
|
||||
@@ -215,7 +218,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const dumpId = ctx.params.dumpId;
|
||||
const userId = ctx.state.user.userId;
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
if (!isUpdateDumpRequest(body)) {
|
||||
throw new APIException(
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type APIResponse,
|
||||
type InviteResponse,
|
||||
} from "../model/interfaces.ts";
|
||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||
import { createInvite, validateInvite } from "../services/invite-service.ts";
|
||||
|
||||
@@ -10,16 +15,20 @@ router.post("/", authMiddleware, (ctx: AuthContext) => {
|
||||
if (!ctx.state.user) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||
}
|
||||
const token = createInvite(ctx.state.user.userId);
|
||||
const responseBody: APIResponse<InviteResponse> = {
|
||||
success: true,
|
||||
data: { token: createInvite(ctx.state.user.userId) },
|
||||
};
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { success: true, data: { token } };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Validate an invite token (used by the register page before showing the form)
|
||||
router.get("/:token", (ctx) => {
|
||||
try {
|
||||
validateInvite(ctx.params.token);
|
||||
ctx.response.body = { success: true, data: null };
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
} catch {
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Router } from "@oak/oak";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type APIResponse,
|
||||
type Notification,
|
||||
type PaginatedData,
|
||||
} from "../model/interfaces.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
@@ -25,14 +27,11 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<Notification>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// POST /api/notifications/read-all
|
||||
|
||||
@@ -3,9 +3,13 @@ import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type APIResponse,
|
||||
isCreatePlaylistRequest,
|
||||
isReorderPlaylistRequest,
|
||||
isUpdatePlaylistRequest,
|
||||
type Playlist,
|
||||
type PlaylistMembership,
|
||||
type PlaylistWithDumps,
|
||||
} from "../model/interfaces.ts";
|
||||
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
|
||||
import { can } from "../lib/permissions.ts";
|
||||
@@ -34,13 +38,16 @@ const router = new Router<AuthState>({ prefix: "/api/playlists" });
|
||||
router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => {
|
||||
const { dumpId } = ctx.params;
|
||||
const userId = ctx.state.user.userId;
|
||||
const memberships = getPlaylistMembershipsForDump(dumpId, userId);
|
||||
ctx.response.body = { success: true, data: memberships };
|
||||
const responseBody: APIResponse<PlaylistMembership[]> = {
|
||||
success: true,
|
||||
data: getPlaylistMembershipsForDump(dumpId, userId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// POST /api/playlists — create
|
||||
router.post("/", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isCreatePlaylistRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -48,21 +55,27 @@ router.post("/", authMiddleware, async (ctx) => {
|
||||
"Invalid request",
|
||||
);
|
||||
}
|
||||
const playlist = createPlaylist(body, ctx.state.user.userId);
|
||||
const responseBody: APIResponse<Playlist> = {
|
||||
success: true,
|
||||
data: createPlaylist(body, ctx.state.user.userId),
|
||||
};
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = { success: true, data: playlist };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// GET /api/playlists/:playlistId — optional auth
|
||||
router.get("/:playlistId", async (ctx) => {
|
||||
const requestingUserId = await parseOptionalAuth(ctx);
|
||||
const playlist = getPlaylist(ctx.params.playlistId, requestingUserId);
|
||||
ctx.response.body = { success: true, data: playlist };
|
||||
const responseBody: APIResponse<PlaylistWithDumps> = {
|
||||
success: true,
|
||||
data: getPlaylist(ctx.params.playlistId, requestingUserId),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// PUT /api/playlists/:playlistId — update metadata
|
||||
router.put("/:playlistId", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdatePlaylistRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -76,7 +89,8 @@ router.put("/:playlistId", authMiddleware, async (ctx) => {
|
||||
ctx.state.user.userId,
|
||||
can(ctx.state.user, "playlist:moderate"),
|
||||
);
|
||||
ctx.response.body = { success: true, data: playlist };
|
||||
const responseBody: APIResponse<Playlist> = { success: true, data: playlist };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// DELETE /api/playlists/:playlistId
|
||||
@@ -158,7 +172,8 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
|
||||
ctx.state.user.userId,
|
||||
can(ctx.state.user, "playlist:moderate"),
|
||||
);
|
||||
ctx.response.body = { success: true, data: playlist };
|
||||
const responseBody: APIResponse<Playlist> = { success: true, data: playlist };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// GET /api/playlists/:playlistId/image — serve playlist image
|
||||
@@ -177,7 +192,7 @@ router.get("/:playlistId/image", async (ctx) => {
|
||||
|
||||
// PUT /api/playlists/:playlistId/order — reorder
|
||||
router.put("/:playlistId/order", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isReorderPlaylistRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -191,7 +206,8 @@ router.put("/:playlistId/order", authMiddleware, async (ctx) => {
|
||||
ctx.state.user.userId,
|
||||
can(ctx.state.user, "playlist:moderate"),
|
||||
);
|
||||
ctx.response.body = { success: true, data: null };
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
isValidHttpUrl,
|
||||
tryFetchRichContent,
|
||||
} from "../services/rich-content-service.ts";
|
||||
import { APIErrorCode } from "../model/interfaces.ts";
|
||||
import {
|
||||
APIErrorCode,
|
||||
type APIFailure,
|
||||
type APIResponse,
|
||||
type LinkPreview,
|
||||
} from "../model/interfaces.ts";
|
||||
import { authMiddleware } from "../middleware/auth.ts";
|
||||
|
||||
const previewRouter = new Router();
|
||||
@@ -25,11 +30,12 @@ const PROXYABLE_IMAGE_TYPES = new Set([
|
||||
previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||
if (!isValidHttpUrl(url)) {
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = {
|
||||
const responseBody: APIFailure = {
|
||||
success: false,
|
||||
error: { code: APIErrorCode.VALIDATION_ERROR, message: "Invalid URL" },
|
||||
};
|
||||
ctx.response.status = 400;
|
||||
ctx.response.body = responseBody;
|
||||
return;
|
||||
}
|
||||
// `reached` is reported separately because a failed fetch still yields a
|
||||
@@ -37,10 +43,11 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
|
||||
// "this page has no preview" from "this link is dead", and shows the same
|
||||
// bare card for both.
|
||||
const { ok, content } = await tryFetchRichContent(url);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<LinkPreview> = {
|
||||
success: true,
|
||||
data: { reached: ok, richContent: content ?? null },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { parsePagination } from "../lib/pagination.ts";
|
||||
import { searchDumps } from "../services/dump-service.ts";
|
||||
import { searchUsersGlobal } from "../services/user-service.ts";
|
||||
import { searchPlaylists } from "../services/playlist-service.ts";
|
||||
import type { APIResponse, SearchResults } from "../model/interfaces.ts";
|
||||
|
||||
const router = new Router({ prefix: "/api/search" });
|
||||
|
||||
@@ -14,7 +15,7 @@ router.get("/", async (ctx) => {
|
||||
const requestingUserId = (await parseOptionalAuth(ctx)) ?? undefined;
|
||||
|
||||
if (!q) {
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<SearchResults> = {
|
||||
success: true,
|
||||
data: {
|
||||
dumps: { items: [], total: 0, hasMore: false },
|
||||
@@ -22,6 +23,7 @@ router.get("/", async (ctx) => {
|
||||
playlists: [],
|
||||
},
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,7 +36,7 @@ router.get("/", async (ctx) => {
|
||||
const users = searchUsersGlobal(q, 20);
|
||||
const playlists = searchPlaylists(q, 20, requestingUserId);
|
||||
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<SearchResults> = {
|
||||
success: true,
|
||||
data: {
|
||||
dumps: {
|
||||
@@ -46,6 +48,7 @@ router.get("/", async (ctx) => {
|
||||
playlists,
|
||||
},
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,11 +3,25 @@ import { Router } from "@oak/oak";
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type APIResponse,
|
||||
type AuthResponse,
|
||||
type AuthTokens,
|
||||
type Dump,
|
||||
fieldOf,
|
||||
type Invitee,
|
||||
isLoginUserRequest,
|
||||
isRegisterUserRequest,
|
||||
isRole,
|
||||
isUpdateUserRequest,
|
||||
type MediaTokenResponse,
|
||||
type PaginatedData,
|
||||
type Playlist,
|
||||
type PublicUser,
|
||||
type SelfUser,
|
||||
toPublicUser,
|
||||
toSelfUser,
|
||||
type User,
|
||||
type UserSuggestion,
|
||||
validateRegisterUserRequest,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
@@ -60,7 +74,7 @@ const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
|
||||
|
||||
// The session token, plus the media-only token that goes in file and
|
||||
// thumbnail URLs (see createMediaToken).
|
||||
async function issueTokens(user: User) {
|
||||
async function issueTokens(user: User): Promise<AuthTokens> {
|
||||
return {
|
||||
token: await createJWT({
|
||||
userId: user.id,
|
||||
@@ -73,11 +87,14 @@ async function issueTokens(user: User) {
|
||||
|
||||
// Register a new user (requires a valid invite token)
|
||||
router.post("/register", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
const registerError = validateRegisterUserRequest(body);
|
||||
if (registerError) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, registerError);
|
||||
if (!isRegisterUserRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
validateRegisterUserRequest(body) ?? "Invalid request",
|
||||
);
|
||||
}
|
||||
|
||||
// Claim the invite up front (throws 404/409 if bad) and hand it back if the
|
||||
@@ -106,17 +123,17 @@ router.post("/register", async (ctx) => {
|
||||
}).catch((err) => console.error("[register] welcome email failed:", err));
|
||||
}
|
||||
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<AuthResponse> = {
|
||||
success: true,
|
||||
data: { ...await issueTokens(user), user: publicUser },
|
||||
data: { ...await issueTokens(user), user: toSelfUser(user) },
|
||||
};
|
||||
ctx.response.status = 201;
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post("/login", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
|
||||
if (!isLoginUserRequest(body)) {
|
||||
throw new APIException(
|
||||
@@ -157,11 +174,11 @@ router.post("/login", async (ctx) => {
|
||||
);
|
||||
}
|
||||
|
||||
const { passwordHash: _, ...publicUser } = user;
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<AuthResponse> = {
|
||||
success: true,
|
||||
data: { ...await issueTokens(user), user: publicUser },
|
||||
data: { ...await issueTokens(user), user: toSelfUser(user) },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Get current user profile
|
||||
@@ -175,14 +192,11 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
||||
);
|
||||
}
|
||||
|
||||
const { passwordHash: _, ...publicUser } = getUserById(
|
||||
ctx.state.user.userId,
|
||||
);
|
||||
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<SelfUser> = {
|
||||
success: true,
|
||||
data: publicUser,
|
||||
data: toSelfUser(getUserById(ctx.state.user.userId)),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -196,28 +210,32 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
||||
|
||||
// A media token for a session that predates them (see createMediaToken).
|
||||
router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<MediaTokenResponse> = {
|
||||
success: true,
|
||||
data: { mediaToken: await createMediaToken(ctx.state.user.userId) },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Request a password reset email (unauthenticated; always returns 200)
|
||||
router.post("/request-password-reset", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const email = typeof body?.email === "string" ? body.email.trim() : "";
|
||||
if (email) {
|
||||
await requestPasswordReset(email).catch((err) =>
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const email = fieldOf(body, "email");
|
||||
const trimmed = typeof email === "string" ? email.trim() : "";
|
||||
if (trimmed) {
|
||||
await requestPasswordReset(trimmed).catch((err) =>
|
||||
console.error("[request-password-reset]", err)
|
||||
);
|
||||
}
|
||||
ctx.response.body = { success: true };
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Consume a reset token and set a new password (unauthenticated)
|
||||
router.post("/reset-password", async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { token, newPassword } = (body ?? {}) as Record<string, unknown>;
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const token = fieldOf(body, "token");
|
||||
const newPassword = fieldOf(body, "newPassword");
|
||||
if (typeof token !== "string" || typeof newPassword !== "string") {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -226,16 +244,15 @@ router.post("/reset-password", async (ctx) => {
|
||||
);
|
||||
}
|
||||
await resetPassword(token, newPassword);
|
||||
ctx.response.body = { success: true };
|
||||
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Change current user's password (requires current password verification)
|
||||
router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const { currentPassword, newPassword } = (body ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const currentPassword = fieldOf(body, "currentPassword");
|
||||
const newPassword = fieldOf(body, "newPassword");
|
||||
|
||||
if (typeof currentPassword !== "string" || typeof newPassword !== "string") {
|
||||
throw new APIException(
|
||||
@@ -271,12 +288,16 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => {
|
||||
const updated = await updateUser(ctx.state.user.userId, {
|
||||
password: newPassword,
|
||||
});
|
||||
ctx.response.body = { success: true, data: await issueTokens(updated) };
|
||||
const responseBody: APIResponse<AuthTokens> = {
|
||||
success: true,
|
||||
data: await issueTokens(updated),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Update current user profile (description, etc.)
|
||||
router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
if (!isUpdateUserRequest(body)) {
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
@@ -292,9 +313,13 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
||||
description: body.description,
|
||||
email: body.email,
|
||||
});
|
||||
const { passwordHash: _, email: _email, ...publicUser } = updated;
|
||||
const publicUser = toPublicUser(updated);
|
||||
broadcastUserUpdated(publicUser);
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
data: publicUser,
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Set another user's role. Requires the user:manage permission.
|
||||
@@ -303,10 +328,14 @@ router.patch(
|
||||
authMiddleware,
|
||||
requirePermission("user:manage"),
|
||||
async (ctx: AuthContext<"/:userId/role">) => {
|
||||
const body = await ctx.request.body.json();
|
||||
const role: unknown = body?.role;
|
||||
const body: unknown = await ctx.request.body.json();
|
||||
const role = fieldOf(body, "role");
|
||||
if (!isRole(role)) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "Invalid role");
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"Invalid role",
|
||||
);
|
||||
}
|
||||
// Disallow changing your own role: prevents an admin from accidentally
|
||||
// locking themselves out, and self-promotion is meaningless anyway.
|
||||
@@ -318,24 +347,33 @@ router.patch(
|
||||
);
|
||||
}
|
||||
const updated = await updateUser(ctx.params.userId, { role });
|
||||
const { passwordHash: _, email: _email, ...publicUser } = updated;
|
||||
const publicUser = toPublicUser(updated);
|
||||
broadcastUserUpdated(publicUser);
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
data: publicUser,
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
},
|
||||
);
|
||||
|
||||
// User search for @mention autocomplete
|
||||
router.get("/search", (ctx) => {
|
||||
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
|
||||
const results = searchUsers(q, 8);
|
||||
ctx.response.body = { success: true, data: results };
|
||||
const responseBody: APIResponse<UserSuggestion[]> = {
|
||||
success: true,
|
||||
data: searchUsers(q, 8),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
|
||||
router.get("/by-id/:userId", (ctx) => {
|
||||
const user = getUserById(ctx.params.userId);
|
||||
const { passwordHash: _, email: _email, ...publicUser } = user;
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
data: toPublicUser(getUserById(ctx.params.userId)),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Followed users for a user (public)
|
||||
@@ -343,14 +381,15 @@ router.get("/:username/followed-users", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, total } = getFollowedUsersByUser(user.id, page, limit);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<PublicUser>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items: items.map(({ passwordHash: _, email: _e, ...pub }) => pub),
|
||||
items: items.map(toPublicUser),
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
},
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Followed playlists for a user (public only)
|
||||
@@ -358,14 +397,11 @@ router.get("/:username/followed-playlists", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Playlists by user (optional auth: include private only if requester === owner)
|
||||
@@ -379,28 +415,30 @@ router.get("/:username/playlists", async (ctx) => {
|
||||
page,
|
||||
limit,
|
||||
);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<Playlist>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Invite tree for a user (public)
|
||||
router.get("/:username/invitees", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const tree = getInviteTree(user.id);
|
||||
ctx.response.body = { success: true, data: tree };
|
||||
const responseBody: APIResponse<Invitee[]> = {
|
||||
success: true,
|
||||
data: getInviteTree(user.id),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Public user profile by username (no passwordHash)
|
||||
router.get("/:username", (ctx) => {
|
||||
const user = getUserByUsername(ctx.params.username);
|
||||
const { passwordHash: _, email: _email, ...publicUser } = user;
|
||||
ctx.response.body = { success: true, data: publicUser };
|
||||
const responseBody: APIResponse<PublicUser> = {
|
||||
success: true,
|
||||
data: toPublicUser(getUserByUsername(ctx.params.username)),
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Dumps posted by user (optional auth: owner sees their private dumps)
|
||||
@@ -410,14 +448,11 @@ router.get("/:username/dumps", async (ctx) => {
|
||||
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||
const includePrivate = requestingUserId === user.id;
|
||||
const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
|
||||
@@ -431,14 +466,11 @@ router.get("/:username/votes", async (ctx) => {
|
||||
limit,
|
||||
requestingUserId,
|
||||
);
|
||||
ctx.response.body = {
|
||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
hasMore: page * limit < total,
|
||||
} satisfies PaginatedData<typeof items[number]>,
|
||||
data: { items, total, hasMore: page * limit < total },
|
||||
};
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -44,6 +44,7 @@ import { getUserById } from "../services/user-service.ts";
|
||||
import {
|
||||
APIException,
|
||||
type ClientToServerMessage,
|
||||
type ServerToClientMessage,
|
||||
type User,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
@@ -193,7 +194,7 @@ function handleMessage(client: WsClient, data: unknown): void {
|
||||
}
|
||||
|
||||
// Sends only while the socket is open: `send` on a closing socket throws.
|
||||
function send(socket: WebSocket, data: Record<string, unknown>): void {
|
||||
function send(socket: WebSocket, data: ServerToClientMessage): void {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
@@ -219,7 +220,6 @@ function handleVote(
|
||||
type: "vote_ack",
|
||||
dumpId,
|
||||
action,
|
||||
success: true,
|
||||
voteCount,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { resolveYouTubePlaylist } from "../services/youtube-playlist-service.ts";
|
||||
import type { APIResponse } from "../model/interfaces.ts";
|
||||
import {
|
||||
resolveYouTubePlaylist,
|
||||
type YouTubePlaylist,
|
||||
} from "../services/youtube-playlist-service.ts";
|
||||
|
||||
const youtubeRouter = new Router();
|
||||
|
||||
@@ -11,10 +15,13 @@ const youtubeRouter = new Router();
|
||||
*/
|
||||
youtubeRouter.get("/api/youtube/playlist", async (ctx) => {
|
||||
const listId = ctx.request.url.searchParams.get("list") ?? "";
|
||||
const playlist = await resolveYouTubePlaylist(listId);
|
||||
const responseBody: APIResponse<YouTubePlaylist> = {
|
||||
success: true,
|
||||
data: await resolveYouTubePlaylist(listId),
|
||||
};
|
||||
|
||||
ctx.response.headers.set("Cache-Control", "public, max-age=3600");
|
||||
ctx.response.body = { success: true, data: playlist };
|
||||
ctx.response.body = responseBody;
|
||||
});
|
||||
|
||||
export default youtubeRouter;
|
||||
|
||||
@@ -147,7 +147,9 @@ export function updateCategory(
|
||||
if (req.slug !== undefined && req.slug !== existing.slug) {
|
||||
validateSlug(req.slug);
|
||||
}
|
||||
const newName = req.name !== undefined ? requireName(req.name) : existing.name;
|
||||
const newName = req.name !== undefined
|
||||
? requireName(req.name)
|
||||
: existing.name;
|
||||
const newPosition = req.position ?? existing.position;
|
||||
const now = new Date();
|
||||
|
||||
|
||||
@@ -115,7 +115,9 @@ export async function resetPassword(
|
||||
try {
|
||||
await updateUser(payload.userId, { password: newPassword });
|
||||
} catch (err) {
|
||||
db.prepare(`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`)
|
||||
db.prepare(
|
||||
`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`,
|
||||
)
|
||||
.run(token);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
APIErrorCode,
|
||||
APIException,
|
||||
type Invitee,
|
||||
type PublicUser,
|
||||
type RegisterUserRequest,
|
||||
toPublicUser,
|
||||
type UpdateUserRequest,
|
||||
type User,
|
||||
type UserSuggestion,
|
||||
} from "../model/interfaces.ts";
|
||||
import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
|
||||
import { closeUserSockets, disconnectUser } from "./ws-service.ts";
|
||||
@@ -129,7 +133,7 @@ export function getUserByEmail(email: string): User | null {
|
||||
export function searchUsers(
|
||||
query: string,
|
||||
limit: number,
|
||||
): { id: string; username: string; avatarMime: string | null }[] {
|
||||
): UserSuggestion[] {
|
||||
if (!query) return [];
|
||||
const rows = db.prepare(
|
||||
`SELECT id, username, avatar_mime FROM users WHERE username LIKE ? ORDER BY username LIMIT ?;`,
|
||||
@@ -145,13 +149,14 @@ export function searchUsers(
|
||||
}));
|
||||
}
|
||||
|
||||
export function searchUsersGlobal(query: string, limit: number): User[] {
|
||||
// For the public search page: profiles only, never hashes or emails.
|
||||
export function searchUsersGlobal(query: string, limit: number): PublicUser[] {
|
||||
if (!query.trim()) return [];
|
||||
const pattern = `%${query}%`;
|
||||
const rows = db.prepare(
|
||||
`${USER_SELECT} WHERE (u.username LIKE ? OR u.description LIKE ?) ORDER BY u.username LIMIT ?;`,
|
||||
).all(pattern, pattern, limit);
|
||||
return rows.filter(isUserRow).map(userRowToApi);
|
||||
return rows.filter(isUserRow).map(userRowToApi).map(toPublicUser);
|
||||
}
|
||||
|
||||
export function listUsers(): User[] {
|
||||
@@ -231,15 +236,7 @@ export function updateUserAvatar(userId: string, mime: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function getInviteTree(
|
||||
userId: string,
|
||||
): {
|
||||
id: string;
|
||||
username: string;
|
||||
avatarMime?: string;
|
||||
invitedById: string;
|
||||
createdAt: Date;
|
||||
}[] {
|
||||
export function getInviteTree(userId: string): Invitee[] {
|
||||
const rows = db.prepare(`
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, username, avatar_mime, invited_by, created_at, 0 AS depth
|
||||
|
||||
@@ -4,9 +4,9 @@ import type {
|
||||
Dump,
|
||||
OnlineUser,
|
||||
Playlist,
|
||||
PublicUser,
|
||||
Role,
|
||||
ServerToClientMessage,
|
||||
User,
|
||||
} from "../model/interfaces.ts";
|
||||
import {
|
||||
isMemberVisibleTo,
|
||||
@@ -223,9 +223,7 @@ export function broadcastPlaylistDumpsUpdated(
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastUserUpdated(
|
||||
user: Omit<User, "passwordHash" | "email">,
|
||||
): void {
|
||||
export function broadcastUserUpdated(user: PublicUser): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "user_updated", user });
|
||||
}
|
||||
|
||||
@@ -72,29 +72,43 @@ function firstString(node: unknown, key: string): string | undefined {
|
||||
return found.find((v): v is string => typeof v === "string");
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
type Json = any;
|
||||
/** Follows `path` into parsed JSON; undefined wherever a step is missing. */
|
||||
function at(node: unknown, ...path: (string | number)[]): unknown {
|
||||
let cur = node;
|
||||
for (const key of path) {
|
||||
if (cur === null || typeof cur !== "object") return undefined;
|
||||
cur = (cur as Record<string | number, unknown>)[key];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
function fromLockup(lockup: Json): YouTubePlaylistTrack | null {
|
||||
if (lockup?.contentType !== "LOCKUP_CONTENT_TYPE_VIDEO") return null;
|
||||
const videoId = lockup.contentId;
|
||||
const title = lockup.metadata?.lockupMetadataViewModel?.title?.content;
|
||||
function fromLockup(lockup: unknown): YouTubePlaylistTrack | null {
|
||||
if (at(lockup, "contentType") !== "LOCKUP_CONTENT_TYPE_VIDEO") return null;
|
||||
const videoId = at(lockup, "contentId");
|
||||
const title = at(
|
||||
lockup,
|
||||
"metadata",
|
||||
"lockupMetadataViewModel",
|
||||
"title",
|
||||
"content",
|
||||
);
|
||||
if (typeof videoId !== "string" || typeof title !== "string") return null;
|
||||
const badges: unknown[] = [];
|
||||
collect(lockup.contentImage, "thumbnailBadgeViewModel", badges);
|
||||
collect(at(lockup, "contentImage"), "thumbnailBadgeViewModel", badges);
|
||||
const duration = badges
|
||||
.map((b) => (b as Json)?.text)
|
||||
.map((b) => at(b, "text"))
|
||||
.filter((t): t is string => typeof t === "string")
|
||||
.map(parseClock)
|
||||
.find((d) => d !== undefined);
|
||||
return { videoId, title, duration };
|
||||
}
|
||||
|
||||
function fromLegacyRenderer(r: Json): YouTubePlaylistTrack | null {
|
||||
const videoId = r?.videoId;
|
||||
const title = r?.title?.runs?.[0]?.text ?? r?.title?.simpleText;
|
||||
function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null {
|
||||
const videoId = at(r, "videoId");
|
||||
const title = at(r, "title", "runs", 0, "text") ??
|
||||
at(r, "title", "simpleText");
|
||||
if (typeof videoId !== "string" || typeof title !== "string") return null;
|
||||
const secs = Number(r.lengthSeconds);
|
||||
const secs = Number(at(r, "lengthSeconds"));
|
||||
return {
|
||||
videoId,
|
||||
title,
|
||||
@@ -111,7 +125,7 @@ export function parsePlaylistPage(
|
||||
const end = html.indexOf(";</script>", start);
|
||||
if (end < 0) return null;
|
||||
|
||||
let data: Json;
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(html.slice(start + "var ytInitialData = ".length, end));
|
||||
} catch {
|
||||
@@ -119,9 +133,9 @@ export function parsePlaylistPage(
|
||||
}
|
||||
|
||||
const lockups: unknown[] = [];
|
||||
collect(data?.contents, "lockupViewModel", lockups);
|
||||
collect(at(data, "contents"), "lockupViewModel", lockups);
|
||||
const legacy: unknown[] = [];
|
||||
collect(data?.contents, "playlistVideoRenderer", legacy);
|
||||
collect(at(data, "contents"), "playlistVideoRenderer", legacy);
|
||||
|
||||
// A video can appear in a playlist twice; both rows are kept, so the list
|
||||
// stays positional and matches what the embed plays.
|
||||
@@ -131,8 +145,10 @@ export function parsePlaylistPage(
|
||||
].filter((t): t is YouTubePlaylistTrack => t !== null);
|
||||
if (tracks.length === 0) return null;
|
||||
|
||||
const title = data?.metadata?.playlistMetadataRenderer?.title ??
|
||||
firstString(data?.header, "content");
|
||||
const metaTitle = at(data, "metadata", "playlistMetadataRenderer", "title");
|
||||
const title = typeof metaTitle === "string"
|
||||
? metaTitle
|
||||
: firstString(at(data, "header"), "content");
|
||||
return { listId, title, tracks };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Tokens, passwords and the checks that turn a token into a user.
|
||||
import { count, createUser, db, get, PASSWORD, post, test } from "./helpers.ts";
|
||||
import type {
|
||||
AuthResponse,
|
||||
AuthTokens,
|
||||
MediaTokenResponse,
|
||||
} from "../model/interfaces.ts";
|
||||
import {
|
||||
assert,
|
||||
assertEquals,
|
||||
@@ -137,7 +142,7 @@ test("optional auth: a missing or placeholder token is anonymous, a bad one is 4
|
||||
);
|
||||
const bad = await get(path, { headers: { Authorization: "Bearer nope" } });
|
||||
assertEquals(bad.status, 401);
|
||||
assertEquals(bad.body.error.code, "UNAUTHORIZED");
|
||||
assertEquals(bad.error.code, "UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test("required auth: missing, malformed and forged tokens are 401", async () => {
|
||||
@@ -160,15 +165,15 @@ test("required auth: missing, malformed and forged tokens are 401", async () =>
|
||||
|
||||
test("POST /api/users/login: right password logs in, wrong one doesn't say which", async () => {
|
||||
const user = await createUser();
|
||||
const ok = await post("/api/users/login", {
|
||||
const ok = await post<AuthResponse>("/api/users/login", {
|
||||
json: { username: user.username, password: PASSWORD },
|
||||
});
|
||||
assertEquals(ok.status, 200);
|
||||
assertEquals(ok.body.data.user.id, user.id);
|
||||
assertEquals(ok.body.data.user.passwordHash, undefined);
|
||||
assertEquals((await verifyJWT(ok.body.data.token))?.userId, user.id);
|
||||
assertEquals(ok.data.user.id, user.id);
|
||||
assertFalse("passwordHash" in ok.data.user);
|
||||
assertEquals((await verifyJWT(ok.data.token))?.userId, user.id);
|
||||
assertEquals(
|
||||
(await verifyMediaToken(ok.body.data.mediaToken))?.userId,
|
||||
(await verifyMediaToken(ok.data.mediaToken))?.userId,
|
||||
user.id,
|
||||
);
|
||||
|
||||
@@ -180,7 +185,7 @@ test("POST /api/users/login: right password logs in, wrong one doesn't say which
|
||||
});
|
||||
assertEquals(wrong.status, 401);
|
||||
assertEquals(unknown.status, 401);
|
||||
assertEquals(wrong.body.error.message, unknown.body.error.message);
|
||||
assertEquals(wrong.error.message, unknown.error.message);
|
||||
|
||||
assertEquals(
|
||||
(await post("/api/users/login", { json: { username: user.username } }))
|
||||
@@ -222,7 +227,7 @@ function invite(inviterId: string, createdAt = new Date()): string {
|
||||
test("POST /api/users/register: an invite opens one account", async () => {
|
||||
const inviter = await createUser();
|
||||
const inviteToken = invite(inviter.id);
|
||||
const res = await post("/api/users/register", {
|
||||
const res = await post<AuthResponse>("/api/users/register", {
|
||||
json: {
|
||||
username: "newbie_reg",
|
||||
password: "longenough",
|
||||
@@ -231,10 +236,10 @@ test("POST /api/users/register: an invite opens one account", async () => {
|
||||
},
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
assertEquals(res.body.data.user.username, "newbie_reg");
|
||||
assertEquals(res.body.data.user.role, "user");
|
||||
assertEquals(res.body.data.user.passwordHash, undefined);
|
||||
assert(res.body.data.token);
|
||||
assertEquals(res.data.user.username, "newbie_reg");
|
||||
assertEquals(res.data.user.role, "user");
|
||||
assertFalse("passwordHash" in res.data.user);
|
||||
assert(res.data.token);
|
||||
const row = db.prepare(
|
||||
`SELECT invited_by FROM users WHERE username = 'newbie_reg';`,
|
||||
).get() as { invited_by: string };
|
||||
@@ -271,7 +276,7 @@ test("POST /api/users/register: validation and unknown invites", async () => {
|
||||
for (const [json, message] of cases) {
|
||||
const res = await post("/api/users/register", { json });
|
||||
assertEquals(res.status, 400, JSON.stringify(json));
|
||||
assert(message.test(res.body.error.message), res.body.error.message);
|
||||
assert(message.test(res.error.message), res.error.message);
|
||||
}
|
||||
const unknown = await post("/api/users/register", {
|
||||
json: { ...base, inviteToken: "made-up" },
|
||||
@@ -372,16 +377,16 @@ test("change-password: needs the current one, revokes old tokens, returns new on
|
||||
// iat has one-second resolution: make sure the old token is from an earlier
|
||||
// second than the change.
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
const ok = await post("/api/users/me/change-password", {
|
||||
const ok = await post<AuthTokens>("/api/users/me/change-password", {
|
||||
auth: user.auth,
|
||||
json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" },
|
||||
});
|
||||
assertEquals(ok.status, 200);
|
||||
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401);
|
||||
assertEquals(await authenticateMedia(user.mediaToken), null);
|
||||
const fresh = { Authorization: `Bearer ${ok.body.data.token}` };
|
||||
const fresh = { Authorization: `Bearer ${ok.data.token}` };
|
||||
assertEquals((await get("/api/users/me", { auth: fresh })).status, 200);
|
||||
assertEquals(await authenticateMedia(ok.body.data.mediaToken), user.id);
|
||||
assertEquals(await authenticateMedia(ok.data.mediaToken), user.id);
|
||||
|
||||
const login = await post("/api/users/login", {
|
||||
json: { username: user.username, password: "brand-new-pass" },
|
||||
@@ -452,7 +457,9 @@ test("reset-password: an unstored, forged or wrong-purpose token fails", async (
|
||||
|
||||
test("GET /api/users/me/media-token issues a media token for the caller", async () => {
|
||||
const user = await createUser();
|
||||
const res = await get("/api/users/me/media-token", { auth: user.auth });
|
||||
const res = await get<MediaTokenResponse>("/api/users/me/media-token", {
|
||||
auth: user.auth,
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(await authenticateMedia(res.body.data.mediaToken), user.id);
|
||||
assertEquals(await authenticateMedia(res.data.mediaToken), user.id);
|
||||
});
|
||||
|
||||
@@ -10,19 +10,28 @@ import {
|
||||
put,
|
||||
test,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals, assertStringIncludes } from "@std/assert";
|
||||
import type {
|
||||
Category,
|
||||
Dump,
|
||||
PaginatedData,
|
||||
SearchResults,
|
||||
} from "../model/interfaces.ts";
|
||||
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
|
||||
|
||||
test("categories: listed by position, then slug; found by slug", async () => {
|
||||
insertCategory({ slug: "zeta", position: 0 });
|
||||
insertCategory({ slug: "alpha", position: 1 });
|
||||
insertCategory({ slug: "beta", position: 0 });
|
||||
const list = await get("/api/categories");
|
||||
assertEquals(list.body.data.map((c: { slug: string }) => c.slug), [
|
||||
const list = await get<Category[]>("/api/categories");
|
||||
assertEquals(list.data.map((c) => c.slug), [
|
||||
"beta",
|
||||
"zeta",
|
||||
"alpha",
|
||||
]);
|
||||
assertEquals((await get("/api/categories/alpha")).body.data.slug, "alpha");
|
||||
assertEquals(
|
||||
(await get<Category>("/api/categories/alpha")).data.slug,
|
||||
"alpha",
|
||||
);
|
||||
assertEquals((await get("/api/categories/nope")).status, 404);
|
||||
});
|
||||
|
||||
@@ -35,9 +44,12 @@ test("categories: only category:manage may create, update or delete", async () =
|
||||
(await post("/api/categories", { auth: mod.auth, json })).status,
|
||||
403,
|
||||
);
|
||||
const created = await post("/api/categories", { auth: admin.auth, json });
|
||||
const created = await post<Category>("/api/categories", {
|
||||
auth: admin.auth,
|
||||
json,
|
||||
});
|
||||
assertEquals(created.status, 201);
|
||||
const id = created.body.data.id;
|
||||
const id = created.data.id;
|
||||
assertEquals(
|
||||
(await put(`/api/categories/${id}`, {
|
||||
auth: mod.auth,
|
||||
@@ -62,7 +74,7 @@ test("categories: only category:manage may create, update or delete", async () =
|
||||
test("categories: slug and name rules, and friendly conflicts", async () => {
|
||||
const admin = await createUser({ role: "admin" });
|
||||
const create = (json: unknown) =>
|
||||
post("/api/categories", { auth: admin.auth, json });
|
||||
post<Category>("/api/categories", { auth: admin.auth, json });
|
||||
for (
|
||||
const [json, message] of [
|
||||
[{ slug: "Bad Slug", name: "x" }, "lowercase"],
|
||||
@@ -74,32 +86,32 @@ test("categories: slug and name rules, and friendly conflicts", async () => {
|
||||
) {
|
||||
const res = await create(json);
|
||||
assertEquals(res.status, 400, JSON.stringify(json));
|
||||
assertStringIncludes(res.body.error.message, message);
|
||||
assertStringIncludes(res.error.message, message);
|
||||
}
|
||||
assertEquals((await create({ slug: "ok" })).status, 400);
|
||||
|
||||
const first = await create({ slug: "music", name: " Music " });
|
||||
assertEquals(first.body.data.name, "Music");
|
||||
assertEquals(first.data.name, "Music");
|
||||
const dupSlug = await create({ slug: "music", name: "Other" });
|
||||
assertEquals(dupSlug.status, 409);
|
||||
assertStringIncludes(dupSlug.body.error.message, "Slug");
|
||||
assertStringIncludes(dupSlug.error.message, "Slug");
|
||||
const dupName = await create({ slug: "other", name: "Music" });
|
||||
assertEquals(dupName.status, 409);
|
||||
assertStringIncludes(dupName.body.error.message, "Name");
|
||||
assertStringIncludes(dupName.error.message, "Name");
|
||||
|
||||
const second = await create({ slug: "films", name: "Films" });
|
||||
const rename = await put(`/api/categories/${second.body.data.id}`, {
|
||||
const rename = await put(`/api/categories/${second.data.id}`, {
|
||||
auth: admin.auth,
|
||||
json: { slug: "music" },
|
||||
});
|
||||
assertEquals(rename.status, 409);
|
||||
const ok = await put(`/api/categories/${second.body.data.id}`, {
|
||||
const ok = await put<Category>(`/api/categories/${second.data.id}`, {
|
||||
auth: admin.auth,
|
||||
json: { name: "Movies", position: 3 },
|
||||
});
|
||||
assertEquals(ok.body.data.slug, "films");
|
||||
assertEquals(ok.body.data.name, "Movies");
|
||||
assertEquals(ok.body.data.position, 3);
|
||||
assertEquals(ok.data.slug, "films");
|
||||
assertEquals(ok.data.name, "Movies");
|
||||
assertEquals(ok.data.position, 3);
|
||||
});
|
||||
|
||||
test("GET /api/categories/:slug/dumps: members only, private for the owner", async () => {
|
||||
@@ -118,13 +130,15 @@ test("GET /api/categories/:slug/dumps: members only, private for the owner", asy
|
||||
)
|
||||
.run(d.id, cat);
|
||||
}
|
||||
const anon = await get("/api/categories/music/dumps");
|
||||
assertEquals(anon.body.data.items.map((d: { title: string }) => d.title), [
|
||||
const anon = await get<PaginatedData<Dump>>("/api/categories/music/dumps");
|
||||
assertEquals(anon.data.items.map((d) => d.title), [
|
||||
"in",
|
||||
]);
|
||||
assertEquals(anon.body.data.items[0].categoryIds, [cat]);
|
||||
assertEquals(anon.data.items[0].categoryIds, [cat]);
|
||||
assertEquals(
|
||||
(await get("/api/categories/music/dumps", { auth: owner.auth })).body.data
|
||||
(await get<PaginatedData<Dump>>("/api/categories/music/dumps", {
|
||||
auth: owner.auth,
|
||||
})).data
|
||||
.total,
|
||||
2,
|
||||
);
|
||||
@@ -133,7 +147,10 @@ test("GET /api/categories/:slug/dumps: members only, private for the owner", asy
|
||||
// Deleting the category unlinks its dumps without deleting them.
|
||||
const admin = await createUser({ role: "admin" });
|
||||
await del(`/api/categories/${cat}`, { auth: admin.auth });
|
||||
assertEquals((await get(`/api/dumps/${inCat.id}`)).body.data.categoryIds, []);
|
||||
assertEquals(
|
||||
(await get<Dump>(`/api/dumps/${inCat.id}`)).data.categoryIds,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/search: dumps, users and playlists, privacy-filtered", async () => {
|
||||
@@ -162,23 +179,28 @@ test("GET /api/search: dumps, users and playlists, privacy-filtered", async () =
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
const anon = (await get("/api/search?q=banana")).body.data;
|
||||
const anon = (await get<SearchResults>("/api/search?q=banana")).data;
|
||||
assertEquals(anon.dumps.total, 3);
|
||||
assertEquals(anon.users.map((u: { id: string }) => u.id), [other.id]);
|
||||
assertEquals(anon.playlists.map((p: { title: string }) => p.title), [
|
||||
assertEquals(anon.users.map((u) => u.id), [other.id]);
|
||||
// Profiles only: a search must never hand out hashes or emails.
|
||||
assertFalse("passwordHash" in anon.users[0]);
|
||||
assertFalse("email" in anon.users[0]);
|
||||
assertEquals(anon.playlists.map((p) => p.title), [
|
||||
"Banana hits",
|
||||
]);
|
||||
|
||||
const mine =
|
||||
(await get("/api/search?q=banana", { auth: owner.auth })).body.data;
|
||||
(await get<SearchResults>("/api/search?q=banana", { auth: owner.auth }))
|
||||
.data;
|
||||
assertEquals(mine.dumps.total, 4);
|
||||
assertEquals(mine.playlists.length, 2);
|
||||
|
||||
const paged = (await get("/api/search?q=banana&limit=2")).body.data.dumps;
|
||||
const paged =
|
||||
(await get<SearchResults>("/api/search?q=banana&limit=2")).data.dumps;
|
||||
assertEquals(paged.items.length, 2);
|
||||
assertEquals(paged.hasMore, true);
|
||||
|
||||
assertEquals((await get("/api/search?q=%20%20")).body.data, {
|
||||
assertEquals((await get<SearchResults>("/api/search?q=%20%20")).data, {
|
||||
dumps: { items: [], total: 0, hasMore: false },
|
||||
users: [],
|
||||
playlists: [],
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
markChatRead,
|
||||
updateChatMessage,
|
||||
} from "../services/chat-service.ts";
|
||||
import { APIException } from "../model/interfaces.ts";
|
||||
import { APIException, type ChatMessage } from "../model/interfaces.ts";
|
||||
|
||||
function insertMessage(
|
||||
userId: string,
|
||||
@@ -130,8 +130,8 @@ test("GET /api/chat: members only, with limit and both cursors", async () => {
|
||||
const ids = [1, 2, 3].map((n) => insertMessage(user.id, `m${n}`, T(n)));
|
||||
assertEquals((await get("/api/chat")).status, 401);
|
||||
const bodies = async (q: string) =>
|
||||
(await get(`/api/chat${q}`, { auth: user.auth })).body.data.map(
|
||||
(m: { body: string }) => m.body,
|
||||
(await get<ChatMessage[]>(`/api/chat${q}`, { auth: user.auth })).data.map(
|
||||
(m) => m.body,
|
||||
);
|
||||
assertEquals(await bodies(""), ["m1", "m2", "m3"]);
|
||||
assertEquals(await bodies("?limit=1"), ["m3"]);
|
||||
|
||||
@@ -10,35 +10,41 @@ import {
|
||||
post,
|
||||
test,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals, assertExists } from "@std/assert";
|
||||
import { assertEquals, assertExists, assertFalse } from "@std/assert";
|
||||
import {
|
||||
castCommentLike,
|
||||
removeCommentLike,
|
||||
} from "../services/comment-like-service.ts";
|
||||
import { APIException } from "../model/interfaces.ts";
|
||||
import {
|
||||
APIException,
|
||||
type Comment,
|
||||
type Dump,
|
||||
type PaginatedData,
|
||||
type PublicUser,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
test("comments: post, list in order, count on the dump", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const { id } = insertDump({ userId: owner.id });
|
||||
const first = await post(`/api/dumps/${id}/comments`, {
|
||||
const first = await post<Comment>(`/api/dumps/${id}/comments`, {
|
||||
auth: other.auth,
|
||||
json: { body: " first " },
|
||||
});
|
||||
assertEquals(first.status, 201);
|
||||
assertEquals(first.body.data.body, "first");
|
||||
assertEquals(first.body.data.authorUsername, other.username);
|
||||
const reply = await post(`/api/dumps/${id}/comments`, {
|
||||
assertEquals(first.data.body, "first");
|
||||
assertEquals(first.data.authorUsername, other.username);
|
||||
const reply = await post<Comment>(`/api/dumps/${id}/comments`, {
|
||||
auth: owner.auth,
|
||||
json: { body: "reply", parentId: first.body.data.id },
|
||||
json: { body: "reply", parentId: first.data.id },
|
||||
});
|
||||
assertEquals(reply.body.data.parentId, first.body.data.id);
|
||||
const list = await get(`/api/dumps/${id}/comments`);
|
||||
assertEquals(list.body.data.map((c: { body: string }) => c.body), [
|
||||
assertEquals(reply.data.parentId, first.data.id);
|
||||
const list = await get<Comment[]>(`/api/dumps/${id}/comments`);
|
||||
assertEquals(list.data.map((c) => c.body), [
|
||||
"first",
|
||||
"reply",
|
||||
]);
|
||||
assertEquals((await get(`/api/dumps/${id}`)).body.data.commentCount, 2);
|
||||
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.commentCount, 2);
|
||||
});
|
||||
|
||||
test("comments: validation, auth and unknown dumps", async () => {
|
||||
@@ -103,7 +109,7 @@ test("comments: the dump's owner is notified, the commenter isn't", async () =>
|
||||
json: { body: "self" },
|
||||
});
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
|
||||
const c = await post(`/api/dumps/${id}/comments`, {
|
||||
const c = await post<Comment>(`/api/dumps/${id}/comments`, {
|
||||
auth: other.auth,
|
||||
json: { body: "nice" },
|
||||
});
|
||||
@@ -115,7 +121,7 @@ test("comments: the dump's owner is notified, the commenter isn't", async () =>
|
||||
};
|
||||
assertEquals(note.user_id, owner.id);
|
||||
assertEquals(note.type, "dump_commented");
|
||||
assertEquals(JSON.parse(note.data).commentId, c.body.data.id);
|
||||
assertEquals(JSON.parse(note.data).commentId, c.data.id);
|
||||
assertEquals(JSON.parse(note.data).dumpTitle, "Mine");
|
||||
});
|
||||
|
||||
@@ -133,13 +139,13 @@ test("PATCH /api/comments/:id: author and moderator edit, others can't", async (
|
||||
.status,
|
||||
401,
|
||||
);
|
||||
const edited = await patch(`/api/comments/${commentId}`, {
|
||||
const edited = await patch<Comment>(`/api/comments/${commentId}`, {
|
||||
auth: author.auth,
|
||||
json: { body: " edited " },
|
||||
});
|
||||
assertEquals(edited.status, 200);
|
||||
assertEquals(edited.body.data.body, "edited");
|
||||
assertExists(edited.body.data.updatedAt);
|
||||
assertEquals(edited.data.body, "edited");
|
||||
assertExists(edited.data.updatedAt);
|
||||
assertEquals(
|
||||
(await patch(`/api/comments/${commentId}`, {
|
||||
auth: mod.auth,
|
||||
@@ -182,10 +188,10 @@ test("DELETE /api/comments/:id soft-deletes, and a deleted comment can't be edit
|
||||
(await del(`/api/comments/${commentId}`, { auth: author.auth })).status,
|
||||
200,
|
||||
);
|
||||
const [c] = (await get(`/api/dumps/${dumpId}/comments`)).body.data;
|
||||
const [c] = (await get<Comment[]>(`/api/dumps/${dumpId}/comments`)).data;
|
||||
assertEquals(c.deleted, true);
|
||||
assertEquals(c.body, "");
|
||||
assertEquals((await get(`/api/dumps/${dumpId}`)).body.data.commentCount, 0);
|
||||
assertEquals((await get<Dump>(`/api/dumps/${dumpId}`)).data.commentCount, 0);
|
||||
assertEquals(
|
||||
(await patch(`/api/comments/${commentId}`, {
|
||||
auth: author.auth,
|
||||
@@ -218,9 +224,11 @@ test("comment likes: count, conflicts, likers list and notifications", async ()
|
||||
1,
|
||||
);
|
||||
|
||||
const likers = await get(`/api/comments/${commentId}/likers`);
|
||||
assertEquals(likers.body.data.total, 2);
|
||||
assertEquals(likers.body.data.items[0].email, undefined);
|
||||
const likers = await get<PaginatedData<PublicUser>>(
|
||||
`/api/comments/${commentId}/likers`,
|
||||
);
|
||||
assertEquals(likers.data.total, 2);
|
||||
assertFalse("email" in likers.data.items[0]);
|
||||
|
||||
assertEquals(removeCommentLike(commentId, liker.id).likeCount, 1);
|
||||
try {
|
||||
|
||||
@@ -17,10 +17,18 @@ import {
|
||||
test,
|
||||
TEST_DIR,
|
||||
} from "./helpers.ts";
|
||||
import type {
|
||||
Dump,
|
||||
DumpUrlMatch,
|
||||
PaginatedData,
|
||||
Playlist,
|
||||
PublicUser,
|
||||
} from "../model/interfaces.ts";
|
||||
import {
|
||||
assert,
|
||||
assertEquals,
|
||||
assertExists,
|
||||
assertFalse,
|
||||
assertMatch,
|
||||
assertNotEquals,
|
||||
} from "@std/assert";
|
||||
@@ -41,18 +49,18 @@ function readFile(path: string): Uint8Array | null {
|
||||
test("POST /api/dumps: a URL dump takes its title from the page", async () => {
|
||||
const user = await createUser();
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
const res = await post("/api/dumps", {
|
||||
const res = await post<Dump>("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://example.com/a?utm_source=x", comment: "look" },
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const dump = res.body.data;
|
||||
const dump = res.data;
|
||||
assertEquals(dump.kind, "url");
|
||||
assertEquals(dump.title, "Scraped title");
|
||||
assertMatch(dump.slug, /^scraped-title-[0-9a-f]{8}$/);
|
||||
assertMatch(dump.slug ?? "", /^scraped-title-[0-9a-f]{8}$/);
|
||||
assertEquals(dump.userId, user.id);
|
||||
assertEquals(dump.isPrivate, false);
|
||||
assertEquals(dump.richContent.thumbnailUrl, "https://example.com/cover.jpg");
|
||||
assertEquals(dump.richContent?.thumbnailUrl, "https://example.com/cover.jpg");
|
||||
const row = db.prepare(`SELECT url, url_canonical FROM dumps WHERE id = ?;`)
|
||||
.get(dump.id) as { url: string; url_canonical: string };
|
||||
// The exact URL is kept; the canonical key drops the tracking parameter.
|
||||
@@ -64,22 +72,22 @@ test("POST /api/dumps: a typed title wins; an unreachable page still posts", asy
|
||||
const user = await createUser();
|
||||
{
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
const res = await post("/api/dumps", {
|
||||
const res = await post<Dump>("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://example.com/b", title: " My title " },
|
||||
});
|
||||
assertEquals(res.body.data.title, "My title");
|
||||
assertEquals(res.data.title, "My title");
|
||||
}
|
||||
{
|
||||
using _ = stubNetwork(() => {
|
||||
throw new TypeError("offline");
|
||||
});
|
||||
const res = await post("/api/dumps", {
|
||||
const res = await post<Dump>("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://www.offline.example/c" },
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
assertEquals(res.body.data.title, "offline.example");
|
||||
assertEquals(res.data.title, "offline.example");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -108,14 +116,14 @@ test("POST /api/dumps: categories are stored and deduplicated", async () => {
|
||||
const user = await createUser();
|
||||
const music = insertCategory({ slug: "music" });
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
const res = await post("/api/dumps", {
|
||||
const res = await post<Dump>("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://example.com/d", categoryIds: [music, music] },
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
assertEquals(res.body.data.categoryIds, [music]);
|
||||
assertEquals(res.data.categoryIds, [music]);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${res.body.data.id}`)).body.data.categoryIds,
|
||||
(await get<Dump>(`/api/dumps/${res.data.id}`)).data.categoryIds,
|
||||
[music],
|
||||
);
|
||||
});
|
||||
@@ -133,12 +141,12 @@ test("POST /api/dumps: an unknown category is refused and nothing is left behind
|
||||
|
||||
test("POST /api/dumps: a file dump is stored and served back", async () => {
|
||||
const user = await createUser();
|
||||
const res = await post("/api/dumps", {
|
||||
const res = await post<Dump>("/api/dumps", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG, "pixel.png", "image/png", { comment: "tiny" }),
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const dump = res.body.data;
|
||||
const dump = res.data;
|
||||
assertEquals(dump.kind, "file");
|
||||
assertEquals(dump.title, "pixel.png");
|
||||
assertEquals(dump.fileMime, "image/png");
|
||||
@@ -202,7 +210,7 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones"
|
||||
json: { url: "https://example.com/private", isPrivate: true },
|
||||
});
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
|
||||
const pub = await post("/api/dumps", {
|
||||
const pub = await post<Dump>("/api/dumps", {
|
||||
auth: poster.auth,
|
||||
json: { url: "https://example.com/public" },
|
||||
});
|
||||
@@ -212,7 +220,7 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones"
|
||||
assertEquals(notes.length, 1);
|
||||
assertEquals(notes[0].user_id, follower.id);
|
||||
assertEquals(notes[0].type, "user_dump_posted");
|
||||
assertEquals(JSON.parse(notes[0].data).dumpId, pub.body.data.id);
|
||||
assertEquals(JSON.parse(notes[0].data).dumpId, pub.data.id);
|
||||
});
|
||||
|
||||
// ── Read ─────────────────────────────────────────────────────────────────────
|
||||
@@ -220,8 +228,8 @@ test("POST /api/dumps: followers are notified of public dumps, not private ones"
|
||||
test("GET /api/dumps/:id works by id and by slug", async () => {
|
||||
const user = await createUser();
|
||||
const { id, slug } = insertDump({ userId: user.id, title: "Hello" });
|
||||
assertEquals((await get(`/api/dumps/${id}`)).body.data.id, id);
|
||||
assertEquals((await get(`/api/dumps/${slug}`)).body.data.id, id);
|
||||
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.id, id);
|
||||
assertEquals((await get<Dump>(`/api/dumps/${slug}`)).data.id, id);
|
||||
assertEquals((await get(`/api/dumps/nope`)).status, 404);
|
||||
assertEquals((await get(`/api/dumps/${crypto.randomUUID()}`)).status, 404);
|
||||
});
|
||||
@@ -265,22 +273,25 @@ test("GET /api/dumps: newest first, paginated, private ones for the owner only",
|
||||
isPrivate: true,
|
||||
createdAt: new Date(Date.UTC(2024, 0, 10)).toISOString(),
|
||||
});
|
||||
const page1 = await get("/api/dumps?limit=2");
|
||||
assertEquals(page1.body.data.items.map((d: { title: string }) => d.title), [
|
||||
const page1 = await get<PaginatedData<Dump>>("/api/dumps?limit=2");
|
||||
assertEquals(page1.data.items.map((d) => d.title), [
|
||||
"d4",
|
||||
"d3",
|
||||
]);
|
||||
assertEquals(page1.body.data.total, 5);
|
||||
assertEquals(page1.body.data.hasMore, true);
|
||||
const page3 = await get("/api/dumps?limit=2&page=3");
|
||||
assertEquals(page3.body.data.items.length, 1);
|
||||
assertEquals(page3.body.data.hasMore, false);
|
||||
assertEquals(page1.data.total, 5);
|
||||
assertEquals(page1.data.hasMore, true);
|
||||
const page3 = await get<PaginatedData<Dump>>("/api/dumps?limit=2&page=3");
|
||||
assertEquals(page3.data.items.length, 1);
|
||||
assertEquals(page3.data.hasMore, false);
|
||||
|
||||
const mine = await get("/api/dumps?limit=1", { auth: owner.auth });
|
||||
assertEquals(mine.body.data.items[0].title, "secret");
|
||||
assertEquals(mine.body.data.total, 6);
|
||||
const mine = await get<PaginatedData<Dump>>("/api/dumps?limit=1", {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(mine.data.items[0].title, "secret");
|
||||
assertEquals(mine.data.total, 6);
|
||||
assertEquals(
|
||||
(await get("/api/dumps", { auth: other.auth })).body.data.total,
|
||||
(await get<PaginatedData<Dump>>("/api/dumps", { auth: other.auth })).data
|
||||
.total,
|
||||
5,
|
||||
);
|
||||
});
|
||||
@@ -300,19 +311,26 @@ test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", as
|
||||
isPrivate: true,
|
||||
});
|
||||
const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10");
|
||||
const anon = await get(`/api/dumps/by-url?url=${q}`);
|
||||
assertEquals(anon.body.data.map((d: { title: string }) => d.title), [
|
||||
const anon = await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`);
|
||||
assertEquals(anon.data.map((d) => d.title), [
|
||||
"public",
|
||||
]);
|
||||
assertEquals(anon.body.data[0].username, owner.username);
|
||||
const mine = await get(`/api/dumps/by-url?url=${q}`, { auth: owner.auth });
|
||||
assertEquals(mine.body.data.length, 2);
|
||||
assertEquals(anon.data[0].username, owner.username);
|
||||
const mine = await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(mine.data.length, 2);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/by-url?url=${q}`, { auth: other.auth })).body.data
|
||||
(await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, {
|
||||
auth: other.auth,
|
||||
})).data
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assertEquals((await get(`/api/dumps/by-url?url=garbage`)).body.data, []);
|
||||
assertEquals(
|
||||
(await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=garbage`)).data,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/dumps/:id/voters lists voters without private fields", async () => {
|
||||
@@ -322,11 +340,11 @@ test("GET /api/dumps/:id/voters lists voters without private fields", async () =
|
||||
db.prepare(
|
||||
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
|
||||
).run(id, voter.id, new Date().toISOString());
|
||||
const res = await get(`/api/dumps/${id}/voters`);
|
||||
assertEquals(res.body.data.total, 1);
|
||||
assertEquals(res.body.data.items[0].username, voter.username);
|
||||
assertEquals(res.body.data.items[0].email, undefined);
|
||||
assertEquals(res.body.data.items[0].passwordHash, undefined);
|
||||
const res = await get<PaginatedData<PublicUser>>(`/api/dumps/${id}/voters`);
|
||||
assertEquals(res.data.total, 1);
|
||||
assertEquals(res.data.items[0].username, voter.username);
|
||||
assertFalse("email" in res.data.items[0]);
|
||||
assertFalse("passwordHash" in res.data.items[0]);
|
||||
});
|
||||
|
||||
test("GET /api/dumps/:id/related: links in both directions, hiding private ones", async () => {
|
||||
@@ -345,7 +363,7 @@ test("GET /api/dumps/:id/related: links in both directions, hiding private ones"
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
// A new dump whose comment links the target by its URL and the secret dump by
|
||||
// its page — and itself, which is ignored.
|
||||
const src = await post("/api/dumps", {
|
||||
const src = await post<Dump>("/api/dumps", {
|
||||
auth: other.auth,
|
||||
json: {
|
||||
url: "https://example.com/src",
|
||||
@@ -353,21 +371,21 @@ test("GET /api/dumps/:id/related: links in both directions, hiding private ones"
|
||||
`see https://x.test/t and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`,
|
||||
},
|
||||
});
|
||||
const srcId = src.body.data.id;
|
||||
const fromSrc = await get(`/api/dumps/${srcId}/related`);
|
||||
assertEquals(fromSrc.body.data.map((d: { title: string }) => d.title), [
|
||||
const srcId = src.data.id;
|
||||
const fromSrc = await get<Dump[]>(`/api/dumps/${srcId}/related`);
|
||||
assertEquals(fromSrc.data.map((d) => d.title), [
|
||||
"target",
|
||||
]);
|
||||
const fromSrcAsOwner = await get(`/api/dumps/${srcId}/related`, {
|
||||
const fromSrcAsOwner = await get<Dump[]>(`/api/dumps/${srcId}/related`, {
|
||||
auth: other.auth,
|
||||
});
|
||||
assertEquals(fromSrcAsOwner.body.data.length, 2);
|
||||
const toTarget = await get(`/api/dumps/${target.id}/related`);
|
||||
assertEquals(toTarget.body.data.map((d: { id: string }) => d.id), [srcId]);
|
||||
assertEquals(fromSrcAsOwner.data.length, 2);
|
||||
const toTarget = await get<Dump[]>(`/api/dumps/${target.id}/related`);
|
||||
assertEquals(toTarget.data.map((d) => d.id), [srcId]);
|
||||
|
||||
// Editing the comment away removes the links.
|
||||
await put(`/api/dumps/${srcId}`, { auth: other.auth, json: { comment: "" } });
|
||||
assertEquals((await get(`/api/dumps/${target.id}/related`)).body.data, []);
|
||||
assertEquals((await get<Dump[]>(`/api/dumps/${target.id}/related`)).data, []);
|
||||
});
|
||||
|
||||
test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private ones", async () => {
|
||||
@@ -386,12 +404,14 @@ test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private
|
||||
).run(p.id, id, new Date().toISOString());
|
||||
}
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${id}/playlists`, { auth: other.auth })).body.data
|
||||
.map((p: { title: string }) => p.title),
|
||||
(await get<Playlist[]>(`/api/dumps/${id}/playlists`, { auth: other.auth }))
|
||||
.data
|
||||
.map((p) => p.title),
|
||||
["Public"],
|
||||
);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${id}/playlists`, { auth: user.auth })).body.data
|
||||
(await get<Playlist[]>(`/api/dumps/${id}/playlists`, { auth: user.auth }))
|
||||
.data
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
@@ -413,21 +433,21 @@ test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async (
|
||||
(await put(`/api/dumps/${id}`, { json: { title: "X" } })).status,
|
||||
401,
|
||||
);
|
||||
const res = await put(`/api/dumps/${slug}`, {
|
||||
const res = await put<Dump>(`/api/dumps/${slug}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "New title", comment: "hello" },
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.title, "New title");
|
||||
assertMatch(res.body.data.slug, /^new-title-/);
|
||||
assertNotEquals(res.body.data.slug, slug);
|
||||
assertExists(res.body.data.updatedAt);
|
||||
assertEquals(res.data.title, "New title");
|
||||
assertMatch(res.data.slug ?? "", /^new-title-/);
|
||||
assertNotEquals(res.data.slug, slug);
|
||||
assertExists(res.data.updatedAt);
|
||||
const byMod = await put(`/api/dumps/${id}`, {
|
||||
auth: mod.auth,
|
||||
json: { title: "Moderated" },
|
||||
});
|
||||
assertEquals(byMod.status, 200);
|
||||
assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Moderated");
|
||||
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.title, "Moderated");
|
||||
});
|
||||
|
||||
test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", async () => {
|
||||
@@ -453,11 +473,11 @@ test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", a
|
||||
// Same URL: no fetch, title kept.
|
||||
await put(`/api/dumps/${id}`, { auth: owner.auth, json: { comment: "c" } });
|
||||
assertEquals(net.calls.length, 0);
|
||||
const moved = await put(`/api/dumps/${id}`, {
|
||||
const moved = await put<Dump>(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { url: "https://b.test/?utm_medium=z" },
|
||||
});
|
||||
assertEquals(moved.body.data.title, "Scraped title");
|
||||
assertEquals(moved.data.title, "Scraped title");
|
||||
assertEquals(net.calls.length, 1);
|
||||
const row = db.prepare(`SELECT url_canonical FROM dumps WHERE id = ?;`).get(
|
||||
id,
|
||||
@@ -476,21 +496,21 @@ test("PUT /api/dumps/:id: categories are replaced, or kept when omitted", async
|
||||
auth: owner.auth,
|
||||
json: { categoryIds: [a] },
|
||||
});
|
||||
const kept = await put(`/api/dumps/${id}`, {
|
||||
const kept = await put<Dump>(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "t" },
|
||||
});
|
||||
assertEquals(kept.body.data.categoryIds, [a]);
|
||||
const replaced = await put(`/api/dumps/${id}`, {
|
||||
assertEquals(kept.data.categoryIds, [a]);
|
||||
const replaced = await put<Dump>(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { categoryIds: [b] },
|
||||
});
|
||||
assertEquals(replaced.body.data.categoryIds, [b]);
|
||||
const cleared = await put(`/api/dumps/${id}`, {
|
||||
assertEquals(replaced.data.categoryIds, [b]);
|
||||
const cleared = await put<Dump>(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { categoryIds: [] },
|
||||
});
|
||||
assertEquals(cleared.body.data.categoryIds, []);
|
||||
assertEquals(cleared.data.categoryIds, []);
|
||||
});
|
||||
|
||||
test("PUT /api/dumps/:id: an unknown category changes nothing", async () => {
|
||||
@@ -506,7 +526,7 @@ test("PUT /api/dumps/:id: an unknown category changes nothing", async () => {
|
||||
json: { title: "After", categoryIds: [a, "nope"] },
|
||||
});
|
||||
assertEquals(res.status, 400);
|
||||
const dump = (await get(`/api/dumps/${id}`)).body.data;
|
||||
const dump = (await get<Dump>(`/api/dumps/${id}`)).data;
|
||||
assertEquals(dump.title, "Before");
|
||||
assertEquals(dump.categoryIds, [a]);
|
||||
});
|
||||
@@ -528,7 +548,7 @@ test("PUT /api/dumps/:id/file: an unknown category keeps the old file", async ()
|
||||
});
|
||||
assertEquals(res.status, 400);
|
||||
assertEquals(readFile(`dumps/${id}`), original);
|
||||
assertEquals((await get(`/api/dumps/${id}`)).body.data.fileName, "a.txt");
|
||||
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.fileName, "a.txt");
|
||||
});
|
||||
|
||||
test("PUT /api/dumps/:id: going private hides the dump from everyone else", async () => {
|
||||
@@ -553,7 +573,7 @@ test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async (
|
||||
title: "file",
|
||||
fileName: "a.txt",
|
||||
});
|
||||
const res = await put(`/api/dumps/${id}`, {
|
||||
const res = await put<Dump>(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: {
|
||||
title: "Renamed",
|
||||
@@ -563,9 +583,9 @@ test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async (
|
||||
},
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.title, "Renamed");
|
||||
assertEquals(res.body.data.isPrivate, true);
|
||||
assertEquals(res.body.data.url, undefined);
|
||||
assertEquals(res.data.title, "Renamed");
|
||||
assertEquals(res.data.isPrivate, true);
|
||||
assertEquals(res.data.url, undefined);
|
||||
});
|
||||
|
||||
test("PUT /api/dumps/:id/file replaces a file dump's contents", async () => {
|
||||
@@ -583,13 +603,13 @@ test("PUT /api/dumps/:id/file replaces a file dump's contents", async () => {
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
const res = await put(`/api/dumps/${id}/file`, {
|
||||
const res = await put<Dump>(`/api/dumps/${id}/file`, {
|
||||
auth: owner.auth,
|
||||
form: form(),
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.fileName, "b.png");
|
||||
assertEquals(res.body.data.title, "Replaced");
|
||||
assertEquals(res.data.fileName, "b.png");
|
||||
assertEquals(res.data.title, "Replaced");
|
||||
assertEquals(readFile(`dumps/${id}`), PNG);
|
||||
|
||||
const urlDump = insertDump({ userId: owner.id });
|
||||
@@ -608,7 +628,7 @@ test("PUT /api/dumps/:id/file: visibility changes with the file, or stays", asyn
|
||||
const other = await createUser();
|
||||
const { id } = insertDump({ userId: owner.id, fileName: "a.txt" });
|
||||
const replace = (fields: Record<string, string>) =>
|
||||
put(`/api/dumps/${id}/file`, {
|
||||
put<Dump>(`/api/dumps/${id}/file`, {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(PNG, "b.png", "image/png", fields),
|
||||
});
|
||||
@@ -616,15 +636,15 @@ test("PUT /api/dumps/:id/file: visibility changes with the file, or stays", asyn
|
||||
(await get(`/api/dumps/${id}`, { auth: other.auth })).status === 200;
|
||||
|
||||
let res = await replace({ isPrivate: "true" });
|
||||
assertEquals(res.body.data.isPrivate, true);
|
||||
assertEquals(res.data.isPrivate, true);
|
||||
assertEquals(await visible(), false);
|
||||
assertEquals((await get(`/api/files/${id}`)).status, 404);
|
||||
// Left out, it stays private.
|
||||
res = await replace({});
|
||||
assertEquals(res.body.data.isPrivate, true);
|
||||
assertEquals(res.data.isPrivate, true);
|
||||
assertEquals(await visible(), false);
|
||||
res = await replace({ isPrivate: "false" });
|
||||
assertEquals(res.body.data.isPrivate, false);
|
||||
assertEquals(res.data.isPrivate, false);
|
||||
assertEquals(await visible(), true);
|
||||
// A refused replacement changes neither.
|
||||
res = await replace({ isPrivate: "true", categoryIds: '["nope"]' });
|
||||
@@ -654,19 +674,21 @@ test("custom thumbnails: set, served, removed", async () => {
|
||||
})).status,
|
||||
400,
|
||||
);
|
||||
const set = await put(`/api/dumps/${id}/thumbnail`, {
|
||||
const set = await put<Dump>(`/api/dumps/${id}/thumbnail`, {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals(set.status, 200);
|
||||
assertEquals(set.body.data.thumbnailMime, "image/png");
|
||||
assertEquals(set.data.thumbnailMime, "image/png");
|
||||
const thumb = await get(`/api/thumbnails/${id}`);
|
||||
assertEquals(thumb.status, 200);
|
||||
assertEquals(thumb.headers.get("content-type"), "image/png");
|
||||
assertEquals(thumb.bytes, PNG);
|
||||
|
||||
const removed = await del(`/api/dumps/${id}/thumbnail`, { auth: owner.auth });
|
||||
assertEquals(removed.body.data.thumbnailMime, undefined);
|
||||
const removed = await del<Dump>(`/api/dumps/${id}/thumbnail`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(removed.data.thumbnailMime, undefined);
|
||||
assertEquals(readFile(`thumbnails/${id}-custom`), null);
|
||||
// A URL dump without its own thumbnail has none to serve.
|
||||
assertEquals((await get(`/api/thumbnails/${id}`)).status, 404);
|
||||
@@ -688,19 +710,19 @@ test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on fai
|
||||
using _ = stubNetwork(() => {
|
||||
throw new TypeError("down");
|
||||
});
|
||||
const res = await post(`/api/dumps/${id}/refresh-metadata`, {
|
||||
const res = await post<Dump>(`/api/dumps/${id}/refresh-metadata`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(res.status, 502);
|
||||
assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Kept");
|
||||
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.title, "Kept");
|
||||
}
|
||||
{
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
const res = await post(`/api/dumps/${id}/refresh-metadata`, {
|
||||
const res = await post<Dump>(`/api/dumps/${id}/refresh-metadata`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.title, "Scraped title");
|
||||
assertEquals(res.data.title, "Scraped title");
|
||||
}
|
||||
const file = insertDump({ userId: owner.id, fileName: "f.txt" });
|
||||
assertEquals(
|
||||
@@ -760,14 +782,12 @@ test("GET /api/users/:username/dumps and /votes respect privacy", async () => {
|
||||
}
|
||||
for (const sub of ["dumps", "votes"]) {
|
||||
const path = `/api/users/${owner.username}/${sub}`;
|
||||
assertEquals((await get(path)).body.data.total, 1, sub);
|
||||
const total = async (opts?: { auth: { Authorization: string } }) =>
|
||||
(await get<PaginatedData<Dump>>(path, opts)).data.total;
|
||||
assertEquals(await total(), 1, sub);
|
||||
assertEquals(await total({ auth: other.auth }), 1, sub);
|
||||
assertEquals(
|
||||
(await get(path, { auth: other.auth })).body.data.total,
|
||||
1,
|
||||
sub,
|
||||
);
|
||||
assertEquals(
|
||||
(await get(path, { auth: owner.auth })).body.data.total,
|
||||
await total({ auth: owner.auth }),
|
||||
2,
|
||||
sub,
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { db } from "../model/db.ts";
|
||||
import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts";
|
||||
import { makeSlug } from "../lib/slugify.ts";
|
||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||
import type { Role } from "../model/interfaces.ts";
|
||||
import type { APIError, APIResponse, Role } from "../model/interfaces.ts";
|
||||
|
||||
export { app, db, TEST_DIR };
|
||||
|
||||
@@ -245,28 +245,35 @@ export function count(sql: string, ...params: (string | number)[]): number {
|
||||
|
||||
export const BASE_URL = "http://gerbeur.test";
|
||||
|
||||
export interface ApiResponse {
|
||||
export interface ApiResponse<T = unknown> {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
// deno-lint-ignore no-explicit-any
|
||||
body: any;
|
||||
/** The parsed body when it's JSON, null otherwise. */
|
||||
body: APIResponse<T> | null;
|
||||
bytes: Uint8Array;
|
||||
/** The body's `data`; throws if the request didn't succeed. */
|
||||
readonly data: T;
|
||||
/** The body's `error`; throws if the request didn't fail. */
|
||||
readonly error: APIError;
|
||||
}
|
||||
|
||||
type RequestOptions = {
|
||||
auth?: { Authorization: string };
|
||||
json?: unknown;
|
||||
form?: FormData;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a request through the full middleware stack in-process — no socket.
|
||||
* `json` sends a JSON body, `form` a multipart one.
|
||||
* `json` sends a JSON body, `form` a multipart one. `T` is what the endpoint
|
||||
* answers with in `data`.
|
||||
*/
|
||||
export async function request(
|
||||
export async function request<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
opts: {
|
||||
auth?: { Authorization: string };
|
||||
json?: unknown;
|
||||
form?: FormData;
|
||||
headers?: Record<string, string>;
|
||||
} = {},
|
||||
): Promise<ApiResponse> {
|
||||
opts: RequestOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
const headers = new Headers({ ...opts.auth, ...opts.headers });
|
||||
let body: BodyInit | undefined;
|
||||
if (opts.json !== undefined) {
|
||||
@@ -281,23 +288,41 @@ export async function request(
|
||||
if (!res) throw new Error(`No response for ${method} ${path}`);
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
let parsed: unknown = null;
|
||||
let parsed: APIResponse<T> | null = null;
|
||||
if ((res.headers.get("content-type") ?? "").includes("json") && text) {
|
||||
parsed = JSON.parse(text);
|
||||
}
|
||||
return { status: res.status, headers: res.headers, body: parsed, bytes };
|
||||
const what = `${method} ${path} (${res.status})`;
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
body: parsed,
|
||||
bytes,
|
||||
get data() {
|
||||
if (!parsed?.success) {
|
||||
throw new Error(`${what} failed: ${parsed?.error.message ?? text}`);
|
||||
}
|
||||
return parsed.data;
|
||||
},
|
||||
get error() {
|
||||
if (parsed?.success !== false) {
|
||||
throw new Error(`${what} didn't fail`);
|
||||
}
|
||||
return parsed.error;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const get = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("GET", path, opts);
|
||||
export const post = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("POST", path, opts);
|
||||
export const put = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("PUT", path, opts);
|
||||
export const patch = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("PATCH", path, opts);
|
||||
export const del = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("DELETE", path, opts);
|
||||
export const get = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||||
request<T>("GET", path, opts);
|
||||
export const post = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||||
request<T>("POST", path, opts);
|
||||
export const put = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||||
request<T>("PUT", path, opts);
|
||||
export const patch = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||||
request<T>("PATCH", path, opts);
|
||||
export const del = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||||
request<T>("DELETE", path, opts);
|
||||
|
||||
// ── Files ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
// their parsers and their routes. All network access is stubbed.
|
||||
import { get, html, stubNetwork } from "./helpers.ts";
|
||||
import { assert, assertEquals, assertFalse } from "@std/assert";
|
||||
import { parseTralbum } from "../services/bandcamp-tralbum.ts";
|
||||
import { parsePlaylistPage } from "../services/youtube-playlist-service.ts";
|
||||
import { parseTralbum, type Tralbum } from "../services/bandcamp-tralbum.ts";
|
||||
import {
|
||||
parsePlaylistPage,
|
||||
type YouTubePlaylist,
|
||||
} from "../services/youtube-playlist-service.ts";
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replaceAll("&", "&").replaceAll('"', """);
|
||||
@@ -147,10 +150,12 @@ Deno.test("parsePlaylistPage: current and legacy rows", () => {
|
||||
Deno.test("GET /api/bandcamp/tracks: resolves, caches, and forces a refetch", async () => {
|
||||
using net = stubNetwork(() => html(tralbumPage(ALBUM)));
|
||||
const url = "https://cached.bandcamp.com/album/x";
|
||||
const res = await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`);
|
||||
const res = await get<Tralbum>(
|
||||
`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`,
|
||||
);
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.headers.get("cache-control"), "private, max-age=3600");
|
||||
assertEquals(res.body.data.tracks.length, 4);
|
||||
assertEquals(res.data.tracks.length, 4);
|
||||
// Same page (different query/case/trailing slash) → cache hit.
|
||||
await get(
|
||||
`/api/bandcamp/tracks?url=${
|
||||
@@ -188,9 +193,9 @@ Deno.test("GET /api/youtube/playlist: resolves and validates the id", async () =
|
||||
? new Response("", { status: 404 })
|
||||
: html(ytPage(YT_DATA));
|
||||
});
|
||||
const ok = await get("/api/youtube/playlist?list=PLgood");
|
||||
const ok = await get<YouTubePlaylist>("/api/youtube/playlist?list=PLgood");
|
||||
assertEquals(ok.status, 200);
|
||||
assertEquals(ok.body.data.tracks.length, 2);
|
||||
assertEquals(ok.data.tracks.length, 2);
|
||||
assertEquals(ok.headers.get("cache-control"), "public, max-age=3600");
|
||||
// The pre-answered consent cookie keeps EU requests off the consent page.
|
||||
assertEquals(cookie, "SOCS=CAI");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type ApiResponse,
|
||||
count,
|
||||
createUser,
|
||||
db,
|
||||
@@ -13,29 +14,35 @@ import {
|
||||
test,
|
||||
TEST_DIR,
|
||||
} from "./helpers.ts";
|
||||
import type {
|
||||
PaginatedData,
|
||||
Playlist,
|
||||
PlaylistMembership,
|
||||
PlaylistWithDumps,
|
||||
} from "../model/interfaces.ts";
|
||||
import { assert, assertEquals, assertMatch } from "@std/assert";
|
||||
|
||||
function ids(res: { body: { data: { dumps: { id: string }[] } } }): string[] {
|
||||
return res.body.data.dumps.map((d) => d.id);
|
||||
function ids(res: ApiResponse<PlaylistWithDumps>): string[] {
|
||||
return res.data.dumps.map((d) => d.id);
|
||||
}
|
||||
|
||||
test("POST /api/playlists creates; GET finds it by id or slug", async () => {
|
||||
const user = await createUser();
|
||||
const res = await post("/api/playlists", {
|
||||
const res = await post<Playlist>("/api/playlists", {
|
||||
auth: user.auth,
|
||||
json: { title: "Road Trip", description: "songs", isPublic: true },
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const { id, slug } = res.body.data;
|
||||
assertMatch(slug, /^road-trip-/);
|
||||
const { id, slug } = res.data;
|
||||
assertMatch(slug ?? "", /^road-trip-/);
|
||||
assertEquals(
|
||||
(await get(`/api/playlists/${id}`)).body.data.title,
|
||||
(await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.title,
|
||||
"Road Trip",
|
||||
);
|
||||
const bySlug = await get(`/api/playlists/${slug}`);
|
||||
assertEquals(bySlug.body.data.id, id);
|
||||
assertEquals(bySlug.body.data.ownerUsername, user.username);
|
||||
assertEquals(bySlug.body.data.dumps, []);
|
||||
const bySlug = await get<PlaylistWithDumps>(`/api/playlists/${slug}`);
|
||||
assertEquals(bySlug.data.id, id);
|
||||
assertEquals(bySlug.data.ownerUsername, user.username);
|
||||
assertEquals(bySlug.data.dumps, []);
|
||||
assertEquals(
|
||||
(await post("/api/playlists", { auth: user.auth, json: { title: "" } }))
|
||||
.status,
|
||||
@@ -63,8 +70,11 @@ test("private playlists are the owner's alone", async () => {
|
||||
);
|
||||
insertPlaylist({ userId: owner.id });
|
||||
const path = `/api/users/${owner.username}/playlists`;
|
||||
assertEquals((await get(path)).body.data.total, 1);
|
||||
assertEquals((await get(path, { auth: owner.auth })).body.data.total, 2);
|
||||
assertEquals((await get<PaginatedData<Playlist>>(path)).data.total, 1);
|
||||
assertEquals(
|
||||
(await get<PaginatedData<Playlist>>(path, { auth: owner.auth })).data.total,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("adding dumps: newest on top, no duplicates, only dumps you can see", async () => {
|
||||
@@ -109,7 +119,10 @@ test("adding dumps: newest on top, no duplicates, only dumps you can see", async
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
assertEquals((await get(`/api/playlists/${id}`)).body.data.dumpCount, 2);
|
||||
assertEquals(
|
||||
(await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.dumpCount,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("a public playlist hides its private dumps from everyone but their owner", async () => {
|
||||
@@ -201,19 +214,19 @@ test("removing a dump; updating; deleting", async () => {
|
||||
})).status,
|
||||
403,
|
||||
);
|
||||
const updated = await put(`/api/playlists/${id}`, {
|
||||
const updated = await put<Playlist>(`/api/playlists/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "New", description: "d", isPublic: false },
|
||||
});
|
||||
assertEquals(updated.body.data.title, "New");
|
||||
assertMatch(updated.body.data.slug, /^new-/);
|
||||
assertEquals(updated.body.data.isPublic, false);
|
||||
const cleared = await put(`/api/playlists/${id}`, {
|
||||
assertEquals(updated.data.title, "New");
|
||||
assertMatch(updated.data.slug ?? "", /^new-/);
|
||||
assertEquals(updated.data.isPublic, false);
|
||||
const cleared = await put<Playlist>(`/api/playlists/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { description: null },
|
||||
});
|
||||
assertEquals(cleared.body.data.description, undefined);
|
||||
assertEquals(cleared.body.data.title, "New");
|
||||
assertEquals(cleared.data.description, undefined);
|
||||
assertEquals(cleared.data.title, "New");
|
||||
|
||||
assertEquals(
|
||||
(await del(`/api/playlists/${id}`, { auth: other.auth })).status,
|
||||
@@ -238,11 +251,14 @@ test("memberships: which of my playlists hold this dump", async () => {
|
||||
insertPlaylist({ userId: other.id, title: "theirs" });
|
||||
const d = insertDump({ userId: owner.id });
|
||||
await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth });
|
||||
const res = await get(`/api/playlists/by-dump/${d.id}/memberships`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
const res = await get<PlaylistMembership[]>(
|
||||
`/api/playlists/by-dump/${d.id}/memberships`,
|
||||
{
|
||||
auth: owner.auth,
|
||||
},
|
||||
);
|
||||
const byTitle = Object.fromEntries(
|
||||
res.body.data.map((
|
||||
res.data.map((
|
||||
m: { playlist: { title: string }; hasDump: boolean },
|
||||
) => [
|
||||
m.playlist.title,
|
||||
@@ -292,14 +308,14 @@ test("playlist images: upload and serve", async () => {
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
const res = await post(`/api/playlists/${slug}/image`, {
|
||||
const res = await post<Playlist>(`/api/playlists/${slug}/image`, {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.imageMime, "image/png");
|
||||
assertEquals(res.data.imageMime, "image/png");
|
||||
// A new cover counts as an edit, so image URLs versioned on it change.
|
||||
assert(res.body.data.updatedAt);
|
||||
assert(res.data.updatedAt);
|
||||
const served = await get(`/api/playlists/${slug}/image`);
|
||||
assertEquals(served.status, 200);
|
||||
assertEquals(served.bytes, PNG);
|
||||
@@ -356,17 +372,17 @@ test("playlist images: a failed write leaves the row and the old cover alone", a
|
||||
test("playlist images: a replaced cover is served under the new version", async () => {
|
||||
const owner = await createUser();
|
||||
const { id } = insertPlaylist({ userId: owner.id });
|
||||
const first = await post(`/api/playlists/${id}/image`, {
|
||||
const first = await post<Playlist>(`/api/playlists/${id}/image`, {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 5)); // a distinct updated_at
|
||||
const other = new Uint8Array([...PNG, 1, 2, 3]);
|
||||
const second = await post(`/api/playlists/${id}/image`, {
|
||||
const second = await post<Playlist>(`/api/playlists/${id}/image`, {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(other),
|
||||
});
|
||||
assert(second.body.data.updatedAt !== first.body.data.updatedAt);
|
||||
assert(second.data.updatedAt !== first.data.updatedAt);
|
||||
assertEquals((await get(`/api/playlists/${id}/image`)).bytes, other);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Follows, feeds, notifications and invites.
|
||||
import {
|
||||
type ApiResponse,
|
||||
count,
|
||||
createUser,
|
||||
db,
|
||||
@@ -12,10 +13,20 @@ import {
|
||||
post,
|
||||
test,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals, assertMatch } from "@std/assert";
|
||||
import type {
|
||||
Comment,
|
||||
Dump,
|
||||
FollowStatus,
|
||||
InviteResponse,
|
||||
Notification,
|
||||
PaginatedData,
|
||||
Playlist,
|
||||
PublicUser,
|
||||
} from "../model/interfaces.ts";
|
||||
import { assertEquals, assertFalse, assertMatch } from "@std/assert";
|
||||
|
||||
function titles(res: { body: { data: { items: { title: string }[] } } }) {
|
||||
return res.body.data.items.map((d) => d.title);
|
||||
function titles(res: ApiResponse<PaginatedData<Dump>>): string[] {
|
||||
return res.data.items.map((d) => d.title);
|
||||
}
|
||||
|
||||
test("following users: status, idempotence, no self-follow, one notification", async () => {
|
||||
@@ -34,13 +45,13 @@ test("following users: status, idempotence, no self-follow, one notification", a
|
||||
400,
|
||||
);
|
||||
assertEquals(
|
||||
(await get("/api/follows/status", { auth: me.auth })).body.data,
|
||||
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data,
|
||||
{ followedUserIds: [them.id], followedPlaylistIds: [] },
|
||||
);
|
||||
// Unfollow and follow again: still just the one notification.
|
||||
await del(`/api/follows/users/${them.id}`, { auth: me.auth });
|
||||
assertEquals(
|
||||
(await get("/api/follows/status", { auth: me.auth })).body.data
|
||||
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data
|
||||
.followedUserIds,
|
||||
[],
|
||||
);
|
||||
@@ -52,11 +63,13 @@ test("following users: status, idempotence, no self-follow, one notification", a
|
||||
),
|
||||
1,
|
||||
);
|
||||
const list = await get(`/api/users/${me.username}/followed-users`);
|
||||
assertEquals(list.body.data.items.map((u: { id: string }) => u.id), [
|
||||
const list = await get<PaginatedData<PublicUser>>(
|
||||
`/api/users/${me.username}/followed-users`,
|
||||
);
|
||||
assertEquals(list.data.items.map((u) => u.id), [
|
||||
them.id,
|
||||
]);
|
||||
assertEquals(list.body.data.items[0].email, undefined);
|
||||
assertFalse("email" in list.data.items[0]);
|
||||
});
|
||||
|
||||
test("following playlists: public ones only, and not your own notification", async () => {
|
||||
@@ -86,13 +99,15 @@ test("following playlists: public ones only, and not your own notification", asy
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
(await get(`/api/users/${me.username}/followed-playlists`)).body.data
|
||||
(await get<PaginatedData<Playlist>>(
|
||||
`/api/users/${me.username}/followed-playlists`,
|
||||
)).data
|
||||
.items[0].id,
|
||||
pub.id,
|
||||
);
|
||||
await del(`/api/follows/playlists/${pub.id}`, { auth: me.auth });
|
||||
assertEquals(
|
||||
(await get("/api/follows/status", { auth: me.auth })).body.data
|
||||
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data
|
||||
.followedPlaylistIds,
|
||||
[],
|
||||
);
|
||||
@@ -121,9 +136,11 @@ test("followed-users feed: public dumps of the people I follow, newest first", a
|
||||
insertDump({ userId: stranger.id, title: "stranger" });
|
||||
await post(`/api/follows/users/${followed.id}`, { auth: me.auth });
|
||||
|
||||
const feed = await get("/api/follows/feed/users", { auth: me.auth });
|
||||
const feed = await get<PaginatedData<Dump>>("/api/follows/feed/users", {
|
||||
auth: me.auth,
|
||||
});
|
||||
assertEquals(titles(feed), ["new", "old"]);
|
||||
assertEquals(feed.body.data.items[0].categoryIds, [music]);
|
||||
assertEquals(feed.data.items[0].categoryIds, [music]);
|
||||
assertEquals(
|
||||
titles(
|
||||
await get("/api/follows/feed/users?category=music", { auth: me.auth }),
|
||||
@@ -132,8 +149,9 @@ test("followed-users feed: public dumps of the people I follow, newest first", a
|
||||
);
|
||||
// An unknown category filters everything out rather than nothing.
|
||||
assertEquals(
|
||||
(await get("/api/follows/feed/users?category=nope", { auth: me.auth })).body
|
||||
.data,
|
||||
(await get<PaginatedData<Dump>>("/api/follows/feed/users?category=nope", {
|
||||
auth: me.auth,
|
||||
})).data,
|
||||
{ items: [], total: 0, hasMore: false },
|
||||
);
|
||||
assertEquals((await get("/api/follows/feed/users")).status, 401);
|
||||
@@ -155,14 +173,18 @@ test("followed-playlists feed: each dump once, public playlists only", async ()
|
||||
await post(`/api/playlists/${p.id}/dumps/${d.id}`, { auth: owner.auth });
|
||||
}
|
||||
await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth });
|
||||
const feed = await get("/api/follows/feed/playlists", { auth: me.auth });
|
||||
const feed = await get<PaginatedData<Dump>>("/api/follows/feed/playlists", {
|
||||
auth: me.auth,
|
||||
});
|
||||
assertEquals(titles(feed), ["shared"]);
|
||||
assertEquals(feed.body.data.total, 1);
|
||||
assertEquals(feed.data.total, 1);
|
||||
|
||||
// A playlist gone private drops out of the feed.
|
||||
db.prepare(`UPDATE playlists SET is_public = 0;`).run();
|
||||
assertEquals(
|
||||
(await get("/api/follows/feed/playlists", { auth: me.auth })).body.data
|
||||
(await get<PaginatedData<Dump>>("/api/follows/feed/playlists", {
|
||||
auth: me.auth,
|
||||
})).data
|
||||
.total,
|
||||
0,
|
||||
);
|
||||
@@ -180,36 +202,42 @@ test("notifications: listed newest first, marked read one or all, never someone
|
||||
});
|
||||
await post(`/api/follows/users/${other.id}`, { auth: me.auth });
|
||||
|
||||
const list = await get("/api/notifications", { auth: me.auth });
|
||||
assertEquals(list.body.data.total, 2);
|
||||
const list = await get<PaginatedData<Notification>>("/api/notifications", {
|
||||
auth: me.auth,
|
||||
});
|
||||
assertEquals(list.data.total, 2);
|
||||
assertEquals(
|
||||
list.body.data.items.map((n: { type: string }) => n.type),
|
||||
list.data.items.map((n) => n.type),
|
||||
["dump_commented", "user_followed"],
|
||||
);
|
||||
assertEquals(
|
||||
list.body.data.items.every((n: { read: boolean }) => !n.read),
|
||||
list.data.items.every((n) => !n.read),
|
||||
true,
|
||||
);
|
||||
|
||||
const theirs =
|
||||
(await get("/api/notifications", { auth: other.auth })).body.data
|
||||
.items[0].id;
|
||||
const theirs = (await get<PaginatedData<Notification>>("/api/notifications", {
|
||||
auth: other.auth,
|
||||
})).data
|
||||
.items[0].id;
|
||||
assertEquals(
|
||||
(await patch(`/api/notifications/${theirs}/read`, { auth: me.auth }))
|
||||
.status,
|
||||
204,
|
||||
);
|
||||
assertEquals(
|
||||
(await get("/api/notifications", { auth: other.auth })).body.data.items[0]
|
||||
(await get<PaginatedData<Notification>>("/api/notifications", {
|
||||
auth: other.auth,
|
||||
})).data.items[0]
|
||||
.read,
|
||||
false,
|
||||
);
|
||||
|
||||
const first = list.body.data.items[0].id;
|
||||
const first = list.data.items[0].id;
|
||||
await patch(`/api/notifications/${first}/read`, { auth: me.auth });
|
||||
const after =
|
||||
(await get("/api/notifications", { auth: me.auth })).body.data.items;
|
||||
assertEquals(after.map((n: { read: boolean }) => n.read), [true, false]);
|
||||
const after = (await get<PaginatedData<Notification>>("/api/notifications", {
|
||||
auth: me.auth,
|
||||
})).data.items;
|
||||
assertEquals(after.map((n) => n.read), [true, false]);
|
||||
|
||||
assertEquals(
|
||||
(await post("/api/notifications/read-all", { auth: me.auth })).status,
|
||||
@@ -232,7 +260,7 @@ test("mentions: notify the named, once per context, never the author or an email
|
||||
const body = `hi @${named.username.toUpperCase()} and @${named.username}, ` +
|
||||
`me @${author.username}, mail x@${named.username}.com, ` +
|
||||
`[@${named.username}](link) @nobody_known`;
|
||||
const c = await post(`/api/dumps/${dumpId}/comments`, {
|
||||
const c = await post<Comment>(`/api/dumps/${dumpId}/comments`, {
|
||||
auth: author.auth,
|
||||
json: { body },
|
||||
});
|
||||
@@ -245,12 +273,12 @@ test("mentions: notify the named, once per context, never the author or an email
|
||||
mentionerId: author.id,
|
||||
mentionerUsername: author.username,
|
||||
contextType: "comment",
|
||||
contextId: c.body.data.id,
|
||||
contextId: c.data.id,
|
||||
contextTitle: "Topic",
|
||||
dumpId,
|
||||
});
|
||||
// Editing the comment doesn't notify a second time.
|
||||
await patch(`/api/comments/${c.body.data.id}`, {
|
||||
await patch(`/api/comments/${c.data.id}`, {
|
||||
auth: author.auth,
|
||||
json: { body: `again @${named.username}` },
|
||||
});
|
||||
@@ -265,9 +293,9 @@ test("mentions: notify the named, once per context, never the author or an email
|
||||
test("invites: anyone signed in can make one; it validates until used or stale", async () => {
|
||||
const user = await createUser();
|
||||
assertEquals((await post("/api/invites")).status, 401);
|
||||
const res = await post("/api/invites", { auth: user.auth });
|
||||
const res = await post<InviteResponse>("/api/invites", { auth: user.auth });
|
||||
assertEquals(res.status, 201);
|
||||
const token = res.body.data.token;
|
||||
const token = res.data.token;
|
||||
assertMatch(token, /^[A-Za-z0-9_-]{22}$/);
|
||||
assertEquals((await get(`/api/invites/${token}`)).status, 200);
|
||||
assertEquals((await get(`/api/invites/unknown`)).status, 404);
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
test,
|
||||
TEST_DIR,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals, assertStringIncludes } from "@std/assert";
|
||||
import type {
|
||||
AttachmentResponse,
|
||||
Comment,
|
||||
SelfUser,
|
||||
} from "../model/interfaces.ts";
|
||||
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
|
||||
|
||||
const BYTES = new TextEncoder().encode("0123456789");
|
||||
|
||||
@@ -214,12 +219,12 @@ test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async
|
||||
|
||||
test("attachments: upload an image, serve it back", async () => {
|
||||
const user = await createUser();
|
||||
const res = await post("/api/attachments", {
|
||||
const res = await post<AttachmentResponse>("/api/attachments", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const id = res.body.data.id;
|
||||
const id = res.data.id;
|
||||
const served = await get(`/api/attachments/${id}`);
|
||||
assertEquals(served.status, 200);
|
||||
assertEquals(served.bytes, PNG);
|
||||
@@ -265,13 +270,13 @@ test("attachments: refuses non-images, non-multipart and anonymous uploads", asy
|
||||
test("attachments are claimed by the first text that links them", async () => {
|
||||
const user = await createUser();
|
||||
const { id: dumpId } = insertDump({ userId: user.id });
|
||||
const up = await post("/api/attachments", {
|
||||
const up = await post<AttachmentResponse>("/api/attachments", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
const attachmentId = up.body.data.id;
|
||||
const attachmentId = up.data.id;
|
||||
const link = ``;
|
||||
const comment = await post(`/api/dumps/${dumpId}/comments`, {
|
||||
const comment = await post<Comment>(`/api/dumps/${dumpId}/comments`, {
|
||||
auth: user.auth,
|
||||
json: { body: link },
|
||||
});
|
||||
@@ -279,26 +284,26 @@ test("attachments are claimed by the first text that links them", async () => {
|
||||
(db.prepare(`SELECT resource_id FROM attachments WHERE id = ?;`).get(
|
||||
attachmentId,
|
||||
) as { resource_id: string | null }).resource_id;
|
||||
assertEquals(owner(), comment.body.data.id);
|
||||
assertEquals(owner(), comment.data.id);
|
||||
// A second text can't take it over.
|
||||
await post(`/api/dumps/${dumpId}/comments`, {
|
||||
auth: user.auth,
|
||||
json: { body: link },
|
||||
});
|
||||
assertEquals(owner(), comment.body.data.id);
|
||||
assertEquals(owner(), comment.data.id);
|
||||
});
|
||||
|
||||
test("avatars: upload, serve, and 404 when there is none", async () => {
|
||||
const user = await createUser();
|
||||
assertEquals((await get(`/api/avatars/${user.id}`)).status, 404);
|
||||
assertEquals((await get(`/api/avatars/${crypto.randomUUID()}`)).status, 404);
|
||||
const res = await post("/api/avatars/me", {
|
||||
const res = await post<SelfUser>("/api/avatars/me", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.avatarMime, "image/png");
|
||||
assertEquals(res.body.data.passwordHash, undefined);
|
||||
assertEquals(res.data.avatarMime, "image/png");
|
||||
assertFalse("passwordHash" in res.data);
|
||||
const served = await get(`/api/avatars/${user.id}`);
|
||||
assertEquals(served.status, 200);
|
||||
assertEquals(served.bytes, PNG);
|
||||
|
||||
@@ -7,37 +7,43 @@ import {
|
||||
patch,
|
||||
test,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals } from "@std/assert";
|
||||
import type {
|
||||
Invitee,
|
||||
PublicUser,
|
||||
SelfUser,
|
||||
UserSuggestion,
|
||||
} from "../model/interfaces.ts";
|
||||
import { assertEquals, assertFalse } from "@std/assert";
|
||||
|
||||
test("public profile: by username or id, without private fields", async () => {
|
||||
const user = await createUser();
|
||||
for (
|
||||
const path of [`/api/users/${user.username}`, `/api/users/by-id/${user.id}`]
|
||||
) {
|
||||
const res = await get(path);
|
||||
const res = await get<PublicUser>(path);
|
||||
assertEquals(res.status, 200, path);
|
||||
assertEquals(res.body.data.username, user.username);
|
||||
assertEquals(res.body.data.email, undefined);
|
||||
assertEquals(res.body.data.passwordHash, undefined);
|
||||
assertEquals(res.data.username, user.username);
|
||||
assertFalse("email" in res.data);
|
||||
assertFalse("passwordHash" in res.data);
|
||||
}
|
||||
assertEquals((await get(`/api/users/no_one_here`)).status, 404);
|
||||
const me = await get("/api/users/me", { auth: user.auth });
|
||||
assertEquals(me.body.data.email, user.email);
|
||||
assertEquals(me.body.data.passwordHash, undefined);
|
||||
const me = await get<SelfUser>("/api/users/me", { auth: user.auth });
|
||||
assertEquals(me.data.email, user.email);
|
||||
assertFalse("passwordHash" in me.data);
|
||||
});
|
||||
|
||||
test("PATCH /api/users/me: description and email only", async () => {
|
||||
const user = await createUser();
|
||||
const taken = await createUser();
|
||||
const res = await patch("/api/users/me", {
|
||||
const res = await patch<PublicUser>("/api/users/me", {
|
||||
auth: user.auth,
|
||||
json: { description: "about me", email: "new@example.test" },
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.description, "about me");
|
||||
assertEquals(res.body.data.email, undefined);
|
||||
assertEquals(res.data.description, "about me");
|
||||
assertFalse("email" in res.data);
|
||||
assertEquals(
|
||||
(await get("/api/users/me", { auth: user.auth })).body.data.email,
|
||||
(await get<SelfUser>("/api/users/me", { auth: user.auth })).data.email,
|
||||
"new@example.test",
|
||||
);
|
||||
// A role, username or password in the body is ignored, not applied.
|
||||
@@ -102,13 +108,13 @@ test("PATCH /api/users/:id/role: admins only, never their own", async () => {
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
const res = await patch(`/api/users/${user.id}/role`, {
|
||||
const res = await patch<PublicUser>(`/api/users/${user.id}/role`, {
|
||||
auth: admin.auth,
|
||||
json: { role: "moderator" },
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.role, "moderator");
|
||||
assertEquals(res.body.data.email, undefined);
|
||||
assertEquals(res.data.role, "moderator");
|
||||
assertFalse("email" in res.data);
|
||||
// The promoted user's existing token now carries moderator powers.
|
||||
const { id: dumpId } = insertDump({ userId: admin.id });
|
||||
assertEquals(
|
||||
@@ -127,14 +133,14 @@ test("PATCH /api/users/:id/role: admins only, never their own", async () => {
|
||||
test("GET /api/users/search: prefix match for mentions, capped at 8", async () => {
|
||||
for (let i = 0; i < 10; i++) await createUser({ username: `zed_${i}` });
|
||||
await createUser({ username: "other_zed" });
|
||||
const res = await get("/api/users/search?q=zed");
|
||||
assertEquals(res.body.data.length, 8);
|
||||
assertEquals(res.body.data[0], {
|
||||
id: res.body.data[0].id,
|
||||
const res = await get<UserSuggestion[]>("/api/users/search?q=zed");
|
||||
assertEquals(res.data.length, 8);
|
||||
assertEquals(res.data[0], {
|
||||
id: res.data[0].id,
|
||||
username: "zed_0",
|
||||
avatarMime: null,
|
||||
});
|
||||
assertEquals((await get("/api/users/search?q=")).body.data, []);
|
||||
assertEquals((await get<UserSuggestion[]>("/api/users/search?q=")).data, []);
|
||||
});
|
||||
|
||||
test("GET /api/users/:username/invitees: the invite tree below a user", async () => {
|
||||
@@ -147,13 +153,14 @@ test("GET /api/users/:username/invitees: the invite tree below a user", async ()
|
||||
invitedBy: child.id,
|
||||
createdAt: "2024-01-02T00:00:00Z",
|
||||
});
|
||||
const tree = (await get(`/api/users/${root.username}/invitees`)).body.data;
|
||||
const tree =
|
||||
(await get<Invitee[]>(`/api/users/${root.username}/invitees`)).data;
|
||||
assertEquals(
|
||||
tree.map((
|
||||
n: { username: string; invitedById: string },
|
||||
) => [n.username, n.invitedById]),
|
||||
[[child.username, root.id], [grandchild.username, child.id]],
|
||||
);
|
||||
const profile = await get(`/api/users/${child.username}`);
|
||||
assertEquals(profile.body.data.invitedByUsername, root.username);
|
||||
const profile = await get<PublicUser>(`/api/users/${child.username}`);
|
||||
assertEquals(profile.data.invitedByUsername, root.username);
|
||||
});
|
||||
|
||||
@@ -18,17 +18,32 @@ import {
|
||||
type TestUser,
|
||||
} from "./helpers.ts";
|
||||
import { assert, assertEquals, assertExists, assertFalse } from "@std/assert";
|
||||
import type {
|
||||
AuthTokens,
|
||||
Dump,
|
||||
ServerToClientMessage,
|
||||
} from "../model/interfaces.ts";
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
type Msg = Record<string, any>;
|
||||
// deno.json's "dom" lib types WebSocket's second argument as protocols only,
|
||||
// hiding Deno's own overload, which takes handshake headers.
|
||||
const DenoWebSocket = WebSocket as unknown as new (
|
||||
url: string,
|
||||
options: { headers: HeadersInit },
|
||||
) => WebSocket;
|
||||
|
||||
type Msg = ServerToClientMessage;
|
||||
type MsgOf<T extends Msg["type"]> = Extract<Msg, { type: T }>;
|
||||
|
||||
interface Client {
|
||||
ws: WebSocket;
|
||||
messages: Msg[];
|
||||
/** Resolves with the next unread message of `type` (and matching `where`). */
|
||||
next(type: string, where?: (m: Msg) => boolean): Promise<Msg>;
|
||||
next<T extends Msg["type"]>(
|
||||
type: T,
|
||||
where?: (m: MsgOf<T>) => boolean,
|
||||
): Promise<MsgOf<T>>;
|
||||
/** Every message of `type` received so far. */
|
||||
all(type: string): Msg[];
|
||||
all<T extends Msg["type"]>(type: T): MsgOf<T>[];
|
||||
send(data: unknown): void;
|
||||
/** Round-trips a ping, so anything the server sent before it has arrived. */
|
||||
sync(): Promise<void>;
|
||||
@@ -53,9 +68,9 @@ async function withServer(
|
||||
|
||||
const connect = (user?: TestUser, origin = "http://allowed.test") => {
|
||||
const query = user ? `?token=${user.token}` : "";
|
||||
const ws = new WebSocket(
|
||||
const ws = new DenoWebSocket(
|
||||
`ws://127.0.0.1:${port}/ws${query}`,
|
||||
{ headers: { origin } } as unknown as string[],
|
||||
{ headers: { origin } },
|
||||
);
|
||||
const messages: Msg[] = [];
|
||||
const read = new Set<Msg>();
|
||||
@@ -71,11 +86,14 @@ async function withServer(
|
||||
ws,
|
||||
messages,
|
||||
closed,
|
||||
async next(type, where = () => true) {
|
||||
async next<T extends Msg["type"]>(
|
||||
type: T,
|
||||
where: (m: MsgOf<T>) => boolean = () => true,
|
||||
) {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (true) {
|
||||
const hit = messages.find((m) =>
|
||||
!read.has(m) && m.type === type && where(m)
|
||||
const hit = messages.find((m): m is MsgOf<T> =>
|
||||
!read.has(m) && m.type === type && where(m as MsgOf<T>)
|
||||
);
|
||||
if (hit) {
|
||||
read.add(hit);
|
||||
@@ -94,7 +112,8 @@ async function withServer(
|
||||
]);
|
||||
}
|
||||
},
|
||||
all: (type) => messages.filter((m) => m.type === type),
|
||||
all: <T extends Msg["type"]>(type: T) =>
|
||||
messages.filter((m): m is MsgOf<T> => m.type === type),
|
||||
send: (data) =>
|
||||
ws.send(typeof data === "string" ? data : JSON.stringify(data)),
|
||||
async sync() {
|
||||
@@ -157,7 +176,7 @@ wsTest("welcome: anonymous and signed-in state", async (connect) => {
|
||||
const me = await connect(user);
|
||||
const [mine] = me.all("welcome");
|
||||
assertEquals(mine.myVotes, [dumpId]);
|
||||
assertEquals(mine.users.map((u: Msg) => u.username), [user.username]);
|
||||
assertEquals(mine.users.map((u) => u.username), [user.username]);
|
||||
assertEquals(mine.chatReadAt, "2024-01-01T00:00:00.000Z");
|
||||
// The anonymous client hears about the arrival.
|
||||
const presence = await anon.next(
|
||||
@@ -217,7 +236,6 @@ wsTest(
|
||||
type: "vote_ack",
|
||||
dumpId,
|
||||
action: "cast",
|
||||
success: true,
|
||||
voteCount: 1,
|
||||
});
|
||||
const update = await watcher.next("votes_update");
|
||||
@@ -480,11 +498,11 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
|
||||
const x = await connect(other);
|
||||
|
||||
// A public file dump arrives for everyone; a private one for no one.
|
||||
const pub = await post("/api/dumps", {
|
||||
const pub = await post<Dump>("/api/dumps", {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals((await x.next("dump_created")).dump.id, pub.body.data.id);
|
||||
assertEquals((await x.next("dump_created")).dump.id, pub.data.id);
|
||||
await post("/api/dumps", {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(PNG, "p.png", "image/png", { isPrivate: "true" }),
|
||||
@@ -493,16 +511,16 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
|
||||
assertEquals(x.all("dump_created").length, 1);
|
||||
|
||||
// Going private is a deletion for everyone else; going public an arrival.
|
||||
await put(`/api/dumps/${pub.body.data.id}`, {
|
||||
await put(`/api/dumps/${pub.data.id}`, {
|
||||
auth: owner.auth,
|
||||
json: { isPrivate: true },
|
||||
});
|
||||
assertEquals((await x.next("dump_deleted")).dumpId, pub.body.data.id);
|
||||
await put(`/api/dumps/${pub.body.data.id}`, {
|
||||
assertEquals((await x.next("dump_deleted")).dumpId, pub.data.id);
|
||||
await put(`/api/dumps/${pub.data.id}`, {
|
||||
auth: owner.auth,
|
||||
json: { isPrivate: false },
|
||||
});
|
||||
await x.next("dump_created", (m) => m.dump.id === pub.body.data.id);
|
||||
await x.next("dump_created", (m) => m.dump.id === pub.data.id);
|
||||
|
||||
// Comments on a private dump stay quiet.
|
||||
const priv = insertDump({ userId: owner.id, isPrivate: true });
|
||||
@@ -510,7 +528,7 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
|
||||
auth: owner.auth,
|
||||
json: { body: "shh" },
|
||||
});
|
||||
await post(`/api/dumps/${pub.body.data.id}/comments`, {
|
||||
await post(`/api/dumps/${pub.data.id}/comments`, {
|
||||
auth: owner.auth,
|
||||
json: { body: "hi" },
|
||||
});
|
||||
@@ -531,7 +549,7 @@ wsTest("a password change closes the user's sockets", async (connect) => {
|
||||
const user = await createUser();
|
||||
const c = await connect(user);
|
||||
await new Promise((r) => setTimeout(r, 1100)); // see auth.test.ts
|
||||
const res = await post("/api/users/me/change-password", {
|
||||
const res = await post<AuthTokens>("/api/users/me/change-password", {
|
||||
auth: user.auth,
|
||||
json: { currentPassword: PASSWORD, newPassword: "a-new-password" },
|
||||
});
|
||||
@@ -541,8 +559,8 @@ wsTest("a password change closes the user's sockets", async (connect) => {
|
||||
// Reconnecting with the old token is anonymous now.
|
||||
const again = await connect(user);
|
||||
assertEquals(again.all("welcome")[0].users, []);
|
||||
assertExists(res.body.data.token);
|
||||
assertFalse(res.body.data.token === user.token);
|
||||
assertExists(res.data.token);
|
||||
assertFalse(res.data.token === user.token);
|
||||
});
|
||||
|
||||
wsTest(
|
||||
|
||||
Reference in New Issue
Block a user