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
+1 -181
View File
@@ -1,184 +1,4 @@
// SHA-256, HMAC-SHA256, PBKDF2-HMAC-SHA256 and OS randomness. Used for password hashing and session tokens.
Sha256 :: struct {
state: [8] u32;
block: [64] u8;
used: int;
length: u64;
}
SHA256_K :: u32.[
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
];
sha256_init :: (s: *Sha256) {
s.state = .[0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
s.used = 0;
s.length = 0;
}
rotr :: inline (x: u32, n: u32) -> u32 {
return (x >> n) | (x << (32 - n));
}
sha256_compress :: (s: *Sha256, block: *u8) {
w: [64] u32 = ---;
for i: 0..15 {
p := block + i * 4;
w[i] = ((cast(u32) p[0]) << 24) | ((cast(u32) p[1]) << 16) | ((cast(u32) p[2]) << 8) | (cast(u32) p[3]);
}
for i: 16..63 {
s0 := rotr(w[i-15], 7) ^ rotr(w[i-15], 18) ^ (w[i-15] >> 3);
s1 := rotr(w[i-2], 17) ^ rotr(w[i-2], 19) ^ (w[i-2] >> 10);
w[i] = w[i-16] + s0 + w[i-7] + s1;
}
a, b, c, d := s.state[0], s.state[1], s.state[2], s.state[3];
e, f, g, h := s.state[4], s.state[5], s.state[6], s.state[7];
for i: 0..63 {
S1 := rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
ch := (e & f) ^ (~e & g);
t1 := h + S1 + ch + SHA256_K[i] + w[i];
S0 := rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
mj := (a & b) ^ (a & c) ^ (b & c);
t2 := S0 + mj;
h = g; g = f; f = e; e = d + t1;
d = c; c = b; b = a; a = t1 + t2;
}
s.state[0] += a; s.state[1] += b; s.state[2] += c; s.state[3] += d;
s.state[4] += e; s.state[5] += f; s.state[6] += g; s.state[7] += h;
}
sha256_update :: (s: *Sha256, data: *u8, count: int) {
s.length += cast(u64) count;
i := 0;
while i < count {
n := min(64 - s.used, count - i);
memcpy(s.block.data + s.used, data + i, n);
s.used += n;
i += n;
if s.used == 64 {
sha256_compress(s, s.block.data);
s.used = 0;
}
}
}
sha256_update :: inline (s: *Sha256, data: string) {
sha256_update(s, data.data, data.count);
}
sha256_final :: (s: *Sha256) -> [32] u8 {
bits := s.length * 8;
pad: [72] u8;
pad[0] = 0x80;
pad_len := ifx s.used < 56 then 56 - s.used else 120 - s.used;
for i: 0..7 pad[pad_len + i] = cast(u8) (bits >> cast(u64) ((7 - i) * 8));
sha256_update(s, pad.data, pad_len + 8);
result: [32] u8;
for i: 0..7 {
result[i*4 + 0] = cast(u8) (s.state[i] >> 24);
result[i*4 + 1] = cast(u8) (s.state[i] >> 16);
result[i*4 + 2] = cast(u8) (s.state[i] >> 8);
result[i*4 + 3] = cast(u8) s.state[i];
}
return result;
}
sha256 :: (data: string) -> [32] u8 {
s: Sha256;
sha256_init(*s);
sha256_update(*s, data);
return sha256_final(*s);
}
// HMAC with the inner/outer pad states precomputed, so PBKDF2 costs two compressions per iteration.
Hmac_Sha256 :: struct {
inner, outer: Sha256;
}
hmac_init :: (h: *Hmac_Sha256, key: string) {
k: [64] u8;
if key.count > 64 {
digest := sha256(key);
memcpy(k.data, digest.data, 32);
} else {
memcpy(k.data, key.data, key.count);
}
ipad, opad: [64] u8;
for i: 0..63 {
ipad[i] = k[i] ^ 0x36;
opad[i] = k[i] ^ 0x5c;
}
sha256_init(*h.inner);
sha256_update(*h.inner, ipad.data, 64);
sha256_init(*h.outer);
sha256_update(*h.outer, opad.data, 64);
}
hmac_compute :: (h: *Hmac_Sha256, message: *u8, count: int) -> [32] u8 {
inner := h.inner;
sha256_update(*inner, message, count);
digest := sha256_final(*inner);
outer := h.outer;
sha256_update(*outer, digest.data, 32);
return sha256_final(*outer);
}
hmac_sha256 :: (key: string, message: string) -> [32] u8 {
h: Hmac_Sha256;
hmac_init(*h, key);
return hmac_compute(*h, message.data, message.count);
}
// PBKDF2-HMAC-SHA256 with a 32-byte output (one block).
pbkdf2_sha256 :: (password: string, salt: string, iterations: int) -> [32] u8 {
h: Hmac_Sha256;
hmac_init(*h, password);
first := alloc_string(salt.count + 4,, temp);
memcpy(first.data, salt.data, salt.count);
first[salt.count + 0] = 0;
first[salt.count + 1] = 0;
first[salt.count + 2] = 0;
first[salt.count + 3] = 1;
u := hmac_compute(*h, first.data, first.count);
result := u;
for 2..iterations {
u = hmac_compute(*h, u.data, 32);
for i: 0..31 result[i] ^= u[i];
}
return result;
}
constant_time_equal :: (a: string, b: string) -> bool {
if a.count != b.count return false;
diff: u8;
for i: 0..a.count-1 diff |= a[i] ^ b[i];
return diff == 0;
}
to_hex :: (data: *u8, count: int) -> string {
DIGITS :: "0123456789abcdef";
result := alloc_string(count * 2);
for i: 0..count-1 {
result[i*2] = DIGITS[data[i] >> 4];
result[i*2 + 1] = DIGITS[data[i] & 0xF];
}
return result;
}
as_string :: (bytes: *[32] u8) -> string {
s: string;
s.data = bytes.data;
s.count = 32;
return s;
}
// OS randomness for salts and session tokens. (Hashing lives in src/sha256.jai.)
//
// Randomness from the OS.
+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";
}