2024-01-18 23:45:23 +00:00
|
|
|
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";
|
2024-01-19 02:10:56 +00:00
|
|
|
import levenshtein from "js-levenshtein";
|
2024-01-19 02:44:55 +00:00
|
|
|
import cookieParser from "cookie-parser";
|
2024-02-10 22:30:03 +00:00
|
|
|
import { fileURLToPath } from "url";
|
|
|
|
import sqlite3 from "sqlite3";
|
2024-02-12 18:09:23 +00:00
|
|
|
import fs from "fs/promises";
|
|
|
|
|
|
|
|
const PORT = 8000;
|
2024-01-18 23:45:23 +00:00
|
|
|
|
|
|
|
let sessions = [];
|
2024-02-12 17:11:57 +00:00
|
|
|
let videoQueue = [];
|
2024-02-10 22:30:03 +00:00
|
|
|
|
|
|
|
const db = new sqlite3.Database("database.sqlite3");
|
|
|
|
|
|
|
|
function dbGet(query, ...parameters) {
|
2024-02-12 15:02:00 +00:00
|
|
|
return new Promise((resolve, reject) => db.get(query, parameters, (err, data) => err ? reject(err) : resolve(data)));
|
2024-02-10 22:30:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
function dbAll(query, ...parameters) {
|
2024-02-12 15:02:00 +00:00
|
|
|
return new Promise((resolve, reject) => db.all(query, parameters, (err, data) => err ? reject(err) : resolve(data)));
|
2024-02-10 22:30:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
function dbRun(query, ...parameters) {
|
2024-02-12 15:02:00 +00:00
|
|
|
return new Promise((resolve, reject) => db.run(query, parameters, (err, data) => err ? reject(err) : resolve(data)));
|
|
|
|
}
|
|
|
|
|
2024-02-12 17:11:57 +00:00
|
|
|
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 }));
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2024-02-12 15:02:00 +00:00
|
|
|
function videoPath(id) {
|
|
|
|
return `videos/${id}.webm`;
|
2024-02-10 22:30:03 +00:00
|
|
|
}
|
2024-01-18 23:45:23 +00:00
|
|
|
|
|
|
|
function randomString(length) {
|
|
|
|
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789";
|
|
|
|
let result = "";
|
|
|
|
for (let i = 0; i < length; ++i) {
|
2024-01-19 02:44:55 +00:00
|
|
|
result += chars[Math.floor(chars.length * Math.random())];
|
2024-01-18 23:45:23 +00:00
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
2024-01-19 02:44:55 +00:00
|
|
|
function dirname() {
|
|
|
|
return path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
}
|
|
|
|
|
2024-02-12 18:09:23 +00:00
|
|
|
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();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-01-18 23:45:23 +00:00
|
|
|
const app = express();
|
|
|
|
app.use(cors());
|
|
|
|
app.use(express.json());
|
2024-01-19 02:44:55 +00:00
|
|
|
app.use(cookieParser());
|
2024-01-18 23:45:23 +00:00
|
|
|
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;
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-18 23:45:23 +00:00
|
|
|
if (typeof username !== "string" || typeof password !== "string") {
|
|
|
|
return res.status(400).json({ ok: false, error: "bad request" });
|
|
|
|
}
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-02-12 15:02:00 +00:00
|
|
|
const existingUser = await dbGet("SELECT * FROM users WHERE username = ?", username);
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-19 02:10:56 +00:00
|
|
|
if (existingUser !== undefined) {
|
2024-01-18 23:45:23 +00:00
|
|
|
return res.status(400).json({ ok: false, error: "username taken" });
|
|
|
|
}
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-18 23:45:23 +00:00
|
|
|
const passwordHash = await bcrypt.hash(password, 10);
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-02-12 15:02:00 +00:00
|
|
|
await dbGet("INSERT INTO users (username, password) VALUES (?, ?)", username, passwordHash);
|
2024-02-10 22:30:03 +00:00
|
|
|
|
|
|
|
return res.status(200).json({ ok: true });
|
2024-01-18 23:45:23 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
app.post("/api/login", async (req, res) => {
|
|
|
|
const { username, password } = req.body;
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-18 23:45:23 +00:00
|
|
|
if (typeof username !== "string" || typeof password !== "string") {
|
|
|
|
return res.status(400).json({ ok: false, error: "bad request" });
|
|
|
|
}
|
2024-02-10 22:30:03 +00:00
|
|
|
|
|
|
|
const user = await dbGet("SELECT * FROM users WHERE username = ?", username);
|
|
|
|
|
2024-01-18 23:45:23 +00:00
|
|
|
if (user === undefined) {
|
|
|
|
return res.status(400).json({ ok: false, error: "wrong username/password" });
|
|
|
|
}
|
2024-02-10 22:30:03 +00:00
|
|
|
|
|
|
|
if (!await bcrypt.compare(password, user.password)) {
|
2024-01-18 23:45:23 +00:00
|
|
|
return res.status(400).json({ ok: false, error: "wrong username/password" });
|
|
|
|
}
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-18 23:45:23 +00:00
|
|
|
sessions = sessions.filter(session => session.userId !== user.id);
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-18 23:45:23 +00:00
|
|
|
const token = randomString(64);
|
|
|
|
const session = { userId: user.id, token };
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-18 23:45:23 +00:00
|
|
|
sessions.push(session);
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-18 23:45:23 +00:00
|
|
|
res.clearCookie("token");
|
|
|
|
res.cookie("token", token);
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-18 23:45:23 +00:00
|
|
|
return res.status(200).json({ ok: true, session });
|
|
|
|
});
|
|
|
|
|
2024-01-19 02:10:56 +00:00
|
|
|
|
|
|
|
app.get("/api/search", async (req, res) => {
|
|
|
|
const page = +req.query.page || 0;
|
|
|
|
const search = req.query.query;
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-19 02:10:56 +00:00
|
|
|
if (!search) {
|
|
|
|
return res.status(400).json({ ok: false, error: "bad request" });
|
|
|
|
}
|
2024-02-10 22:30:03 +00:00
|
|
|
|
|
|
|
const [start, end] = [20 * page, 20];
|
2024-02-12 15:02:00 +00:00
|
|
|
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);
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-02-12 15:02:00 +00:00
|
|
|
const { total } = await dbGet("SELECT COUNT(*) AS total FROM videos");
|
2024-02-10 22:30:03 +00:00
|
|
|
|
|
|
|
return res.status(200).json({ ok: true, videos, total });
|
2024-01-19 02:10:56 +00:00
|
|
|
});
|
|
|
|
|
2024-01-18 23:45:23 +00:00
|
|
|
app.get("/api/logout", authorized(), (req, res) => {
|
|
|
|
sessions = sessions.filter(session => session.userId !== req.user.id);
|
|
|
|
return res.status(200).json({ ok: true });
|
|
|
|
});
|
|
|
|
|
2024-02-12 15:02:00 +00:00
|
|
|
app.post("/api/upload-video", authorized(), fileUpload({ limits: { fileSize: 2 ** 26 }, useTempFiles: true }), async (req, res) => {
|
2024-02-12 23:05:22 +00:00
|
|
|
const { title, description } = req.body;
|
2024-02-12 23:07:28 +00:00
|
|
|
if (!title) {
|
|
|
|
return res.status(400).json({ ok: false, error: "bad request" });
|
|
|
|
}
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-19 02:44:55 +00:00
|
|
|
if (!req.files || !req.files.video) {
|
2024-01-18 23:45:23 +00:00
|
|
|
return res.status(400).json({ ok: false, error: "bad request" });
|
|
|
|
}
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-19 02:44:55 +00:00
|
|
|
if (req.files.video.mimetype !== "video/mp4") {
|
2024-02-12 17:11:57 +00:00
|
|
|
return res.status(415).json({ ok: false, error: "bad mimetype" });
|
2024-01-18 23:45:23 +00:00
|
|
|
}
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-01-18 23:45:23 +00:00
|
|
|
const userId = req.user.id;
|
|
|
|
const id = randomString(4);
|
2024-01-19 02:44:55 +00:00
|
|
|
const tempPath = req.files.video.tempFilePath;
|
2024-02-12 15:02:00 +00:00
|
|
|
const newPath = path.join(dirname(), videoPath(id));
|
2024-02-10 23:02:33 +00:00
|
|
|
const thumbnailPath = path.join(dirname(), "videos", `${id}.png`);
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-02-12 17:11:57 +00:00
|
|
|
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" });
|
|
|
|
}
|
2024-01-18 23:45:23 +00:00
|
|
|
|
2024-02-12 18:09:23 +00:00
|
|
|
const duration = parseFloat(durationResult.data.match(/duration=([.\d]+)/)[1]) * 1_000_000;;
|
2024-02-10 22:30:03 +00:00
|
|
|
|
2024-02-12 17:11:57 +00:00
|
|
|
const queueItem = {
|
|
|
|
videoId: id,
|
|
|
|
userId,
|
2024-02-12 23:07:28 +00:00
|
|
|
title,
|
2024-02-12 17:11:57 +00:00
|
|
|
errors: [],
|
|
|
|
progress: 0,
|
|
|
|
duration,
|
|
|
|
};
|
2024-01-18 23:45:23 +00:00
|
|
|
|
2024-02-12 17:11:57 +00:00
|
|
|
videoQueue.push(queueItem);
|
2024-02-10 23:02:33 +00:00
|
|
|
|
2024-02-12 18:09:23 +00:00
|
|
|
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]);
|
2024-02-12 17:11:57 +00:00
|
|
|
const thumbnailProcess = childProcess.spawn("ffmpeg", ["-i", tempPath, "-ss", "00:00:01.000", "-vframes", "1", "-loglevel", "error", thumbnailPath]);
|
2024-02-10 23:02:33 +00:00
|
|
|
|
2024-02-12 17:11:57 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2024-02-12 23:05:22 +00:00
|
|
|
dbRun(
|
|
|
|
"INSERT INTO videos (id, user_id, title, description, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
|
|
id, userId, title, description ?? "", new Date().toISOString()
|
|
|
|
);
|
2024-02-12 17:11:57 +00:00
|
|
|
|
|
|
|
const index = videoQueue.indexOf(item => item.videoId === queueItem.videoId)
|
|
|
|
videoQueue.splice(index, 1);
|
|
|
|
});
|
2024-02-10 22:30:03 +00:00
|
|
|
|
|
|
|
return res.status(200).json({ ok: true });
|
2024-02-12 15:02:00 +00:00
|
|
|
});
|
|
|
|
|
2024-02-12 17:11:57 +00:00
|
|
|
app.get("/api/video-queue", authorized(), (req, res) => {
|
|
|
|
const userId = req.user.id;
|
|
|
|
|
2024-02-12 18:37:02 +00:00
|
|
|
const queue = videoQueue.filter(item => item.userId === userId);
|
2024-02-12 17:11:57 +00:00
|
|
|
|
2024-02-12 18:37:02 +00:00
|
|
|
return res.status(200).json({ ok: true, queue });
|
2024-02-12 17:11:57 +00:00
|
|
|
});
|
|
|
|
|
2024-02-12 15:02:00 +00:00
|
|
|
app.get("/api/video-info", async (req, res) => {
|
|
|
|
const id = req.query["id"];
|
|
|
|
|
|
|
|
const video = await dbGet(`
|
2024-02-12 23:05:22 +00:00
|
|
|
SELECT videos.id, videos.title, videos.description, videos.created_at, users.username AS author
|
2024-02-12 15:02:00 +00:00
|
|
|
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 });
|
|
|
|
});
|
2024-01-18 23:45:23 +00:00
|
|
|
|
|
|
|
app.use((err, req, res, next) => {
|
|
|
|
console.error(err);
|
|
|
|
res.status(500).json({ ok: false, error: "server error" })
|
|
|
|
});
|
|
|
|
|
2024-02-12 18:09:23 +00:00
|
|
|
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();
|
|
|
|
|