399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import { randomBytes, scryptSync } from "node:crypto";
|
|
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
|
import {
|
|
type Comment,
|
|
Dump,
|
|
type Notification,
|
|
type NotificationType,
|
|
type Playlist,
|
|
type RichContent,
|
|
type User,
|
|
} from "./interfaces.ts";
|
|
import {
|
|
ATTACHMENTS_DIR,
|
|
DB_PATH,
|
|
ORPHANED_ATTACHMENTS_RETENTION_HOURS,
|
|
UNUSED_INVITES_RETENTION_DAYS,
|
|
} from "../config.ts";
|
|
|
|
export const db = new DatabaseSync(DB_PATH);
|
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
|
|
// Purge expired unused invites on startup
|
|
db.prepare(
|
|
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`,
|
|
).run();
|
|
|
|
// Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour
|
|
const orphanedAttachments = db.prepare(
|
|
`SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`,
|
|
).all() as { id: string }[];
|
|
if (orphanedAttachments.length > 0) {
|
|
for (const { id } of orphanedAttachments) {
|
|
await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {});
|
|
}
|
|
db.prepare(
|
|
`DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`,
|
|
).run();
|
|
}
|
|
|
|
// Create default admin user if no users exist
|
|
const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as {
|
|
count: number;
|
|
};
|
|
if (userCount.count === 0) {
|
|
const salt = randomBytes(16).toString("hex");
|
|
const hash = scryptSync("admin", salt, 64).toString("hex");
|
|
const passwordHash = `${hash}.${salt}`;
|
|
db.prepare(
|
|
`INSERT INTO users (id, username, password_hash, is_admin, created_at) VALUES (?, 'admin', ?, 1, datetime('now'))`,
|
|
).run(crypto.randomUUID(), passwordHash);
|
|
console.log("Created default admin user (username: admin, password: admin)");
|
|
}
|
|
|
|
/**
|
|
* Database Row Types
|
|
*/
|
|
|
|
export interface DumpRow {
|
|
id: string;
|
|
kind: string;
|
|
title: string;
|
|
comment: string | null;
|
|
user_id: string;
|
|
created_at: string;
|
|
updated_at: string | null;
|
|
slug: string | null;
|
|
url: string | null;
|
|
rich_content: string | null;
|
|
file_name: string | null;
|
|
file_mime: string | null;
|
|
file_size: number | null;
|
|
vote_count: number;
|
|
comment_count: number;
|
|
is_private: number;
|
|
[key: string]: SQLOutputValue; // Index signature
|
|
}
|
|
|
|
export interface UserRow {
|
|
id: string;
|
|
username: string;
|
|
password_hash: string;
|
|
is_admin: number;
|
|
created_at: string;
|
|
updated_at: string | null;
|
|
avatar_mime: string | null;
|
|
description: string | null;
|
|
invited_by: string | null;
|
|
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
|
|
invited_by_username: string | null;
|
|
[key: string]: SQLOutputValue; // Index signature
|
|
}
|
|
|
|
/**
|
|
* Type Guards
|
|
*/
|
|
|
|
export function isDumpRow(obj: unknown): obj is DumpRow {
|
|
return !!obj &&
|
|
typeof obj === "object" &&
|
|
"id" in obj && typeof obj.id === "string" &&
|
|
"kind" in obj && typeof obj.kind === "string" &&
|
|
"title" in obj && typeof obj.title === "string" &&
|
|
"comment" in obj &&
|
|
(typeof obj.comment === "string" || obj.comment === null) &&
|
|
"user_id" in obj && typeof obj.user_id === "string" &&
|
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
|
"updated_at" in obj &&
|
|
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
|
|
"slug" in obj && (typeof obj.slug === "string" || obj.slug === null) &&
|
|
"url" in obj && (typeof obj.url === "string" || obj.url === null) &&
|
|
"rich_content" in obj &&
|
|
(typeof obj.rich_content === "string" || obj.rich_content === null) &&
|
|
"file_name" in obj &&
|
|
(typeof obj.file_name === "string" || obj.file_name === null) &&
|
|
"file_mime" in obj &&
|
|
(typeof obj.file_mime === "string" || obj.file_mime === null) &&
|
|
"file_size" in obj &&
|
|
(typeof obj.file_size === "number" || obj.file_size === null) &&
|
|
"vote_count" in obj && typeof obj.vote_count === "number" &&
|
|
"comment_count" in obj && typeof obj.comment_count === "number" &&
|
|
"is_private" in obj && typeof obj.is_private === "number";
|
|
}
|
|
|
|
export function isUserRow(obj: unknown): obj is UserRow {
|
|
return !!obj &&
|
|
typeof obj === "object" &&
|
|
"id" in obj && typeof obj.id === "string" &&
|
|
"username" in obj && typeof obj.username === "string" &&
|
|
"password_hash" in obj && typeof obj.password_hash === "string" &&
|
|
"is_admin" in obj && typeof obj.is_admin === "number" &&
|
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
|
"updated_at" in obj &&
|
|
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
|
|
"avatar_mime" in obj &&
|
|
(typeof obj.avatar_mime === "string" || obj.avatar_mime === null) &&
|
|
"description" in obj &&
|
|
(typeof obj.description === "string" || obj.description === null) &&
|
|
"invited_by" in obj &&
|
|
(typeof obj.invited_by === "string" || obj.invited_by === null);
|
|
}
|
|
|
|
/**
|
|
* Conversion Helpers
|
|
*/
|
|
|
|
export function dumpRowToApi(row: DumpRow): Dump {
|
|
return {
|
|
id: row.id,
|
|
kind: row.kind as "url" | "file",
|
|
title: row.title,
|
|
slug: row.slug ?? undefined,
|
|
url: row.url ?? undefined,
|
|
comment: row.comment ?? undefined,
|
|
userId: row.user_id,
|
|
createdAt: new Date(row.created_at),
|
|
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
|
richContent: row.rich_content
|
|
? (JSON.parse(row.rich_content) as RichContent)
|
|
: undefined,
|
|
fileName: row.file_name ?? undefined,
|
|
fileMime: row.file_mime ?? undefined,
|
|
fileSize: row.file_size ?? undefined,
|
|
voteCount: row.vote_count,
|
|
commentCount: row.comment_count,
|
|
isPrivate: Boolean(row.is_private),
|
|
};
|
|
}
|
|
|
|
export function dumpApiToRow(dump: Dump): DumpRow {
|
|
return {
|
|
id: dump.id,
|
|
kind: dump.kind,
|
|
title: dump.title,
|
|
slug: dump.slug ?? null,
|
|
comment: dump.comment ?? null,
|
|
user_id: dump.userId,
|
|
created_at: dump.createdAt.toISOString(),
|
|
updated_at: dump.updatedAt?.toISOString() ?? null,
|
|
url: dump.url ?? null,
|
|
rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null,
|
|
file_name: dump.fileName ?? null,
|
|
file_mime: dump.fileMime ?? null,
|
|
file_size: dump.fileSize ?? null,
|
|
vote_count: dump.voteCount,
|
|
comment_count: dump.commentCount,
|
|
is_private: dump.isPrivate ? 1 : 0,
|
|
};
|
|
}
|
|
|
|
export function userRowToApi(row: UserRow): User {
|
|
return {
|
|
id: row.id,
|
|
username: row.username,
|
|
passwordHash: row.password_hash,
|
|
isAdmin: Boolean(row.is_admin),
|
|
createdAt: new Date(row.created_at),
|
|
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
|
avatarMime: row.avatar_mime ?? undefined,
|
|
description: row.description ?? undefined,
|
|
invitedByUsername: typeof row.invited_by_username === "string"
|
|
? row.invited_by_username
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
export function userApiToRow(user: User): UserRow {
|
|
return {
|
|
id: user.id,
|
|
username: user.username,
|
|
password_hash: user.passwordHash,
|
|
is_admin: user.isAdmin ? 1 : 0,
|
|
created_at: user.createdAt.toISOString(),
|
|
updated_at: user.updatedAt?.toISOString() ?? null,
|
|
avatar_mime: user.avatarMime ?? null,
|
|
description: user.description ?? null,
|
|
invited_by: null,
|
|
invited_by_username: null,
|
|
};
|
|
}
|
|
|
|
export interface CommentRow {
|
|
id: string;
|
|
dump_id: string;
|
|
user_id: string;
|
|
parent_id: string | null;
|
|
body: string;
|
|
created_at: string;
|
|
updated_at: string | null;
|
|
deleted: number;
|
|
author_username: string;
|
|
author_avatar_mime: string | null;
|
|
[key: string]: SQLOutputValue;
|
|
}
|
|
|
|
export function isCommentRow(
|
|
obj: unknown,
|
|
): obj is CommentRow {
|
|
return !!obj && typeof obj === "object" &&
|
|
"id" in obj && typeof obj.id === "string" &&
|
|
"dump_id" in obj && typeof obj.dump_id === "string" &&
|
|
"user_id" in obj && typeof obj.user_id === "string" &&
|
|
"parent_id" in obj &&
|
|
(typeof obj.parent_id === "string" || obj.parent_id === null) &&
|
|
"body" in obj && typeof obj.body === "string" &&
|
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
|
"updated_at" in obj &&
|
|
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
|
|
"deleted" in obj && typeof obj.deleted === "number" &&
|
|
"author_username" in obj && typeof obj.author_username === "string" &&
|
|
"author_avatar_mime" in obj &&
|
|
(typeof obj.author_avatar_mime === "string" ||
|
|
obj.author_avatar_mime === null);
|
|
}
|
|
|
|
export function commentRowToApi(row: CommentRow): Comment {
|
|
return {
|
|
id: row.id,
|
|
dumpId: row.dump_id,
|
|
userId: row.user_id,
|
|
parentId: row.parent_id ?? undefined,
|
|
body: row.body,
|
|
createdAt: new Date(row.created_at),
|
|
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
|
deleted: Boolean(row.deleted),
|
|
authorUsername: row.author_username,
|
|
authorAvatarMime: row.author_avatar_mime ?? undefined,
|
|
};
|
|
}
|
|
|
|
export interface PlaylistRow {
|
|
id: string;
|
|
user_id: string;
|
|
title: string;
|
|
slug: string | null;
|
|
description: string | null;
|
|
is_public: number;
|
|
created_at: string;
|
|
updated_at: string | null;
|
|
image_mime: string | null;
|
|
[key: string]: SQLOutputValue;
|
|
}
|
|
|
|
export function isPlaylistRow(
|
|
obj: unknown,
|
|
): obj is PlaylistRow {
|
|
return !!obj && typeof obj === "object" &&
|
|
"id" in obj && typeof obj.id === "string" &&
|
|
"user_id" in obj && typeof obj.user_id === "string" &&
|
|
"title" in obj && typeof obj.title === "string" &&
|
|
"slug" in obj && (typeof obj.slug === "string" || obj.slug === null) &&
|
|
"description" in obj &&
|
|
(typeof obj.description === "string" || obj.description === null) &&
|
|
"is_public" in obj && typeof obj.is_public === "number" &&
|
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
|
"updated_at" in obj &&
|
|
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
|
|
"image_mime" in obj &&
|
|
(typeof obj.image_mime === "string" || obj.image_mime === null);
|
|
}
|
|
|
|
export function playlistRowToApi(row: PlaylistRow): Playlist {
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
title: row.title,
|
|
slug: row.slug ?? undefined,
|
|
description: row.description ?? undefined,
|
|
isPublic: Boolean(row.is_public),
|
|
createdAt: new Date(row.created_at),
|
|
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
|
imageMime: row.image_mime ?? undefined,
|
|
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
|
|
ownerUsername: typeof row.owner_username === "string"
|
|
? row.owner_username
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
export interface FollowRow {
|
|
id: string;
|
|
follower_id: string;
|
|
followed_user_id: string | null;
|
|
followed_playlist_id: string | null;
|
|
created_at: string;
|
|
[key: string]: SQLOutputValue;
|
|
}
|
|
|
|
export function isFollowRow(
|
|
obj: unknown,
|
|
): obj is FollowRow {
|
|
return !!obj && typeof obj === "object" &&
|
|
"id" in obj && typeof obj.id === "string" &&
|
|
"follower_id" in obj && typeof obj.follower_id === "string" &&
|
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
|
"followed_user_id" in obj && (obj.followed_user_id === null ||
|
|
typeof obj.followed_user_id === "string") &&
|
|
"followed_playlist_id" in obj && (obj.followed_playlist_id === null ||
|
|
typeof obj.followed_playlist_id === "string");
|
|
}
|
|
|
|
// ── Notifications ─────────────────────────────────────────────────────────────
|
|
|
|
export interface NotificationRow {
|
|
id: string;
|
|
user_id: string;
|
|
type: string;
|
|
data: string;
|
|
read: number;
|
|
created_at: string;
|
|
source_key: string | null;
|
|
[key: string]: SQLOutputValue;
|
|
}
|
|
|
|
export function isNotificationRow(
|
|
obj: unknown,
|
|
): obj is NotificationRow {
|
|
return !!obj && typeof obj === "object" &&
|
|
"id" in obj && typeof obj.id === "string" &&
|
|
"user_id" in obj && typeof obj.user_id === "string" &&
|
|
"type" in obj && typeof obj.type === "string" &&
|
|
"data" in obj && typeof obj.data === "string" &&
|
|
"read" in obj && typeof obj.read === "number" &&
|
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
|
"source_key" in obj &&
|
|
(typeof obj.source_key === "string" || obj.source_key === null);
|
|
}
|
|
|
|
export function notificationRowToApi(row: NotificationRow): Notification {
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
type: row.type as NotificationType,
|
|
data: JSON.parse(row.data),
|
|
read: Boolean(row.read),
|
|
createdAt: new Date(row.created_at),
|
|
};
|
|
}
|
|
|
|
// ── Invites ───────────────────────────────────────────────────────────────────
|
|
|
|
export interface InviteRow {
|
|
token: string;
|
|
inviter_id: string;
|
|
used_at: string | null;
|
|
created_at: string;
|
|
[key: string]: SQLOutputValue;
|
|
}
|
|
|
|
export function isInviteRow(
|
|
obj: unknown,
|
|
): obj is InviteRow {
|
|
return !!obj && typeof obj === "object" &&
|
|
"token" in obj && typeof obj.token === "string" &&
|
|
"inviter_id" in obj && typeof obj.inviter_id === "string" &&
|
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
|
"used_at" in obj &&
|
|
(obj.used_at === null || typeof obj.used_at === "string");
|
|
}
|