patx/youtube-clone

import { unlink } from 'node:fs/promises';
import { join } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { get, run, all } from './db.js';
import {
  CATEGORIES, publicUser, findVideo, listVideos, countVideos, likedVideos, historyVideos,
  watchLaterVideos, relatedVideos, videoComments, findComment, recordView, isSubscribed,
} from './model.js';
import {
  hashPassword, verifyPassword, createSession, destroySession, sessionCookie, clearCookie,
} from './auth.js';
import { HttpError, bad, notFound, forbidden, sendJson, readJson, field, intParam } from './http.js';
import { parseMultipart } from './multipart.js';
import { VIDEO_DIR, THUMB_DIR } from './paths.js';

const execFileAsync = promisify(execFile);
const VIDEO_TYPES = /^video\/(mp4|webm|quicktime|x-m4v|ogg)$/i;
const MAX_UPLOAD = 512 * 1024 * 1024;

/* ------------------------------------------------------------------ auth -- */

const authRoutes = {
  'POST /api/auth/signup': async (ctx) => {
    const body = await readJson(ctx.req);
    const username = field(body, 'username', { max: 24, label: 'Username' }).replace(/^@/, '');
    const email = field(body, 'email', { max: 160, label: 'Email' });
    const password = field(body, 'password', { min: 8, max: 200, label: 'Password' });
    const displayName = (typeof body.displayName === 'string' && body.displayName.trim()) || username;

    if (!/^[a-zA-Z0-9_.]{3,24}$/.test(username)) {
      bad('Username must be 3–24 characters: letters, numbers, underscore or dot.');
    }
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) bad('Enter a valid email address.');
    if (get('SELECT 1 AS x FROM users WHERE username = ?', username)) bad('That username is already taken.');
    if (get('SELECT 1 AS x FROM users WHERE email = ?', email)) bad('An account already uses that email.');

    const hue = Math.floor(Math.random() * 360);
    const { lastInsertRowid } = run(
      `INSERT INTO users (username, email, password_hash, display_name, avatar_hue)
       VALUES (?, ?, ?, ?, ?)`,
      username, email, hashPassword(password), displayName.slice(0, 40), hue
    );
    const token = createSession(Number(lastInsertRowid));
    const user = get('SELECT * FROM users WHERE id = ?', Number(lastInsertRowid));
    sendJson(ctx.res, 201, { user: publicUser(user, user.id) }, { 'Set-Cookie': sessionCookie(token) });
  },

  'POST /api/auth/login': async (ctx) => {
    const body = await readJson(ctx.req);
    const identifier = field(body, 'identifier', { max: 160, label: 'Username or email' }).replace(/^@/, '');
    const password = field(body, 'password', { max: 200, label: 'Password' });

    const user = get('SELECT * FROM users WHERE username = ? OR email = ?', identifier, identifier);
    if (!user || !verifyPassword(password, user.password_hash)) {
      throw new HttpError(401, 'That username/email and password combination did not match.');
    }
    const token = createSession(user.id);
    sendJson(ctx.res, 200, { user: publicUser(user, user.id) }, { 'Set-Cookie': sessionCookie(token) });
  },

  'POST /api/auth/logout': (ctx) => {
    destroySession(ctx.token);
    sendJson(ctx.res, 200, { ok: true }, { 'Set-Cookie': clearCookie() });
  },

  'GET /api/auth/me': (ctx) => {
    sendJson(ctx.res, 200, { user: ctx.user ? publicUser(ctx.user, ctx.user.id) : null });
  },

  'PATCH /api/me': async (ctx) => {
    const me = ctx.requireUser();
    const body = await readJson(ctx.req);
    if (typeof body.displayName === 'string') {
      const displayName = field(body, 'displayName', { max: 40, label: 'Display name' });
      run('UPDATE users SET display_name = ? WHERE id = ?', displayName, me.id);
    }
    if (typeof body.bio === 'string') {
      run('UPDATE users SET bio = ? WHERE id = ?', body.bio.trim().slice(0, 600), me.id);
    }
    if (Number.isInteger(body.avatarHue)) {
      run('UPDATE users SET avatar_hue = ? WHERE id = ?', ((body.avatarHue % 360) + 360) % 360, me.id);
    }
    const fresh = get('SELECT * FROM users WHERE id = ?', me.id);
    sendJson(ctx.res, 200, { user: publicUser(fresh, me.id) });
  },
};

/* ---------------------------------------------------------------- videos -- */

