v3: fixes to database schema and user registration
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
||||
import {
|
||||
type Comment,
|
||||
@@ -17,6 +18,20 @@ db.prepare(
|
||||
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`,
|
||||
).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
|
||||
*/
|
||||
@@ -70,6 +85,9 @@ export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
|
||||
(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) &&
|
||||
@@ -92,10 +110,14 @@ export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
|
||||
"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);
|
||||
(typeof obj.description === "string" || obj.description === null) &&
|
||||
"invited_by" in obj &&
|
||||
(typeof obj.invited_by === "string" || obj.invited_by === null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,11 +130,11 @@ export function dumpRowToApi(row: DumpRow): Dump {
|
||||
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,
|
||||
url: row.url ?? undefined,
|
||||
richContent: row.rich_content
|
||||
? (JSON.parse(row.rich_content) as RichContent)
|
||||
: undefined,
|
||||
@@ -201,6 +223,7 @@ export function isCommentRow(
|
||||
(typeof obj.parent_id === "string" || obj.parent_id === null) &&
|
||||
typeof obj.body === "string" &&
|
||||
typeof obj.created_at === "string" &&
|
||||
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
|
||||
typeof obj.deleted === "number" &&
|
||||
typeof obj.author_username === "string" &&
|
||||
(typeof obj.author_avatar_mime === "string" ||
|
||||
@@ -241,8 +264,12 @@ export function isPlaylistRow(
|
||||
return !!obj && typeof obj.id === "string" &&
|
||||
typeof obj.user_id === "string" &&
|
||||
typeof obj.title === "string" &&
|
||||
(typeof obj.slug === "string" || obj.slug === null) &&
|
||||
(typeof obj.description === "string" || obj.description === null) &&
|
||||
typeof obj.is_public === "number" &&
|
||||
typeof obj.created_at === "string";
|
||||
typeof obj.created_at === "string" &&
|
||||
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
|
||||
(typeof obj.image_mime === "string" || obj.image_mime === null);
|
||||
}
|
||||
|
||||
export function playlistRowToApi(row: PlaylistRow): Playlist {
|
||||
@@ -307,7 +334,8 @@ export function isNotificationRow(
|
||||
typeof obj.type === "string" &&
|
||||
typeof obj.data === "string" &&
|
||||
typeof obj.read === "number" &&
|
||||
typeof obj.created_at === "string";
|
||||
typeof obj.created_at === "string" &&
|
||||
(typeof obj.source_key === "string" || obj.source_key === null);
|
||||
}
|
||||
|
||||
export function notificationRowToApi(row: NotificationRow): Notification {
|
||||
|
||||
@@ -89,16 +89,33 @@ export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest {
|
||||
export function isRegisterUserRequest(
|
||||
obj: unknown,
|
||||
): obj is RegisterUserRequest {
|
||||
return validateRegisterUserRequest(obj) === null;
|
||||
}
|
||||
|
||||
/** Returns a human-readable error string, or null if the request is valid. */
|
||||
export function validateRegisterUserRequest(obj: unknown): string | null {
|
||||
if (
|
||||
!obj || typeof obj !== "object" ||
|
||||
!("username" in obj) || typeof obj.username !== "string" ||
|
||||
!("password" in obj) || typeof obj.password !== "string" ||
|
||||
!("inviteToken" in obj) || typeof obj.inviteToken !== "string"
|
||||
) return false;
|
||||
) return "Invalid request";
|
||||
const { username, password } = obj as RegisterUserRequest;
|
||||
return /^[a-zA-Z0-9_]{1,32}$/.test(username) &&
|
||||
password.length >= VALIDATION.PASSWORD_MIN &&
|
||||
password.length <= VALIDATION.PASSWORD_MAX;
|
||||
if (
|
||||
!new RegExp(
|
||||
`^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`,
|
||||
)
|
||||
.test(username)
|
||||
) {
|
||||
return `Username must be ${VALIDATION.USERNAME_MIN}–${VALIDATION.USERNAME_MAX} characters and contain only letters, numbers, or underscores`;
|
||||
}
|
||||
if (password.length < VALIDATION.PASSWORD_MIN) {
|
||||
return `Password must be at least ${VALIDATION.PASSWORD_MIN} characters`;
|
||||
}
|
||||
if (password.length > VALIDATION.PASSWORD_MAX) {
|
||||
return `Password must be at most ${VALIDATION.PASSWORD_MAX} characters`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
|
||||
@@ -310,7 +327,10 @@ export function isCreatePlaylistRequest(
|
||||
!("isPublic" in obj) || typeof obj.isPublic !== "boolean"
|
||||
) return false;
|
||||
const o = obj as Record<string, unknown>;
|
||||
if ((o.title as string).length === 0 || (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX) return false;
|
||||
if (
|
||||
(o.title as string).length === 0 ||
|
||||
(o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX
|
||||
) return false;
|
||||
if (
|
||||
"description" in o && typeof o.description !== "string" &&
|
||||
o.description !== null
|
||||
@@ -329,7 +349,10 @@ export function isUpdatePlaylistRequest(
|
||||
const o = obj as Record<string, unknown>;
|
||||
if ("title" in o) {
|
||||
if (typeof o.title !== "string") return false;
|
||||
if ((o.title as string).length === 0 || (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX) return false;
|
||||
if (
|
||||
(o.title as string).length === 0 ||
|
||||
(o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX
|
||||
) return false;
|
||||
}
|
||||
if (
|
||||
"description" in o && typeof o.description !== "string" &&
|
||||
|
||||
Reference in New Issue
Block a user