v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,38 +1,9 @@
|
||||
import type { PublicUser, Role } from "../../model.ts";
|
||||
|
||||
// Mirror of api/lib/permissions.ts. Keep the two in sync.
|
||||
export type Permission =
|
||||
| "dump:moderate"
|
||||
| "comment:moderate"
|
||||
| "playlist:moderate"
|
||||
| "chat:moderate"
|
||||
| "user:manage"
|
||||
| "category:manage";
|
||||
|
||||
const ALL_PERMISSIONS: readonly Permission[] = [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
"playlist:moderate",
|
||||
"chat:moderate",
|
||||
"user:manage",
|
||||
"category:manage",
|
||||
];
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
|
||||
admin: ALL_PERMISSIONS,
|
||||
moderator: [
|
||||
"dump:moderate",
|
||||
"comment:moderate",
|
||||
"playlist:moderate",
|
||||
"chat:moderate",
|
||||
],
|
||||
user: [],
|
||||
};
|
||||
import { hasPermission, type Permission } from "../../../shared/permissions.ts";
|
||||
import type { PublicUser } from "../../../shared/user.ts";
|
||||
|
||||
export function can(
|
||||
user: Pick<PublicUser, "role"> | null | undefined,
|
||||
permission: Permission,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
return ROLE_PERMISSIONS[user.role].includes(permission);
|
||||
return !!user && hasPermission(user.role, permission);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { type AuthResponse, deserializeAuthResponse } from "../../model.ts";
|
||||
import type { AuthResponse } from "../../../shared/user.ts";
|
||||
import { deserializeAuthResponse } from "../shared/deserialize.ts";
|
||||
|
||||
// The session is shared by every tab through this one localStorage entry.
|
||||
/**
|
||||
* The signed-in session as the app holds it. Sessions stored before media
|
||||
* tokens existed have none; AuthProvider fetches one for them.
|
||||
*/
|
||||
export type Session = Omit<AuthResponse, "mediaToken"> & {
|
||||
mediaToken?: string;
|
||||
};
|
||||
|
||||
export const AUTH_STORAGE_KEY = "authResponse";
|
||||
|
||||
export function isTokenExpired(token: string): boolean {
|
||||
@@ -13,7 +22,7 @@ export function isTokenExpired(token: string): boolean {
|
||||
}
|
||||
|
||||
/** The stored session, or null — discarding one that can no longer be used. */
|
||||
export function readStoredSession(): AuthResponse | null {
|
||||
export function readStoredSession(): Session | null {
|
||||
const stored = localStorage.getItem(AUTH_STORAGE_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
@@ -38,7 +47,7 @@ export function readStoredSession(): AuthResponse | null {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function storeSession(session: AuthResponse): void {
|
||||
export function storeSession(session: Session): void {
|
||||
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { markdownToPlainText } from "./text.ts";
|
||||
import { isEmojiOnly } from "../editor/text.ts";
|
||||
import { VALIDATION } from "../../../shared/validation.ts";
|
||||
|
||||
// Keep in sync with MAX_CHAT_LENGTH in api/services/chat-service.ts.
|
||||
export const MAX_CHAT_LENGTH = 2000;
|
||||
// The counter only shows once a message is getting near the limit.
|
||||
export const COUNT_FROM = 1800;
|
||||
|
||||
// What the server holds a message to: its length once trimmed.
|
||||
export function sendable(draft: string): boolean {
|
||||
const body = draft.trim();
|
||||
return body.length > 0 && body.length <= MAX_CHAT_LENGTH;
|
||||
return body.length > 0 && body.length <= VALIDATION.CHAT_MESSAGE_MAX;
|
||||
}
|
||||
|
||||
// A message of nothing but a few emoji is shown large, as chats do.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Text helpers for the live chat.
|
||||
import { MENTION_RE } from "../../../shared/mention.ts";
|
||||
|
||||
// Mirrors MENTION_RE in api/services/notification-service.ts.
|
||||
const MENTION_RE = /(?<![[(\w])@(\w+)/g;
|
||||
// Text helpers for the live chat.
|
||||
|
||||
/** Whether `body` @-mentions `username` (case-insensitively), by the same rule
|
||||
* the server uses to send mention notifications. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dump } from "../../model.ts";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
|
||||
export function hotScore(dump: Dump): number {
|
||||
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dump } from "../../model.ts";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import { hotScore } from "./hotScore.ts";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
import type { PlayerItem } from "../../contexts/player/PlayerContext.ts";
|
||||
|
||||
/** Mirrors the `Tralbum` shape returned by GET /api/bandcamp/tracks. */
|
||||
interface BandcampTrack {
|
||||
index: number;
|
||||
trackNum?: number;
|
||||
title: string;
|
||||
duration?: number;
|
||||
streamUrl: string | null;
|
||||
streamable: boolean;
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
interface BandcampAlbum {
|
||||
sourceUrl: string;
|
||||
itemType: "album" | "track";
|
||||
title?: string;
|
||||
artist?: string;
|
||||
artworkUrl?: string;
|
||||
tracks: BandcampTrack[];
|
||||
resolvedAt: number;
|
||||
}
|
||||
import type { Tralbum, TralbumTrack } from "../../../shared/player.ts";
|
||||
|
||||
export interface BandcampContext {
|
||||
dumpHref?: string;
|
||||
@@ -48,12 +28,12 @@ export async function resolveBandcampQueue(
|
||||
);
|
||||
if (!res.ok) throw new Error(`resolve failed: ${res.status}`);
|
||||
|
||||
const body = await res.json() as { success: boolean; data?: BandcampAlbum };
|
||||
const body = await res.json() as { success: boolean; data?: Tralbum };
|
||||
const album = body.data;
|
||||
if (!body.success || !album) throw new Error("resolve returned no data");
|
||||
|
||||
const items = album.tracks
|
||||
.filter((t): t is BandcampTrack & { streamUrl: string } =>
|
||||
.filter((t): t is TralbumTrack & { streamUrl: string } =>
|
||||
t.streamable && typeof t.streamUrl === "string"
|
||||
)
|
||||
.map((t): PlayerItem => ({
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { API_URL } from "../../config/api.ts";
|
||||
|
||||
/** Mirrors one entry of GET /api/youtube/playlist. */
|
||||
export interface YouTubeTrack {
|
||||
videoId: string;
|
||||
title: string;
|
||||
/** Seconds. */
|
||||
duration?: number;
|
||||
}
|
||||
import type { YouTubePlaylistTrack } from "../../../shared/player.ts";
|
||||
|
||||
/**
|
||||
* The playlist an embed URL plays, if any.
|
||||
@@ -43,14 +36,14 @@ export function withJsApi(url: string): string {
|
||||
/** Titles and lengths for a playlist. Throws; callers fall back to no list. */
|
||||
export async function fetchPlaylistTracks(
|
||||
listId: string,
|
||||
): Promise<YouTubeTrack[]> {
|
||||
): Promise<YouTubePlaylistTrack[]> {
|
||||
const res = await fetch(
|
||||
`${API_URL}/api/youtube/playlist?list=${encodeURIComponent(listId)}`,
|
||||
);
|
||||
if (!res.ok) throw new Error(`playlist fetch failed: ${res.status}`);
|
||||
const body = await res.json() as {
|
||||
success: boolean;
|
||||
data?: { tracks?: YouTubeTrack[] };
|
||||
data?: { tracks?: YouTubePlaylistTrack[] };
|
||||
};
|
||||
const tracks = body.data?.tracks;
|
||||
if (!body.success || !tracks?.length) throw new Error("no tracks");
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { parseAPIResponse } from "../../model.ts";
|
||||
import type { APIResponse } from "../../../shared/api.ts";
|
||||
|
||||
/**
|
||||
* Parses an unknown JSON payload into a typed APIResponse<T>.
|
||||
* Performs a minimal runtime check on the `success` discriminant so the
|
||||
* single internal `as` cast is safe; throws if the shape is unexpected.
|
||||
*/
|
||||
export function parseAPIResponse<T>(raw: unknown): APIResponse<T> {
|
||||
if (raw !== null && typeof raw === "object" && "success" in raw) {
|
||||
return raw as APIResponse<T>;
|
||||
}
|
||||
throw new Error("Unexpected response format");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a caught fetch error into a human-readable message.
|
||||
|
||||
94
src/utils/shared/deserialize.ts
Normal file
94
src/utils/shared/deserialize.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { Wire } from "../../../shared/api.ts";
|
||||
import type { Category } from "../../../shared/category.ts";
|
||||
import type { ChatMessage } from "../../../shared/chat.ts";
|
||||
import type { Comment } from "../../../shared/comment.ts";
|
||||
import type { Dump, DumpUrlMatch } from "../../../shared/dump.ts";
|
||||
import type { Notification } from "../../../shared/notification.ts";
|
||||
import type {
|
||||
Playlist,
|
||||
PlaylistMembership,
|
||||
PlaylistWithDumps,
|
||||
} from "../../../shared/playlist.ts";
|
||||
import type {
|
||||
AuthResponse,
|
||||
Invitee,
|
||||
PublicUser,
|
||||
SelfUser,
|
||||
} from "../../../shared/user.ts";
|
||||
|
||||
// The API, the socket and localStorage all hand back JSON, where dates are ISO
|
||||
// strings (see Wire<T>); these turn them back into Dates.
|
||||
|
||||
function dates<T extends { createdAt: string; updatedAt?: string }>(raw: T) {
|
||||
return {
|
||||
...raw,
|
||||
createdAt: new Date(raw.createdAt),
|
||||
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function deserializeDump(raw: Wire<Dump>): Dump {
|
||||
return dates(raw);
|
||||
}
|
||||
|
||||
export function hydrateDump(raw: unknown): Dump {
|
||||
return deserializeDump(raw as Wire<Dump>);
|
||||
}
|
||||
|
||||
export function deserializeDumpUrlMatch(raw: Wire<DumpUrlMatch>): DumpUrlMatch {
|
||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
||||
}
|
||||
|
||||
export function deserializeCategory(raw: Wire<Category>): Category {
|
||||
return dates(raw);
|
||||
}
|
||||
|
||||
export function deserializePublicUser(raw: Wire<PublicUser>): PublicUser {
|
||||
return dates(raw);
|
||||
}
|
||||
|
||||
export function deserializeSelfUser(raw: Wire<SelfUser>): SelfUser {
|
||||
return dates(raw);
|
||||
}
|
||||
|
||||
export function deserializeInvitee(raw: Wire<Invitee>): Invitee {
|
||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
||||
}
|
||||
|
||||
export function deserializeAuthResponse(
|
||||
raw: Wire<AuthResponse>,
|
||||
): AuthResponse {
|
||||
return { ...raw, user: deserializeSelfUser(raw.user) };
|
||||
}
|
||||
|
||||
export function deserializeComment(raw: Wire<Comment>): Comment {
|
||||
return dates(raw);
|
||||
}
|
||||
|
||||
export function deserializeChatMessage(raw: Wire<ChatMessage>): ChatMessage {
|
||||
return dates(raw);
|
||||
}
|
||||
|
||||
export function deserializePlaylist(raw: Wire<Playlist>): Playlist {
|
||||
return dates(raw);
|
||||
}
|
||||
|
||||
export function hydratePlaylist(raw: unknown): Playlist {
|
||||
return deserializePlaylist(raw as Wire<Playlist>);
|
||||
}
|
||||
|
||||
export function deserializePlaylistWithDumps(
|
||||
raw: Wire<PlaylistWithDumps>,
|
||||
): PlaylistWithDumps {
|
||||
return { ...dates(raw), dumps: raw.dumps.map(deserializeDump) };
|
||||
}
|
||||
|
||||
export function deserializePlaylistMembership(
|
||||
raw: Wire<PlaylistMembership>,
|
||||
): PlaylistMembership {
|
||||
return { playlist: deserializePlaylist(raw.playlist), hasDump: raw.hasDump };
|
||||
}
|
||||
|
||||
export function deserializeNotification(raw: Wire<Notification>): Notification {
|
||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
||||
}
|
||||
Reference in New Issue
Block a user