v3: search engine, responsive header with compact user menu
This commit is contained in:
@@ -1,11 +1,69 @@
|
||||
export const PROTOCOL = Deno.env.get("GERBEUR_PROTOCOL") || "http";
|
||||
export const HOSTNAME = Deno.env.get("GERBEUR_HOSTNAME") || "localhost";
|
||||
export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
|
||||
export const SMTPS_URL = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || "";
|
||||
export const JWT_SECRET = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || "";
|
||||
// GERBEUR_LISTEN_HOST controls the network interface Oak binds to.
|
||||
// Defaults to 0.0.0.0 so Docker port-forwarding works out of the box.
|
||||
// Set to 127.0.0.1 to restrict to loopback only.
|
||||
export const LISTEN_HOST = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0";
|
||||
export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`;
|
||||
export const DB_PATH = "api/sql/gerbeur.db";
|
||||
|
||||
// Upload/files
|
||||
export const UPLOADS_DIR = "api/uploads";
|
||||
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
|
||||
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
|
||||
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
|
||||
export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
|
||||
|
||||
export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
export const ALLOWED_IMAGE_MIMES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
export const DUMP_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB
|
||||
export const DUMP_ALLOWED_MIME_PREFIXES = ["text/", "image/", "video/", "audio/"];
|
||||
export const DUMP_ALLOWED_MIME_TYPES = new Set([
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/msword",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.ms-powerpoint",
|
||||
]);
|
||||
|
||||
// Pagination
|
||||
export const PAGINATION_DEFAULT_LIMIT = 20;
|
||||
export const PAGINATION_MAX_LIMIT = 100;
|
||||
|
||||
// Startup housekeeping
|
||||
export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1;
|
||||
export const UNUSED_INVITES_RETENTION_DAYS = 7;
|
||||
|
||||
// Validation constraints
|
||||
export const VALIDATION = {
|
||||
USERNAME_MIN: 1,
|
||||
USERNAME_MAX: 32,
|
||||
PASSWORD_MIN: 8,
|
||||
PASSWORD_MAX: 128,
|
||||
DUMP_TITLE_MAX: 200,
|
||||
DUMP_COMMENT_MAX: 5000,
|
||||
PLAYLIST_TITLE_MAX: 100,
|
||||
PLAYLIST_DESCRIPTION_MAX: 2000,
|
||||
COMMENT_BODY_MAX: 5000,
|
||||
USER_DESCRIPTION_MAX: 2000,
|
||||
} as const;
|
||||
|
||||
// SEO/OG
|
||||
export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur";
|
||||
|
||||
const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ??
|
||||
"http://localhost:3000";
|
||||
|
||||
@@ -7,14 +7,14 @@ import {
|
||||
isAuthPayload,
|
||||
isInvitePayload,
|
||||
} from "../model/interfaces.ts";
|
||||
import { JWT_SECRET } from "../config.ts";
|
||||
|
||||
const jwtSecret = Deno.env.get("GERBEUR_JWT_SECRET");
|
||||
if (!jwtSecret) {
|
||||
if (!JWT_SECRET) {
|
||||
throw new Error(
|
||||
"GERBEUR_JWT_SECRET environment variable is required. Generate one with: openssl rand -hex 32",
|
||||
);
|
||||
}
|
||||
const JWT_KEY = new TextEncoder().encode(jwtSecret);
|
||||
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
||||
|
||||
// ── Invite tokens ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from "../config.ts";
|
||||
|
||||
/**
|
||||
* Parses page/limit query parameters with sensible defaults and bounds.
|
||||
* page: clamped to [1, ∞)
|
||||
@@ -5,7 +10,7 @@
|
||||
*/
|
||||
export function parsePagination(
|
||||
params: URLSearchParams,
|
||||
defaultLimit = 20,
|
||||
defaultLimit = PAGINATION_DEFAULT_LIMIT,
|
||||
): { page: number; limit: number } {
|
||||
const page = Math.max(
|
||||
1,
|
||||
@@ -16,7 +21,7 @@ export function parsePagination(
|
||||
1,
|
||||
parseInt(params.get("limit") ?? String(defaultLimit)) || defaultLimit,
|
||||
),
|
||||
100,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
);
|
||||
return { page, limit };
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import type { Context } from "@oak/oak";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
import {
|
||||
ALLOWED_IMAGE_MIMES,
|
||||
ATTACHMENTS_DIR,
|
||||
AVATARS_DIR,
|
||||
DUMPS_DIR,
|
||||
MAX_IMAGE_SIZE_BYTES,
|
||||
PLAYLIST_IMAGES_DIR,
|
||||
UPLOADS_DIR,
|
||||
} from "../config.ts";
|
||||
|
||||
export const UPLOADS_DIR = "api/uploads";
|
||||
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
|
||||
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
|
||||
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
|
||||
export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
|
||||
|
||||
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
export const ALLOWED_IMAGE_MIMES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
]);
|
||||
export {
|
||||
ALLOWED_IMAGE_MIMES,
|
||||
ATTACHMENTS_DIR,
|
||||
AVATARS_DIR,
|
||||
DUMPS_DIR,
|
||||
PLAYLIST_IMAGES_DIR,
|
||||
UPLOADS_DIR,
|
||||
};
|
||||
|
||||
/** Detect image MIME type from magic bytes, ignoring the browser-declared type. */
|
||||
export function detectImageMime(data: Uint8Array): string | null {
|
||||
@@ -39,7 +42,7 @@ export function detectImageMime(data: Uint8Array): string | null {
|
||||
|
||||
/** Validates image upload data: checks size and MIME. Returns the detected MIME type or throws APIException. */
|
||||
export function validateImageUpload(data: Uint8Array): string {
|
||||
if (data.length > MAX_IMAGE_SIZE) {
|
||||
if (data.length > MAX_IMAGE_SIZE_BYTES) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
|
||||
@@ -13,6 +13,7 @@ import commentsRouter from "./routes/comments.ts";
|
||||
import followsRouter from "./routes/follows.ts";
|
||||
import notificationsRouter from "./routes/notifications.ts";
|
||||
import invitesRouter from "./routes/invites.ts";
|
||||
import searchRouter from "./routes/search.ts";
|
||||
|
||||
import { ALLOWED_ORIGINS, BASE_URL, LISTEN_HOST, PORT } from "./config.ts";
|
||||
import { errorMiddleware } from "./middleware/error.ts";
|
||||
@@ -83,6 +84,10 @@ app.use(
|
||||
invitesRouter.routes(),
|
||||
invitesRouter.allowedMethods(),
|
||||
);
|
||||
app.use(
|
||||
searchRouter.routes(),
|
||||
searchRouter.allowedMethods(),
|
||||
);
|
||||
app.use(routeStaticFilesFrom([
|
||||
`${Deno.cwd()}/dist`,
|
||||
`${Deno.cwd()}/public`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Context, Next } from "@oak/oak";
|
||||
import { getDump } from "../services/dump-service.ts";
|
||||
import { getUserByUsername } from "../services/user-service.ts";
|
||||
import { getPlaylistById } from "../services/playlist-service.ts";
|
||||
import { OG_SITE_NAME } from "../config.ts";
|
||||
|
||||
interface OGMeta {
|
||||
title: string;
|
||||
@@ -10,8 +11,6 @@ interface OGMeta {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const SITE_NAME = "gerbeur";
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
@@ -24,7 +23,7 @@ function buildTags(meta: OGMeta): string {
|
||||
const card = meta.imageUrl ? "summary_large_image" : "summary";
|
||||
const tags = [
|
||||
`<title>${escapeAttr(meta.title)}</title>`,
|
||||
`<meta property="og:site_name" content="${SITE_NAME}" />`,
|
||||
`<meta property="og:site_name" content="${OG_SITE_NAME}" />`,
|
||||
`<meta property="og:type" content="website" />`,
|
||||
`<meta property="og:url" content="${escapeAttr(meta.url)}" />`,
|
||||
`<meta property="og:title" content="${escapeAttr(meta.title)}" />`,
|
||||
|
||||
@@ -9,26 +9,31 @@ import {
|
||||
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("api/sql/gerbeur.db");
|
||||
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', '-7 days');`,
|
||||
`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', '-1 hour');`,
|
||||
`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) {
|
||||
const { ATTACHMENTS_DIR } = await import("../lib/upload.ts");
|
||||
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', '-1 hour');`,
|
||||
`DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`,
|
||||
).run();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import { VALIDATION } from "../config.ts";
|
||||
|
||||
/**
|
||||
* Backend
|
||||
*/
|
||||
|
||||
// ── Validation constants (shared with frontend via src/config/api.ts) ──────────
|
||||
export const VALIDATION = {
|
||||
USERNAME_MIN: 1,
|
||||
USERNAME_MAX: 32,
|
||||
PASSWORD_MIN: 8,
|
||||
PASSWORD_MAX: 128,
|
||||
DUMP_TITLE_MAX: 200,
|
||||
DUMP_COMMENT_MAX: 5000,
|
||||
PLAYLIST_TITLE_MAX: 100,
|
||||
PLAYLIST_DESCRIPTION_MAX: 2000,
|
||||
COMMENT_BODY_MAX: 5000,
|
||||
USER_DESCRIPTION_MAX: 2000,
|
||||
} as const;
|
||||
|
||||
export interface RichContent {
|
||||
type: string;
|
||||
url: string;
|
||||
|
||||
47
api/routes/search.ts
Normal file
47
api/routes/search.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||
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";
|
||||
|
||||
const router = new Router({ prefix: "/api/search" });
|
||||
|
||||
router.get("/", async (ctx) => {
|
||||
const params = ctx.request.url.searchParams;
|
||||
const q = (params.get("q") ?? "").trim();
|
||||
const { page, limit } = parsePagination(params);
|
||||
const requestingUserId = (await parseOptionalAuth(ctx)) ?? undefined;
|
||||
|
||||
if (!q) {
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: {
|
||||
dumps: { items: [], total: 0, hasMore: false },
|
||||
users: [],
|
||||
playlists: [],
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const { items: dumpItems, total: dumpTotal } = searchDumps(
|
||||
q,
|
||||
page,
|
||||
limit,
|
||||
requestingUserId,
|
||||
);
|
||||
const users = searchUsersGlobal(q, 20);
|
||||
const playlists = searchPlaylists(q, 20, requestingUserId);
|
||||
|
||||
ctx.response.body = {
|
||||
success: true,
|
||||
data: {
|
||||
dumps: { items: dumpItems, total: dumpTotal, hasMore: page * limit < dumpTotal },
|
||||
users,
|
||||
playlists,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -17,28 +17,17 @@ import {
|
||||
notifyUserFollowersNewDump,
|
||||
} from "./notification-service.ts";
|
||||
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
|
||||
import { DUMPS_DIR } from "../lib/upload.ts";
|
||||
import {
|
||||
DUMP_ALLOWED_MIME_PREFIXES,
|
||||
DUMP_ALLOWED_MIME_TYPES,
|
||||
DUMP_MAX_FILE_SIZE_BYTES,
|
||||
DUMPS_DIR,
|
||||
} from "../config.ts";
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
const ALLOWED_MIME_PREFIXES = ["text/", "image/", "video/", "audio/"];
|
||||
const ALLOWED_MIME_TYPES = new Set([
|
||||
"application/pdf",
|
||||
"application/json",
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/msword",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.ms-powerpoint",
|
||||
]);
|
||||
|
||||
function isAllowedMime(mime: string): boolean {
|
||||
return ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
|
||||
ALLOWED_MIME_TYPES.has(mime);
|
||||
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
|
||||
DUMP_ALLOWED_MIME_TYPES.has(mime);
|
||||
}
|
||||
|
||||
function titleFromUrl(url: string): string {
|
||||
@@ -128,7 +117,7 @@ export async function createFileDump(
|
||||
`File type '${file.type}' is not allowed`,
|
||||
);
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
@@ -216,6 +205,39 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
|
||||
return dump;
|
||||
}
|
||||
|
||||
export function searchDumps(
|
||||
query: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
requestingUserId?: string,
|
||||
): { items: Dump[]; total: number } {
|
||||
if (!query.trim()) return { items: [], total: 0 };
|
||||
const pattern = `%${query}%`;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const searchClause =
|
||||
`(title LIKE ? OR comment LIKE ? OR json_extract(rich_content,'$.title') LIKE ? OR json_extract(rich_content,'$.description') LIKE ?)`;
|
||||
const searchParams = [pattern, pattern, pattern, pattern] as const;
|
||||
|
||||
if (requestingUserId) {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(requestingUserId, ...searchParams, limit, offset);
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
|
||||
).get(requestingUserId, ...searchParams) as { count: number } | undefined;
|
||||
return { items: rows.filter(isDumpRow).map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||
} else {
|
||||
const rows = db.prepare(
|
||||
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||
).all(...searchParams, limit, offset);
|
||||
const totalRow = db.prepare(
|
||||
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
|
||||
).get(...searchParams) as { count: number } | undefined;
|
||||
return { items: rows.filter(isDumpRow).map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||
}
|
||||
}
|
||||
|
||||
export function listDumps(
|
||||
page: number,
|
||||
limit: number,
|
||||
@@ -367,7 +389,7 @@ export async function replaceFileDump(
|
||||
`File type '${file.type}' is not allowed`,
|
||||
);
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
if (file.size > DUMP_MAX_FILE_SIZE_BYTES) {
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
|
||||
93
api/services/email-service.ts
Normal file
93
api/services/email-service.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer";
|
||||
|
||||
import { SMTPS_URL } from "../config.ts";
|
||||
|
||||
export type EmailRecipient = string | string[];
|
||||
|
||||
export interface EmailMessage {
|
||||
from: string;
|
||||
to: EmailRecipient;
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
cc?: EmailRecipient;
|
||||
bcc?: EmailRecipient;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
export interface EmailSendResult {
|
||||
messageId: string;
|
||||
accepted: string[];
|
||||
rejected: string[];
|
||||
}
|
||||
|
||||
let transporter: Transporter | null = null;
|
||||
|
||||
export function isEmailEnabled(): boolean {
|
||||
return SMTPS_URL.length > 0;
|
||||
}
|
||||
|
||||
function normalizeRecipients(
|
||||
recipient: string | readonly (string | { address?: string })[] | undefined,
|
||||
): string[] {
|
||||
if (!recipient) return [];
|
||||
if (typeof recipient === "string") {
|
||||
return recipient.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
return recipient.flatMap((entry) =>
|
||||
typeof entry === "string" ? entry.trim() : (entry.address?.trim() ?? "")
|
||||
).filter(Boolean);
|
||||
}
|
||||
|
||||
function getTransporter(): Transporter {
|
||||
if (!isEmailEnabled()) {
|
||||
throw new Error(
|
||||
"GERBEUR_SMTPS_URL environment variable is required to send emails.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport(SMTPS_URL);
|
||||
}
|
||||
|
||||
return transporter;
|
||||
}
|
||||
|
||||
export async function verifyEmailTransport(): Promise<boolean> {
|
||||
if (!isEmailEnabled()) return false;
|
||||
await getTransporter().verify();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function sendEmail(message: EmailMessage): Promise<EmailSendResult> {
|
||||
if (normalizeRecipients(message.to).length === 0) {
|
||||
throw new Error("Email recipient is required.");
|
||||
}
|
||||
|
||||
if (!message.subject.trim()) {
|
||||
throw new Error("Email subject is required.");
|
||||
}
|
||||
|
||||
if (!message.text?.trim() && !message.html?.trim()) {
|
||||
throw new Error("Email body is required (text and/or html).");
|
||||
}
|
||||
|
||||
const options: SendMailOptions = {
|
||||
from: message.from,
|
||||
to: message.to,
|
||||
cc: message.cc,
|
||||
bcc: message.bcc,
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
html: message.html,
|
||||
replyTo: message.replyTo,
|
||||
};
|
||||
|
||||
const info = await getTransporter().sendMail(options);
|
||||
|
||||
return {
|
||||
messageId: info.messageId,
|
||||
accepted: normalizeRecipients(info.accepted),
|
||||
rejected: normalizeRecipients(info.rejected),
|
||||
};
|
||||
}
|
||||
@@ -112,6 +112,26 @@ export function getPlaylist(
|
||||
return { ...playlist, dumps: visibleDumps };
|
||||
}
|
||||
|
||||
export function searchPlaylists(
|
||||
query: string,
|
||||
limit: number,
|
||||
requestingUserId?: string,
|
||||
): Playlist[] {
|
||||
if (!query.trim()) return [];
|
||||
const pattern = `%${query}%`;
|
||||
const searchClause = `(p.title LIKE ? OR p.description LIKE ?)`;
|
||||
|
||||
const rows = requestingUserId
|
||||
? db.prepare(
|
||||
`SELECT ${PLAYLIST_SELECT} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
|
||||
).all(requestingUserId, pattern, pattern, limit)
|
||||
: db.prepare(
|
||||
`SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
|
||||
).all(pattern, pattern, limit);
|
||||
|
||||
return rows.filter(isPlaylistRow).map(playlistRowToApi);
|
||||
}
|
||||
|
||||
export function listPlaylistsByUser(
|
||||
userId: string,
|
||||
requestingUserId: string | null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RichContent } from "../../model/interfaces.ts";
|
||||
import type { RichContentProvider } from "../rich-content-service.ts";
|
||||
import { fetchWithTimeout } from "../rich-content-service.ts";
|
||||
import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts";
|
||||
|
||||
function extractVideoId(url: string): string | null {
|
||||
try {
|
||||
@@ -24,38 +24,123 @@ function extractVideoId(url: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractPlaylistId(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).searchParams.get("list");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Matches /channel/UC…, /@handle, /c/name, /user/name */
|
||||
function extractChannelPath(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (u.hostname === "youtube.com" || u.hostname === "www.youtube.com") {
|
||||
if (
|
||||
u.pathname.startsWith("/channel/") ||
|
||||
u.pathname.startsWith("/@") ||
|
||||
u.pathname.startsWith("/c/") ||
|
||||
u.pathname.startsWith("/user/")
|
||||
) {
|
||||
return u.pathname;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// invalid URL
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchOEmbed(
|
||||
url: string,
|
||||
): Promise<{ title?: string; thumbnailUrl?: string }> {
|
||||
try {
|
||||
const oembedUrl =
|
||||
`https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
|
||||
const res = await fetchWithTimeout(oembedUrl);
|
||||
if (res.ok) {
|
||||
const data = await res.json() as {
|
||||
title?: string;
|
||||
thumbnail_url?: string;
|
||||
};
|
||||
return { title: data.title, thumbnailUrl: data.thumbnail_url };
|
||||
}
|
||||
} catch {
|
||||
// oembed failed — carry on
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export const youtubeProvider: RichContentProvider = {
|
||||
name: "youtube",
|
||||
|
||||
matches(url: string): boolean {
|
||||
return extractVideoId(url) !== null;
|
||||
return (
|
||||
extractVideoId(url) !== null ||
|
||||
extractPlaylistId(url) !== null ||
|
||||
extractChannelPath(url) !== null
|
||||
);
|
||||
},
|
||||
|
||||
async fetch(url: string): Promise<RichContent> {
|
||||
const videoId = extractVideoId(url)!;
|
||||
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||
let title: string | undefined;
|
||||
const videoId = extractVideoId(url);
|
||||
const listId = extractPlaylistId(url);
|
||||
const channelPath = extractChannelPath(url);
|
||||
|
||||
try {
|
||||
const oembedUrl =
|
||||
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
|
||||
const res = await fetchWithTimeout(oembedUrl);
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { title?: string };
|
||||
title = data.title;
|
||||
// ── Channel ───────────────────────────────────────────────────────────────
|
||||
if (channelPath && !videoId) {
|
||||
// oEmbed doesn't support channel URLs — scrape og:image from the page instead
|
||||
let title: string | undefined;
|
||||
let thumbnailUrl: string | undefined;
|
||||
try {
|
||||
const res = await fetchWithTimeout(url);
|
||||
if (res.ok) {
|
||||
const html = await res.text();
|
||||
title = extractOgTag(html, "title");
|
||||
thumbnailUrl = extractOgTag(html, "image");
|
||||
}
|
||||
} catch {
|
||||
// scrape failed — carry on with undefined values
|
||||
}
|
||||
} catch {
|
||||
// oembed failed — thumbnail still works
|
||||
return {
|
||||
type: "youtube",
|
||||
siteName: "YouTube",
|
||||
url,
|
||||
title,
|
||||
thumbnailUrl,
|
||||
// channels are not embeddable as a player
|
||||
};
|
||||
}
|
||||
|
||||
// ── Playlist (no specific video) ──────────────────────────────────────────
|
||||
if (listId && !videoId) {
|
||||
const { title, thumbnailUrl } = await fetchOEmbed(url);
|
||||
return {
|
||||
type: "youtube",
|
||||
siteName: "YouTube",
|
||||
url,
|
||||
title,
|
||||
thumbnailUrl,
|
||||
embedUrl: `https://www.youtube.com/embed/videoseries?list=${listId}&rel=0`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Video (with optional playlist context) ────────────────────────────────
|
||||
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||
const { title } = await fetchOEmbed(url);
|
||||
|
||||
const embedParams = new URLSearchParams({ rel: "0" });
|
||||
if (listId) embedParams.set("list", listId);
|
||||
|
||||
return {
|
||||
type: "youtube",
|
||||
siteName: "YouTube",
|
||||
url,
|
||||
videoId,
|
||||
videoId: videoId!,
|
||||
title,
|
||||
thumbnailUrl,
|
||||
embedUrl: `https://www.youtube.com/embed/${videoId}?rel=0`,
|
||||
embedUrl: `https://www.youtube.com/embed/${videoId}?${embedParams}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -102,6 +102,15 @@ export function searchUsers(
|
||||
}));
|
||||
}
|
||||
|
||||
export function searchUsersGlobal(query: string, limit: number): User[] {
|
||||
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);
|
||||
}
|
||||
|
||||
export function listUsers(): User[] {
|
||||
const userRows = db.prepare(
|
||||
`${USER_SELECT}`,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
const DB_FILE = "api/sql/gerbeur.db";
|
||||
import { DB_PATH } from "../config.ts";
|
||||
|
||||
try {
|
||||
await Deno.stat(DB_FILE);
|
||||
await Deno.stat(DB_PATH);
|
||||
console.log("Database already exists, skipping initialization.");
|
||||
} catch {
|
||||
console.log("Initializing database from schema...");
|
||||
const schema = Deno.readTextFileSync("api/sql/schema.sql");
|
||||
const db = new DatabaseSync(DB_FILE);
|
||||
const db = new DatabaseSync(DB_PATH);
|
||||
db.exec(schema);
|
||||
db.close();
|
||||
console.log("Database initialized.");
|
||||
|
||||
Reference in New Issue
Block a user