#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 db_error(r); } 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 db_error(r); } 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 db_sqlite_error(r); 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 db_error(r); 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 db_sqlite_error(r); 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 db_error(r); 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 db_error(r); 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] };