Video support, comment section, more
This commit is contained in:
+163
-1
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user