From 8351fa7752bb8341ef405a37ff7c73142715e981 Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Sun, 20 Sep 2026 20:11:19 +0200 Subject: commands: split the command table by domain --- src/cmd_attachments.c | 414 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 src/cmd_attachments.c (limited to 'src/cmd_attachments.c') diff --git a/src/cmd_attachments.c b/src/cmd_attachments.c new file mode 100644 index 0000000..b1590fd --- /dev/null +++ b/src/cmd_attachments.c @@ -0,0 +1,414 @@ +#include "commands.h" +#include "cmd_util.h" + +#include +#include +#include + +#include "audit.h" +#include "config.h" +#include "db.h" +#include "sha256.h" +#include "util.h" + +/* ------------------------------------------------------------------ */ +/* attachments */ +/* ------------------------------------------------------------------ */ + +static int link_attachment(struct req *r, int64_t aid, int64_t vid, + char **errmsg, const char **errcode) +{ + *errcode = "INTERNAL"; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT (SELECT count(*) FROM vouchers WHERE org_id=?1 AND id=?2)," + "(SELECT count(*) FROM voucher_attachments WHERE org_id=?1" + " AND voucher_id=?2 AND attachment_id=?3)," + "(SELECT count(*) FROM attachments WHERE org_id=?1 AND id=?3)", + -1, &st, NULL) != SQLITE_OK) { + *errmsg = xstrdup("database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, vid); + sqlite3_bind_int64(st, 3, aid); + int64_t vexists = 0, linked = 0, aexists = 0; + if (sqlite3_step(st) == SQLITE_ROW) { + vexists = sqlite3_column_int64(st, 0); + linked = sqlite3_column_int64(st, 1); + aexists = sqlite3_column_int64(st, 2); + } + sqlite3_finalize(st); + if (!aexists) { + *errmsg = xstrdup("attachment not found"); + *errcode = "NOT_FOUND"; + return -1; + } + if (!vexists) { + *errmsg = xstrdup("voucher not found"); + *errcode = "NOT_FOUND"; + return -1; + } + if (linked) { + *errmsg = xstrdup("attachment is already linked"); + *errcode = "CONFLICT"; + return -1; + } + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO voucher_attachments(org_id,voucher_id,attachment_id," + "created_at) VALUES(?1,?2,?3,?4)", + -1, &st, NULL) != SQLITE_OK) { + *errmsg = xstrdup("database error"); + return -1; + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, vid); + sqlite3_bind_int64(st, 3, aid); + sqlite3_bind_text(st, 4, ts, -1, SQLITE_TRANSIENT); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) { + *errmsg = xstrdup("could not link attachment"); + return -1; + } + return 0; +} + +static yyjson_mut_val *h_attachment_put(struct req *r) +{ + const char *filename = arg_str(r->args, "filename"); + const char *mime = arg_str(r->args, "mime"); + const char *b64 = arg_str(r->args, "content_base64"); + int64_t voucher_id = 0; + arg_int(r->args, "voucher_id", &voucher_id); + if (!filename || !*filename || !b64) + return fail(r, "INVALID_ARGS", + "filename and content_base64 are required"); + if (!mime || !*mime) + mime = "application/octet-stream"; + unsigned char *content = NULL; + size_t len = 0; + if (util_b64_decode(b64, strlen(b64), &content, &len) != 0) + return fail(r, "INVALID_ARGS", "content_base64 is not valid base64"); + if ((long)len > g_cfg.max_attachment_bytes) { + free(content); + return failf(r, "TOO_LARGE", "attachment exceeds %ld bytes", + g_cfg.max_attachment_bytes); + } + unsigned char hash[32]; + util_sha256(content, len, hash); + char ts[32]; + util_iso8601(util_now(), ts, sizeof ts); + + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT id FROM attachments WHERE org_id=?1 AND sha256=?2" + " AND filename=?3", + -1, &st, NULL) != SQLITE_OK) { + free(content); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, filename, -1, SQLITE_TRANSIENT); + int64_t existing = 0; + if (sqlite3_step(st) == SQLITE_ROW) + existing = sqlite3_column_int64(st, 0); + sqlite3_finalize(st); + + if (existing) { + free(content); + if (voucher_id) { + char *emsg = NULL; + const char *ecode = NULL; + if (link_attachment(r, existing, voucher_id, &emsg, &ecode) != 0) { + yyjson_mut_val *res = + fail(r, ecode ? ecode : "INTERNAL", emsg); + free(emsg); + return res; + } + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "attachment.link", reqjson, "OK", NULL); + free(reqjson); + } + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", existing); + yyjson_mut_obj_add_bool(r->rdoc, o, "replayed", true); + return o; + } + + if (sqlite3_prepare_v2( + r->db, + "INSERT INTO attachments(org_id,sha256,filename,mime,size_bytes," + "content,created_at,created_by) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", + -1, &st, NULL) != SQLITE_OK) { + free(content); + return fail(r, "INTERNAL", "database error"); + } + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_blob(st, 2, hash, 32, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, filename, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, mime, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 5, (int64_t)len); + sqlite3_bind_blob(st, 6, content, (int)len, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 7, ts, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 8, r->sess->user_id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + free(content); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + int64_t id = db_last_id(r->db); + + if (voucher_id) { + char *emsg = NULL; + const char *ecode = NULL; + if (link_attachment(r, id, voucher_id, &emsg, &ecode) != 0) { + yyjson_mut_val *res = fail(r, ecode ? ecode : "INTERNAL", emsg); + free(emsg); + return res; + } + } + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "attachment.put", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", (int64_t)len); + return o; +} + +static yyjson_mut_val *h_attachment_link(struct req *r) +{ + int64_t id = 0, voucher_id = 0; + arg_int(r->args, "id", &id); + arg_int(r->args, "voucher_id", &voucher_id); + if (id <= 0 || voucher_id <= 0) + return fail(r, "INVALID_ARGS", "id and voucher_id are required"); + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + char *emsg = NULL; + const char *ecode = NULL; + if (link_attachment(r, id, voucher_id, &emsg, &ecode) != 0) { + yyjson_mut_val *e = fail(r, ecode ? ecode : "INTERNAL", emsg); + free(emsg); + return e; + } + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "attachment.link", reqjson, "OK", NULL); + free(reqjson); + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", voucher_id); + return o; +} + +static yyjson_mut_val *h_attachment_unlink(struct req *r) +{ + int64_t id = 0, voucher_id = 0; + arg_int(r->args, "id", &id); + arg_int(r->args, "voucher_id", &voucher_id); + if (id <= 0 || voucher_id <= 0) + return fail(r, "INVALID_ARGS", "id and voucher_id are required"); + if (r->dry_run) { + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_bool(r->rdoc, o, "dry_run", true); + return o; + } + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "DELETE FROM voucher_attachments WHERE org_id=?1" + " AND voucher_id=?2 AND attachment_id=?3", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + sqlite3_bind_int64(st, 3, id); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) + return fail(r, "INTERNAL", sqlite3_errmsg(r->db)); + if (sqlite3_changes(r->db) == 0) + return fail(r, "NOT_FOUND", "link not found"); + char *reqjson = audit_args_json(r->args); + audit_append(r->db, r->org_id, r->sess->user_id, r->sess->token_id, + "attachment.unlink", reqjson, "OK", NULL); + free(reqjson); + return yyjson_mut_obj(r->rdoc); +} + +static yyjson_mut_val *h_attachment_get(struct req *r) +{ + int64_t id = 0; + if (!arg_int(r->args, "id", &id) || id <= 0) + return fail(r, "INVALID_ARGS", "id is required"); + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT filename,mime,size_bytes,content,sha256,created_at" + " FROM attachments WHERE org_id=?1 AND id=?2", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, id); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + return fail(r, "NOT_FOUND", "attachment not found"); + } + const void *content = sqlite3_column_blob(st, 3); + size_t len = (size_t)sqlite3_column_bytes(st, 3); + char *b64 = util_b64(content ? content : (const unsigned char *)"", len); + char hex[65]; + const void *hb = sqlite3_column_blob(st, 4); + if (hb && sqlite3_column_bytes(st, 4) == 32) + util_hex((const unsigned char *)hb, 32, hex); + else + hex[0] = '\0'; + yyjson_mut_val *o = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_int(r->rdoc, o, "id", id); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", + sq(sqlite3_column_text(st, 0))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "mime", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", + sqlite3_column_int64(st, 2)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 5))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "content_base64", b64); + free(b64); + sqlite3_finalize(st); + return o; +} + +static yyjson_mut_val *h_attachment_list(struct req *r) +{ + int64_t voucher_id = 0, cursor = 0, limit = 100; + int unlinked = 0; + arg_int(r->args, "voucher_id", &voucher_id); + arg_int(r->args, "cursor", &cursor); + arg_int(r->args, "limit", &limit); + arg_bool(r->args, "unlinked", &unlinked); + if (limit < 1) + limit = 100; + if (limit > 1000) + limit = 1000; + sqlite3_stmt *st = NULL; + if (sqlite3_prepare_v2( + r->db, + "SELECT a.id,a.filename,a.mime,a.size_bytes,a.created_at,a.sha256," + "(SELECT va.voucher_id FROM voucher_attachments va" + " WHERE va.org_id=a.org_id AND va.attachment_id=a.id" + " AND (?2=0 OR va.voucher_id=?2)" + " ORDER BY va.voucher_id LIMIT 1)" + " FROM attachments a WHERE a.org_id=?1" + " AND (?2=0 OR EXISTS(SELECT 1 FROM voucher_attachments va2" + " WHERE va2.org_id=a.org_id AND va2.attachment_id=a.id" + " AND va2.voucher_id=?2))" + " AND (?3=0 OR NOT EXISTS(SELECT 1 FROM voucher_attachments va3" + " WHERE va3.org_id=a.org_id AND va3.attachment_id=a.id))" + " AND a.id>?4 ORDER BY a.id LIMIT ?5", + -1, &st, NULL) != SQLITE_OK) + return fail(r, "INTERNAL", "database error"); + sqlite3_bind_int64(st, 1, r->org_id); + sqlite3_bind_int64(st, 2, voucher_id); + sqlite3_bind_int(st, 3, unlinked); + sqlite3_bind_int64(st, 4, cursor); + sqlite3_bind_int64(st, 5, limit); + yyjson_mut_val *items = yyjson_mut_arr(r->rdoc); + int64_t n = 0, last = cursor; + while (sqlite3_step(st) == SQLITE_ROW) { + n++; + last = sqlite3_column_int64(st, 0); + yyjson_mut_val *o = yyjson_mut_arr_add_obj(r->rdoc, items); + yyjson_mut_obj_add_int(r->rdoc, o, "id", last); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "filename", + sq(sqlite3_column_text(st, 1))); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "mime", + sq(sqlite3_column_text(st, 2))); + yyjson_mut_obj_add_int(r->rdoc, o, "size_bytes", + sqlite3_column_int64(st, 3)); + yyjson_mut_obj_add_strcpy(r->rdoc, o, "created_at", + sq(sqlite3_column_text(st, 4))); + char hex[65]; + const void *hb = sqlite3_column_blob(st, 5); + if (hb && sqlite3_column_bytes(st, 5) == 32) + util_hex((const unsigned char *)hb, 32, hex); + else + hex[0] = '\0'; + yyjson_mut_obj_add_strcpy(r->rdoc, o, "sha256", hex); + if (sqlite3_column_type(st, 6) == SQLITE_NULL) + yyjson_mut_obj_add_null(r->rdoc, o, "voucher_id"); + else + yyjson_mut_obj_add_int(r->rdoc, o, "voucher_id", + sqlite3_column_int64(st, 6)); + } + sqlite3_finalize(st); + yyjson_mut_val *out = yyjson_mut_obj(r->rdoc); + yyjson_mut_obj_add_val(r->rdoc, out, "items", items); + if (n == limit) + yyjson_mut_obj_add_int(r->rdoc, out, "next_cursor", last); + else + yyjson_mut_obj_add_null(r->rdoc, out, "next_cursor"); + return out; +} + + +static const struct cmd_arg args_attachment_put[] = { + { "filename", ARG_STR, 1, NULL, NULL, "File name" }, + { "mime", ARG_STR, 0, "application/octet-stream", NULL, "MIME type" }, + { "content_base64", ARG_STR, 1, NULL, NULL, "File content, base64" }, + { "voucher_id", ARG_INT, 0, NULL, NULL, "Link to this voucher" }, +}; + +static const struct cmd_arg args_attachment_link[] = { + { "id", ARG_INT, 1, NULL, NULL, "Attachment id" }, + { "voucher_id", ARG_INT, 1, NULL, NULL, "Voucher id" }, +}; + +static const struct cmd_arg args_attachment_unlink[] = { + { "id", ARG_INT, 1, NULL, NULL, "Attachment id" }, + { "voucher_id", ARG_INT, 1, NULL, NULL, "Voucher id" }, +}; + +static const struct cmd_arg args_attachment_get[] = { + { "id", ARG_INT, 1, NULL, NULL, "Attachment id" }, +}; + +static const struct cmd_arg args_attachment_list[] = { + { "voucher_id", ARG_INT, 0, NULL, NULL, + "Only attachments on this voucher" }, + { "unlinked", ARG_BOOL, 0, NULL, NULL, + "Only unlinked attachments (inbox)" }, + { "cursor", ARG_INT, 0, NULL, NULL, "Cursor from next_cursor" }, + { "limit", ARG_INT, 0, "100", NULL, "Page size, 1-1000" }, +}; + +const struct command g_cmd_attachments[] = { + { "attachment.put", "Store an underlag (receipt, invoice)", PERM_WRITE, 1, + 1, 1, h_attachment_put, CMD_ARGS(args_attachment_put) }, + { "attachment.link", "Link an existing attachment to a voucher", + PERM_WRITE, 1, 1, 1, h_attachment_link, CMD_ARGS(args_attachment_link) }, + { "attachment.unlink", "Remove an attachment link", PERM_WRITE, 1, 1, 1, + h_attachment_unlink, CMD_ARGS(args_attachment_unlink) }, + { "attachment.get", "Fetch an attachment", PERM_READ, 1, 0, 0, + h_attachment_get, CMD_ARGS(args_attachment_get) }, + { "attachment.list", "List attachments / inbox", PERM_READ, 1, 0, 0, + h_attachment_list, CMD_ARGS(args_attachment_list) }, +}; + +const struct cmd_table g_cmd_table_attachments = { + g_cmd_attachments, sizeof g_cmd_attachments / sizeof g_cmd_attachments[0] +}; -- cgit v1.3