const videoRoutes = {
  'GET /api/categories': (ctx) => sendJson(ctx.res, 200, { categories: CATEGORIES }),

  'GET /api/videos': (ctx) => {
    const viewerId = ctx.user?.id ?? null;
    const q = ctx.query.get('q')?.trim() || null;
    const category = ctx.query.get('category') || null;
    const sort = ctx.query.get('sort') || (q ? 'popular' : 'new');
    const limit = intParam(ctx.query.get('limit'), 24, { min: 1, max: 60 });
    const offset = intParam(ctx.query.get('offset'), 0);
    const username = ctx.query.get('channel');
    const channelId = username
      ? get('SELECT id FROM users WHERE username = ?', username.replace(/^@/, ''))?.id ?? -1
      : null;
    const subscribedBy = ctx.query.get('feed') === 'subscriptions' ? ctx.requireUser().id : null;

    const filters = { channelId, category, q, subscribedBy };
    sendJson(ctx.res, 200, {
      videos: listVideos({ ...filters, viewerId, sort, limit, offset }),
      total: countVideos(filters),
    });
  },

  'GET /api/videos/:id': (ctx) => {
    const viewerId = ctx.user?.id ?? null;
    const video = findVideo(ctx.params.id, viewerId);
    if (!video) notFound('That video does not exist.');
    sendJson(ctx.res, 200, { video, related: relatedVideos(video, viewerId) });
  },

  'POST /api/videos/:id/view': (ctx) => {
    const video = findVideo(ctx.params.id, ctx.user?.id ?? null);
    if (!video) notFound('That video does not exist.');
    recordView(video.id, ctx.user?.id ?? null);
    sendJson(ctx.res, 200, { views: video.views + 1 });
  },

  'POST /api/videos/:id/like': async (ctx) => {
    const me = ctx.requireUser();
    const body = await readJson(ctx.req);
    const value = Number(body.value);
    if (![1, -1, 0].includes(value)) bad('Vote must be 1, -1 or 0.');
    const video = findVideo(ctx.params.id, me.id);
    if (!video) notFound('That video does not exist.');

    if (value === 0) {
      run('DELETE FROM video_likes WHERE video_id = ? AND user_id = ?', video.id, me.id);
    } else {
      run(
        `INSERT INTO video_likes (video_id, user_id, value) VALUES (?, ?, ?)
         ON CONFLICT(video_id, user_id) DO UPDATE SET value = excluded.value, created_at = datetime('now')`,
        video.id, me.id, value
      );
    }
    const fresh = findVideo(video.id, me.id);
    sendJson(ctx.res, 200, { likes: fresh.likes, dislikes: fresh.dislikes, myVote: fresh.myVote });
  },

  'POST /api/videos/:id/save': (ctx) => {
    const me = ctx.requireUser();
    const video = findVideo(ctx.params.id, me.id);
    if (!video) notFound('That video does not exist.');
    if (video.saved) {
      run('DELETE FROM watch_later WHERE user_id = ? AND video_id = ?', me.id, video.id);
      sendJson(ctx.res, 200, { saved: false });
    } else {
      run('INSERT OR IGNORE INTO watch_later (user_id, video_id) VALUES (?, ?)', me.id, video.id);
      sendJson(ctx.res, 200, { saved: true });
    }
  },

  'PATCH /api/videos/:id': async (ctx) => {
    const me = ctx.requireUser();
    const row = get('SELECT * FROM videos WHERE id = ?', ctx.params.id);
    if (!row) notFound('That video does not exist.');
    if (row.user_id !== me.id) forbidden('You can only edit your own videos.');
    const body = await readJson(ctx.req);
    if (typeof body.title === 'string') {
      run('UPDATE videos SET title = ? WHERE id = ?', field(body, 'title', { max: 120, label: 'Title' }), row.id);
    }
    if (typeof body.description === 'string') {
      run('UPDATE videos SET description = ? WHERE id = ?', body.description.trim().slice(0, 5000), row.id);
    }
    if (typeof body.category === 'string' && CATEGORIES.includes(body.category)) {
      run('UPDATE videos SET category = ? WHERE id = ?', body.category, row.id);
    }
    sendJson(ctx.res, 200, { video: findVideo(row.id, me.id) });
  },

  'DELETE /api/videos/:id': async (ctx) => {
    const me = ctx.requireUser();
    const row = get('SELECT * FROM videos WHERE id = ?', ctx.params.id);
    if (!row) notFound('That video does not exist.');
    if (row.user_id !== me.id) forbidden('You can only delete your own videos.');
    run('DELETE FROM videos WHERE id = ?', row.id);
    for (const [dir, name] of [[VIDEO_DIR, row.src], [THUMB_DIR, row.thumb]]) {
      const file = name?.split('/').pop();
      if (file) await unlink(join(dir, file)).catch(() => {});
    }
    sendJson(ctx.res, 200, { ok: true });
  },

  'POST /api/videos': async (ctx) => {
    const me = ctx.requireUser();
    const { fields, files, cleanup } = await parseMultipart(ctx.req, {
      dir: VIDEO_DIR,
      maxFileBytes: MAX_UPLOAD,
    });
    try {
      const video = files.find((f) => f.field === 'video');
      if (!video) bad('Choose a video file to upload.');
      if (!VIDEO_TYPES.test(video.contentType) && !/\.(mp4|webm|mov|m4v|ogg)$/i.test(video.filename)) {
        bad('Unsupported format — upload an MP4, WebM, MOV or OGG file.');
      }
      if (!video.size) bad('That file was empty.');

      const title = field(fields, 'title', { max: 120, label: 'Title' });
      const description = (fields.description ?? '').trim().slice(0, 5000);
      const category = CATEGORIES.includes(fields.category) && fields.category !== 'All'
        ? fields.category
        : 'General';

      const src = `/media/videos/${video.storedName}`;
      const duration = await probeDuration(video.path);
      const thumb = await makeThumbnail(video.path, video.storedName, duration);

      const { lastInsertRowid } = run(
        `INSERT INTO videos (user_id, title, description, category, src, thumb, duration)
         VALUES (?, ?, ?, ?, ?, ?, ?)`,
        me.id, title, description, category, src, thumb, duration
      );
      sendJson(ctx.res, 201, { video: findVideo(Number(lastInsertRowid), me.id) });
    } catch (err) {
      await cleanup();
      throw err;
    }
  },
};

