Video support, comment section, more

This commit is contained in:
2026-10-30 00:09:58 +01:00
parent c8789214bd
commit f8dcc01a4a
21 changed files with 3154 additions and 249 deletions
+163 -1
View File
@@ -15,6 +15,7 @@ Config :: struct {
listen_on := "127.0.0.1";
db_path := "taskforge.db";
web_dir := "web";
blob_dir := ""; // Defaults to "blobs" next to the database.
threads: s32 = 8;
demo: bool;
}
@@ -40,6 +41,7 @@ main :: () {
if a == "--listen" && has_value { config.listen_on = args[i + 1]; i += 2; continue; }
if a == "--db" && has_value { config.db_path = args[i + 1]; i += 2; continue; }
if a == "--web" && has_value { config.web_dir = args[i + 1]; i += 2; continue; }
if a == "--blobs" && has_value { config.blob_dir = args[i + 1]; i += 2; continue; }
if a == "--threads" && has_value { config.threads = cast(s32) string_to_int(args[i + 1]); i += 2; continue; }
if a == "--demo" { config.demo = true; i += 1; continue; }
if a == "--admin" { admin_flag = true; i += 1; continue; }
@@ -53,6 +55,13 @@ main :: () {
i += 1;
}
if !config.blob_dir.count {
dir := path_strip_filename(config.db_path);
// Heap copy: temporary storage is reset between requests, and this must live for the whole run.
config.blob_dir = ifx dir.count then sprint("%blobs", dir) else "blobs";
}
make_directory_if_it_does_not_exist(config.blob_dir);
if !load_sqlite() exit(1);
db, ok := db_open(config.db_path);
if !ok exit(1);
@@ -132,6 +141,12 @@ CREATE TABLE IF NOT EXISTS login_failures (
at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS login_failures_by_name ON login_failures(username, at);
CREATE TABLE IF NOT EXISTS blobs (
hash TEXT PRIMARY KEY,
size INTEGER NOT NULL,
created INTEGER NOT NULL,
user_id INTEGER
);
SQL
migrate :: (db: Db) -> bool {
@@ -370,8 +385,13 @@ Request :: struct {
path: string;
authorization: string;
body: string;
// Streamed uploads: the body isn't read up front; `body` holds what arrived with the headers.
streamed: bool;
content_length: s64;
}
MAX_UPLOAD :: 600 * 1024 * 1024;
handle_connection :: (db: Db, s: Socket) {
request, ok := read_request(s);
if !ok {
@@ -379,6 +399,14 @@ handle_connection :: (db: Db, s: Socket) {
return;
}
if request.method == "GET" && begins_with(request.path, "/api/blob/") {
handle_blob_download(db, s, request);
return;
}
if request.method == "POST" && request.path == "/api/blob" {
handle_blob_upload(db, s, request);
return;
}
if request.method == "GET" {
if request.path == "/api/info" {
respond(s, 200, "text/plain", "taskforge-server 1\n");
@@ -445,10 +473,17 @@ read_request :: (s: Socket) -> Request, bool {
if name == "content-length" content_length = string_to_int(value);
if name == "authorization" request.authorization = value;
}
have := min(buffer.count - header_end, content_length);
if request.method == "POST" && request.path == "/api/blob" {
if content_length < 0 || content_length > MAX_UPLOAD return request, false;
request.streamed = true;
request.content_length = content_length;
request.body = substring(string.{buffer.count, buffer.data}, header_end, header_end + have);
return request, true;
}
if content_length < 0 || content_length > MAX_BODY return request, false;
body := alloc_string(content_length,, temp);
have := min(buffer.count - header_end, content_length);
memcpy(body.data, buffer.data + header_end, have);
while have < content_length {
n := recv(s, body.data + have, xx min(content_length - have, 1 << 20), 0);
@@ -467,6 +502,8 @@ respond :: (s: Socket, status: int, content_type: string, body: string, extra_he
case 403; reason = "Forbidden";
case 404; reason = "Not Found";
case 405; reason = "Method Not Allowed";
case 413; reason = "Payload Too Large";
case 415; reason = "Unsupported Media Type";
case 429; reason = "Too Many Requests";
case 500; reason = "Internal Server Error";
}
@@ -723,6 +760,129 @@ handle_users :: (db: Db, s: Socket, request: Request) {
else respond(s, 200, "text/plain", "ok\n");
}
//
// Attachments: content-addressed blobs (images, videos) stored as files, named by their SHA-256.
//
blob_path :: (hash: string) -> string {
return tprint("%/%/%", config.blob_dir, substring(hash, 0, 2), hash);
}
valid_hash :: (hash: string) -> bool {
if hash.count != 64 return false;
for i: 0..63 {
c := hash[i];
if !((c >= #char "0" && c <= #char "9") || (c >= #char "a" && c <= #char "f")) return false;
}
return true;
}
// Only images and videos may be stored.
allowed_media :: (head: string) -> bool {
starts :: (data: string, offset: int, magic: string) -> bool {
if data.count < offset + magic.count return false;
return memcmp(data.data + offset, magic.data, magic.count) == 0;
}
return starts(head, 0, "\x89PNG") || starts(head, 0, "\xFF\xD8\xFF") || starts(head, 0, "GIF8") || starts(head, 0, "BM") ||
(starts(head, 0, "RIFF") && (starts(head, 8, "WEBP") || starts(head, 8, "AVI "))) ||
starts(head, 4, "ftyp") || starts(head, 0, "\x1A\x45\xDF\xA3");
}
handle_blob_upload :: (db: Db, s: Socket, request: Request) {
session, authed := authenticate(db, request);
if !authed {
respond(s, 401, "text/plain", "error \"Please sign in again.\"\n");
return;
}
total := request.content_length;
// Stream to a temp file while hashing, so large videos never sit in memory.
token: [8] u8;
random_bytes(token.data, 8);
temp_path := tprint("%/upload-%.tmp", config.blob_dir, to_hex(token.data, 8));
file, ok := file_open(temp_path, for_writing = true);
if !ok {
respond(s, 500, "text/plain", "error \"Could not store the file.\"\n");
return;
}
hasher: Sha256;
sha256_init(*hasher);
head: [16] u8;
head_count := 0;
received := 0;
failed := false;
consume :: (data: *u8, count: int) #expand {
sha256_update(*hasher, data, count);
take := min(count, head.count - head_count);
if take > 0 { memcpy(head.data + head_count, data, take); head_count += take; }
if !file_write(*file, data, count) failed = true;
received += count;
}
if request.body.count consume(request.body.data, request.body.count);
chunk := NewArray(1 << 20, u8,, temp);
while received < total && !failed {
n := recv(s, chunk.data, xx min(total - received, chunk.count), 0);
if n <= 0 { failed = true; break; }
consume(chunk.data, n);
}
file_close(*file);
head_string := string.{head_count, head.data};
if failed || received != total || !allowed_media(head_string) {
file_delete(temp_path);
if !failed && received == total respond(s, 415, "text/plain", "error \"Only images and videos can be attached.\"\n");
else respond(s, 400, "text/plain", "error \"Upload interrupted.\"\n");
return;
}
digest := sha256_final(*hasher);
hash := to_hex(digest.data, 32);
final_path := blob_path(hash);
make_directory_if_it_does_not_exist(path_strip_filename(final_path));
if file_exists(final_path) file_delete(temp_path);
else file_move(temp_path, final_path);
ins := prepare(db, tprint("INSERT OR IGNORE INTO blobs(hash, size, created, user_id) VALUES (?, ?, %, ?)", NOW_SQL));
bind(ins, 1, hash);
bind(ins, 2, total);
bind(ins, 3, session.user_id);
run(ins);
finalize(ins);
respond(s, 200, "text/plain", tprint("hash %\n", hash));
}
handle_blob_download :: (db: Db, s: Socket, request: Request) {
_, authed := authenticate(db, request);
if !authed {
respond(s, 401, "text/plain", "error \"Please sign in again.\"\n");
return;
}
hash := substring(request.path, "/api/blob/".count, request.path.count);
if !valid_hash(hash) {
respond(s, 404, "text/plain", "Not found\n");
return;
}
file, ok := file_open(blob_path(hash), log_errors = false);
if !ok {
respond(s, 404, "text/plain", "Not found\n");
return;
}
defer file_close(*file);
length := file_length(file);
header := tprint("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: %\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nCache-Control: private, max-age=31536000, immutable\r\n\r\n", length);
send_all(s, header);
chunk := NewArray(1 << 20, u8,, temp);
sent := 0;
while sent < length {
ok, n := file_read(file, chunk.data, min(length - sent, chunk.count));
if !ok || n <= 0 break;
send_all(s, string.{n, chunk.data});
sent += n;
}
}
users_command :: (db: Db, session: Session, body: string, token_hash: string) -> error: string {
r := Line_Reader.{line = body};
command := next_token(*r);
@@ -771,6 +931,7 @@ users_command :: (db: Db, session: Session, body: string, token_hash: string) ->
#load "sqlite.jai";
#load "crypto.jai";
#load "../src/sha256.jai";
#load "../src/common.jai";
#load "../src/model.jai";
#load "../src/storage.jai";
@@ -781,6 +942,7 @@ users_command :: (db: Db, session: Session, body: string, token_hash: string) ->
#import "File";
#import "Socket";
#import "Thread";
#import "File_Utilities";
#if OS != .WINDOWS {
#import "POSIX";
}