aboutsummaryrefslogtreecommitdiff
path: root/src/cmd_attachments.c
diff options
context:
space:
mode:
Diffstat (limited to 'src/cmd_attachments.c')
-rw-r--r--src/cmd_attachments.c414
1 files changed, 414 insertions, 0 deletions
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 <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#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]
+};