/* -------------------------------------------------------------- comments -- */

const commentRoutes = {
  'GET /api/videos/:id/comments': (ctx) => {
    if (!get('SELECT 1 AS x FROM videos WHERE id = ?', ctx.params.id)) notFound('That video does not exist.');
    const sort = ctx.query.get('sort') === 'top' ? 'top' : 'new';
    sendJson(ctx.res, 200, { comments: videoComments(Number(ctx.params.id), ctx.user?.id ?? null, sort) });
  },

  'POST /api/videos/:id/comments': async (ctx) => {
    const me = ctx.requireUser();
    if (!get('SELECT 1 AS x FROM videos WHERE id = ?', ctx.params.id)) notFound('That video does not exist.');
    const body = await readJson(ctx.req);
    const text = field(body, 'body', { max: 2000, label: 'Comment' });

    let parentId = null;
    if (body.parentId != null) {
      const parent = get('SELECT * FROM comments WHERE id = ?', body.parentId);
      if (!parent || parent.video_id !== Number(ctx.params.id)) bad('That comment no longer exists.');
      parentId = parent.parent_id ?? parent.id; // keep replies one level deep
    }
    const { lastInsertRowid } = run(
      'INSERT INTO comments (video_id, user_id, parent_id, body) VALUES (?, ?, ?, ?)',
      Number(ctx.params.id), me.id, parentId, text
    );
    sendJson(ctx.res, 201, { comment: findComment(Number(lastInsertRowid), me.id) });
  },

  'POST /api/comments/:id/like': async (ctx) => {
    const me = ctx.requireUser();
    const body = await readJson(ctx.req);
    const value = Number(body.value);
    if (![1, 0].includes(value)) bad('Vote must be 1 or 0.');
    if (!get('SELECT 1 AS x FROM comments WHERE id = ?', ctx.params.id)) notFound('That comment no longer exists.');
    if (value === 0) {
      run('DELETE FROM comment_likes WHERE comment_id = ? AND user_id = ?', ctx.params.id, me.id);
    } else {
      run(
        `INSERT INTO comment_likes (comment_id, user_id, value) VALUES (?, ?, 1)
         ON CONFLICT(comment_id, user_id) DO UPDATE SET value = 1`,
        ctx.params.id, me.id
      );
    }
    const fresh = findComment(Number(ctx.params.id), me.id);
    sendJson(ctx.res, 200, { likes: fresh.likes, myVote: fresh.myVote });
  },

  'DELETE /api/comments/:id': (ctx) => {
    const me = ctx.requireUser();
    const row = get('SELECT * FROM comments WHERE id = ?', ctx.params.id);
    if (!row) notFound('That comment no longer exists.');
    const owner = get('SELECT user_id FROM videos WHERE id = ?', row.video_id)?.user_id;
    if (row.user_id !== me.id && owner !== me.id) forbidden('You can only delete your own comments.');
    run('DELETE FROM comments WHERE id = ?', row.id);
    sendJson(ctx.res, 200, { ok: true });
  },
};

