import express from "express"; import cors from "cors"; import bcrypt from "bcrypt"; import fileUpload from "express-fileupload"; import path from "path"; import childProcess from "child_process"; import levenshtein from "js-levenshtein"; import cookieParser from "cookie-parser"; import { fileURLToPath } from "url"; import sqlite3 from "sqlite3"; import fs from "fs/promises"; const PORT = 8000; let sessions = []; let videoQueue = []; const db = new sqlite3.Database("database.sqlite3"); function dbGet(query, ...parameters) { return new Promise((resolve, reject) => db.get(query, parameters, (err, data) => err ? reject(err) : resolve(data))); } function dbAll(query, ...parameters) { return new Promise((resolve, reject) => db.all(query, parameters, (err, data) => err ? reject(err) : resolve(data))); } function dbRun(query, ...parameters) { return new Promise((resolve, reject) => db.run(query, parameters, (err, data) => err ? reject(err) : resolve(data))); } function runCommand(cmd, args) { return new Promise(resolve => { const process = childProcess.spawn(cmd, args); let data = ""; let error = false; process.stdout.on("data", chunk => data += chunk); process.stderr.on("data", chunk => { error = true; data += chunk; }); process.on("close", () => resolve({ error, data })); }); } function videoPath(id) { return `videos/${id}.webm`; } function randomString(length) { const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789"; let result = ""; for (let i = 0; i < length; ++i) { result += chars[Math.floor(chars.length * Math.random())]; } return result; } function dirname() { return path.dirname(fileURLToPath(import.meta.url)); } function authorized() { return async (req, res, next) => { const token = (() => { if (req.cookies && "token" in req.cookies) { return req.cookies["token"]; } else if ("token" in req.query) { return req.query["token"]; } else if (req.method === "post" && "token" in req.body) { return req.body["token"]; } else { return null; } })(); if (token === null) { return res.status(400).json({ ok: false, error: "unauthorized" }); } const session = sessions.find(session => session.token === token); if (session === undefined) { return res.status(400).json({ ok: false, error: "unauthorized" }); } const user = await dbGet("SELECT * FROM users WHERE id = ?", session.userId); if (user === undefined) { throw new Error("error: session with invalid userId"); } req.user = user; next(); } } const app = express(); app.use(cors()); app.use(express.json()); app.use(cookieParser()); app.use(express.urlencoded({ extended: true })); app.use("/", express.static("public/")); app.use("/videos", express.static("videos/")); app.post("/api/register", async (req, res) => { const { username, password } = req.body; if (typeof username !== "string" || typeof password !== "string") { return res.status(400).json({ ok: false, error: "bad request" }); } const existingUser = await dbGet("SELECT * FROM users WHERE username = ?", username); if (existingUser !== undefined) { return res.status(400).json({ ok: false, error: "username taken" }); } const passwordHash = await bcrypt.hash(password, 10); await dbGet("INSERT INTO users (username, password) VALUES (?, ?)", username, passwordHash); return res.status(200).json({ ok: true }); }); app.post("/api/login", async (req, res) => { const { username, password } = req.body; if (typeof username !== "string" || typeof password !== "string") { return res.status(400).json({ ok: false, error: "bad request" }); } const user = await dbGet("SELECT * FROM users WHERE username = ?", username); if (user === undefined) { return res.status(400).json({ ok: false, error: "wrong username/password" }); } if (!await bcrypt.compare(password, user.password)) { return res.status(400).json({ ok: false, error: "wrong username/password" }); } sessions = sessions.filter(session => session.userId !== user.id); const token = randomString(64); const session = { userId: user.id, token }; sessions.push(session); res.clearCookie("token"); res.cookie("token", token); return res.status(200).json({ ok: true, session }); }); app.get("/api/search", async (req, res) => { const page = +req.query.page || 0; const search = req.query.query; if (!search) { return res.status(400).json({ ok: false, error: "bad request" }); } const [start, end] = [20 * page, 20]; const videos = await dbAll(` SELECT videos.*, users.username AS author FROM videos JOIN users ON users.id = videos.user_id WHERE title LIKE CONCAT('%', ?, '%') LIMIT ? OFFSET ? `, search, end, start); const { total } = await dbGet("SELECT COUNT(*) AS total FROM videos"); return res.status(200).json({ ok: true, videos, total }); }); app.get("/api/logout", authorized(), (req, res) => { sessions = sessions.filter(session => session.userId !== req.user.id); return res.status(200).json({ ok: true }); }); app.post("/api/upload-video", authorized(), fileUpload({ limits: { fileSize: 2 ** 26 }, useTempFiles: true }), async (req, res) => { const { title, description } = req.body; if (!title) { return res.status(400).json({ ok: false, error: "bad request" }); } if (!req.files || !req.files.video) { return res.status(400).json({ ok: false, error: "bad request" }); } if (req.files.video.mimetype !== "video/mp4") { return res.status(415).json({ ok: false, error: "bad mimetype" }); } const userId = req.user.id; const id = randomString(4); const tempPath = req.files.video.tempFilePath; const newPath = path.join(dirname(), videoPath(id)); const thumbnailPath = path.join(dirname(), "videos", `${id}.png`); const durationResult = await runCommand("ffprobe", ["-i", tempPath, "-show_format", "-loglevel", "error"]); if (durationResult.error) { return res.status(400).json({ ok: false, error: "invalid video file" }); } const duration = parseFloat(durationResult.data.match(/duration=([.\d]+)/)[1]) * 1_000_000;; const queueItem = { videoId: id, userId, title, errors: [], progress: 0, duration, }; videoQueue.push(queueItem); const progressPath = path.join(dirname(), "tmp", "progress", `${id}.txt`) const uploadProcess = childProcess.spawn("ffmpeg", ["-i", tempPath, "-b:v", "1M", "-b:a", "192k", "-progress", progressPath, "-loglevel", "error", newPath]); const thumbnailProcess = childProcess.spawn("ffmpeg", ["-i", tempPath, "-ss", "00:00:01.000", "-vframes", "1", "-loglevel", "error", thumbnailPath]); uploadProcess.stderr.on("data", data => queueItem.errors.push(data.toString())); thumbnailProcess.stderr.on("data", data => queueItem.errors.push(data.toString())); uploadProcess.on("close", () => { if (queueItem.errors.length) { return; } dbRun( "INSERT INTO videos (id, user_id, title, description, created_at) VALUES (?, ?, ?, ?, ?)", id, userId, title, description ?? "", new Date().toISOString() ); const index = videoQueue.indexOf(item => item.videoId === queueItem.videoId) videoQueue.splice(index, 1); }); return res.status(200).json({ ok: true }); }); app.get("/api/video-queue", authorized(), (req, res) => { const userId = req.user.id; const queue = videoQueue.filter(item => item.userId === userId); return res.status(200).json({ ok: true, queue }); }); app.get("/api/video-info", async (req, res) => { const id = req.query["id"]; const video = await dbGet(` SELECT videos.id, videos.title, videos.description, videos.created_at, users.username AS author FROM videos JOIN users ON users.id = videos.user_id WHERE videos.id = ? LIMIT 1 `, id); if (!video) { return res.status(404).json({ ok: false, error: "video not found" }); } video.path = path.join("/", videoPath(id)); return res.status(200).json({ ok: true, video }); }); app.use((err, req, res, next) => { console.error(err); res.status(500).json({ ok: false, error: "server error" }) }); async function main() { (async () => { const progressPath = path.join(dirname(), "tmp", "progress"); await fs.mkdir(progressPath, { recursive: true }); const watch = fs.watch(progressPath, { recursive: true }); for await (const event of watch) { const videoId = event.filename.replace(".txt", ""); const queueItem = videoQueue.find(item => item.videoId === videoId); if (!queueItem) { continue; } fs.readFile(path.join(progressPath, event.filename), "utf-8") .then(string => { const index = string.lastIndexOf("out_time_ms="); if (index === -1) return; const length = parseInt(string.slice(index).match(/out_time_ms=(\d+)/)[1]); for (const i in videoQueue) { if (videoQueue[i].videoId !== videoId) continue; videoQueue[i].progress = length; } }); } })(); app.listen(PORT, () => { console.log("app at http://localhost:8000/"); }); } main();