/* -------------------------------------------------------------- channels -- */

const channelRoutes = {
  'GET /api/channels/:username': (ctx) => {
    const viewerId = ctx.user?.id ?? null;
    const username = String(ctx.params.username).replace(/^@/, '');
    const row = get('SELECT * FROM users WHERE username = ?', username);
    if (!row) notFound('That channel does not exist.');
    const sort = ctx.query.get('sort') || 'new';
    sendJson(ctx.res, 200, {
      channel: publicUser(row, viewerId),
      videos: listVideos({ viewerId, channelId: row.id, sort, limit: 60 }),
      totalViews: get('SELECT COALESCE(SUM(views), 0) AS n FROM videos WHERE user_id = ?', row.id).n,
    });
  },

  'POST /api/channels/:username/subscribe': (ctx) => {
    const me = ctx.requireUser();
    const username = String(ctx.params.username).replace(/^@/, '');
    const channel = get('SELECT * FROM users WHERE username = ?', username);
    if (!channel) notFound('That channel does not exist.');
    if (channel.id === me.id) bad('You cannot subscribe to your own channel.');

    const already = isSubscribed(channel.id, me.id);
    if (already) {
      run('DELETE FROM subscriptions WHERE channel_id = ? AND subscriber_id = ?', channel.id, me.id);
    } else {
      run('INSERT OR IGNORE INTO subscriptions (channel_id, subscriber_id) VALUES (?, ?)', channel.id, me.id);
    }
    sendJson(ctx.res, 200, {
      isSubscribed: !already,
      subscriberCount: get('SELECT COUNT(*) AS n FROM subscriptions WHERE channel_id = ?', channel.id).n,
    });
  },

  'GET /api/subscriptions': (ctx) => {
    const me = ctx.requireUser();
    const rows = all(
      `SELECT u.* FROM subscriptions s JOIN users u ON u.id = s.channel_id
        WHERE s.subscriber_id = ? ORDER BY s.created_at DESC`,
      me.id
    );
    sendJson(ctx.res, 200, { channels: rows.map((r) => publicUser(r, me.id)) });
  },

  'GET /api/channels': (ctx) => {
    const viewerId = ctx.user?.id ?? null;
    const rows = all(
      `SELECT u.*, (SELECT COUNT(*) FROM subscriptions s WHERE s.channel_id = u.id) AS subs
         FROM users u
        WHERE (SELECT COUNT(*) FROM videos v WHERE v.user_id = u.id) > 0
        ORDER BY subs DESC, u.display_name ASC LIMIT 40`
    );
    sendJson(ctx.res, 200, { channels: rows.map((r) => publicUser(r, viewerId)) });
  },
};

/* ------------------------------------------------------------- libraries -- */

const libraryRoutes = {
  'GET /api/library/liked': (ctx) => sendJson(ctx.res, 200, { videos: likedVideos(ctx.requireUser().id) }),
  'GET /api/library/history': (ctx) => sendJson(ctx.res, 200, { videos: historyVideos(ctx.requireUser().id) }),
  'GET /api/library/saved': (ctx) => sendJson(ctx.res, 200, { videos: watchLaterVideos(ctx.requireUser().id) }),
  'DELETE /api/library/history': (ctx) => {
    run('DELETE FROM watch_history WHERE user_id = ?', ctx.requireUser().id);
    sendJson(ctx.res, 200, { ok: true });
  },
};

/* ----------------------------------------------------------------- ffmpeg -- */

async function probeDuration(path) {
  try {
    const { stdout } = await execFileAsync('ffprobe', [
      '-v', 'error', '-show_entries', 'format=duration',
      '-of', 'default=noprint_wrappers=1:nokey=1', path,
    ]);
    const seconds = Number.parseFloat(stdout.trim());
    return Number.isFinite(seconds) ? Math.round(seconds * 10) / 10 : 0;
  } catch {
    return 0;
  }
}

async function makeThumbnail(videoPath, storedName, duration) {
  const name = `${storedName.replace(/\.[^.]+$/, '')}.jpg`;
  const at = duration > 2 ? Math.min(duration / 3, 10) : 0;
  try {
    await execFileAsync('ffmpeg', [
      '-y', '-loglevel', 'error', '-ss', String(at), '-i', videoPath,
      '-frames:v', '1', '-vf', 'scale=640:-2', '-q:v', '4',
      join(THUMB_DIR, name),
    ]);
    return `/media/thumbs/${name}`;
  } catch {
    return '';
  }
}

export const routes = {
  ...authRoutes, ...videoRoutes, ...commentRoutes, ...channelRoutes, ...libraryRoutes,
};