A session's transcript lived beside its attachments, both named after an id
that was a timestamp. Two things fell out of that: on a workspace two
machines share, a timestamp is not an id (the same second on both is enough
to hand out the same name), and "delete this conversation" had to chase the
jsonl, the title and a directory in a shared attachments/ tree, guessing what
belonged to whom. A phone's app data has nobody to tidy it by hand.
Now one session is one directory:
<workspace>/.agent/sessions/
<uuid>/session.jsonl the transcript
<uuid>/title the window title, one line
<uuid>/attachments/ images and delivered files (img-…, art-…)
<uuid>/sub-1.jsonl an agent role's own transcript
CURRENT the id the next run opens
- src/agent_store.c: new_id() makes a uuid v4 (16 bytes from /dev/urandom,
clock+pid only as a fallback), dir_for_id/flat_for_id/nested_for_id/
shape_of_id decide which shape an id has, so every reader takes either.
An id is a directory name, so it is checked as one (no "/", no "..",
nothing starting with ".", not CURRENT or attachments). path_for_id() keeps
the shape a session already has and makes the directory for a new id, so
nothing had to change in the callers. store_list() lists the directories
that hold a transcript plus the flat files, newest first. delete() is one
rm_tree of the session's directory, plus the old shared attachments/<id>
and CURRENT when it pointed there.
- cst_agent_store_migrate() (--migrate-store, or plain cst-agent for a
session): moves a flat store in — <id>.jsonl to <id>/session.jsonl,
<id>.title in, the shared attachments/<id>/ to <id>/attachments/. rename
first, copy when the filesystem says no, and counts what it moved, so a
second run is a no-op that says "nothing to move". On the real workspace:
6 sessions moved, the shared directory gone, --list-sessions reads them
back unchanged, a second run moved 0.
- cst_agent_store_id_of_path(): the id of a session path. This is the bug the
whole change had been hiding: cst_agent_session_id() took the id from the
file name, and the new file name is session.jsonl -- every session would
have called itself "session" (the sidebar, CURRENT, undo, an agent role's
directory, and the metrics file, which now also goes through the helper).
- src/attach.c: a session that owns its directory keeps its payloads inside
it (<id>/attachments/); only the flat layout needs the shared directory and
the id stem. Attachments behave exactly as before otherwise.
- tests/test_store.c: uuid shape and uniqueness, a small buffer refused, a
new session's path and directory, write/list/info/title read back, the
payload landing in <id>/attachments/, delete taking the directory with it;
then an old-layout store: readable before the move, moved byte for byte,
its payload readable through the new path, listed once, a second migration
a no-op, deletion covering the migrated payload. Also in the Makefile and
CMakeLists test lists.
- app/src/ui/UiTest.cpp: the image directory check wanted "/attachments/",
which the new layout has no trailing slash for.
Desktop: build clean, make check 35/35, --uitest all green, --selftest and
cst-app-selftest OK, and --selftest opens a session of the migrated workspace.
Version 0.9.5.
901 lines
28 KiB
C
901 lines
28 KiB
C
#ifndef _DEFAULT_SOURCE
|
|
#define _DEFAULT_SOURCE
|
|
#endif
|
|
|
|
#include "cJSON.h"
|
|
#include "cst_attach.h"
|
|
#include "cst_pdf.h"
|
|
#include "cst_util.h"
|
|
#include "os_compat.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* --- base64 -------------------------------------------------------------- */
|
|
|
|
static const char B64[] =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
|
|
char *cst_base64_encode(const void *data, size_t n) {
|
|
const unsigned char *p = data;
|
|
size_t olen = ((n + 2) / 3) * 4;
|
|
char *out = malloc(olen + 1);
|
|
if (!out)
|
|
return NULL;
|
|
size_t o = 0;
|
|
for (size_t i = 0; i < n; i += 3) {
|
|
unsigned v = (unsigned)p[i] << 16;
|
|
if (i + 1 < n)
|
|
v |= (unsigned)p[i + 1] << 8;
|
|
if (i + 2 < n)
|
|
v |= (unsigned)p[i + 2];
|
|
out[o++] = B64[(v >> 18) & 63];
|
|
out[o++] = B64[(v >> 12) & 63];
|
|
out[o++] = i + 1 < n ? B64[(v >> 6) & 63] : '=';
|
|
out[o++] = i + 2 < n ? B64[v & 63] : '=';
|
|
}
|
|
out[o] = 0;
|
|
return out;
|
|
}
|
|
|
|
static int b64_val(unsigned char c) {
|
|
if (c >= 'A' && c <= 'Z')
|
|
return c - 'A';
|
|
if (c >= 'a' && c <= 'z')
|
|
return c - 'a' + 26;
|
|
if (c >= '0' && c <= '9')
|
|
return c - '0' + 52;
|
|
if (c == '+' || c == '-')
|
|
return 62;
|
|
if (c == '/' || c == '_')
|
|
return 63;
|
|
return -1;
|
|
}
|
|
|
|
char *cst_base64_decode(const char *s, size_t n, size_t *out_len) {
|
|
if (out_len)
|
|
*out_len = 0;
|
|
if (!s)
|
|
return NULL;
|
|
char *out = malloc(n / 4 * 3 + 4);
|
|
if (!out)
|
|
return NULL;
|
|
size_t o = 0;
|
|
unsigned acc = 0;
|
|
int bits = 0;
|
|
for (size_t i = 0; i < n; i++) {
|
|
unsigned char c = (unsigned char)s[i];
|
|
if (c == '=')
|
|
break;
|
|
if (c == ' ' || c == '\t' || c == '\r' || c == '\n')
|
|
continue;
|
|
int v = b64_val(c);
|
|
if (v < 0) {
|
|
free(out);
|
|
return NULL;
|
|
}
|
|
acc = (acc << 6) | (unsigned)v;
|
|
bits += 6;
|
|
if (bits >= 8) {
|
|
bits -= 8;
|
|
out[o++] = (char)((acc >> bits) & 0xFF);
|
|
}
|
|
}
|
|
if (out_len)
|
|
*out_len = o;
|
|
return out;
|
|
}
|
|
|
|
/* --- format sniffing ----------------------------------------------------- */
|
|
|
|
static int be32(const unsigned char *p) {
|
|
return (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3];
|
|
}
|
|
|
|
static int le32(const unsigned char *p) {
|
|
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8) |
|
|
((unsigned)p[2] << 16) | ((unsigned)p[3] << 24));
|
|
}
|
|
|
|
static int le16(const unsigned char *p) {
|
|
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8));
|
|
}
|
|
|
|
static const char *mime_of_ext(const char *mime) {
|
|
if (strcmp(mime, "application/pdf") == 0 ||
|
|
strcmp(mime, "text/plain") == 0)
|
|
return "txt"; /* a document is stored as the text it was reduced to */
|
|
if (strcmp(mime, "image/png") == 0)
|
|
return "png";
|
|
if (strcmp(mime, "image/jpeg") == 0)
|
|
return "jpg";
|
|
if (strcmp(mime, "image/gif") == 0)
|
|
return "gif";
|
|
if (strcmp(mime, "image/webp") == 0)
|
|
return "webp";
|
|
if (strcmp(mime, "image/bmp") == 0)
|
|
return "bmp";
|
|
return "bin";
|
|
}
|
|
|
|
/* A JPEG SOF marker carries the frame size; skip it when scanning. */
|
|
static int jpeg_sof(int marker) {
|
|
switch (marker) {
|
|
case 0xC0:
|
|
case 0xC1:
|
|
case 0xC2:
|
|
case 0xC3:
|
|
case 0xC5:
|
|
case 0xC6:
|
|
case 0xC7:
|
|
case 0xC9:
|
|
case 0xCA:
|
|
case 0xCB:
|
|
case 0xCD:
|
|
case 0xCE:
|
|
case 0xCF:
|
|
return 1;
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
int cst_attach_sniff(const void *data, size_t n, char *mime, size_t mime_sz) {
|
|
const unsigned char *p = data;
|
|
const char *m = NULL;
|
|
if (n >= 8 && memcmp(p, "\x89PNG\r\n\x1a\n", 8) == 0)
|
|
m = "image/png";
|
|
else if (n >= 3 && p[0] == 0xFF && p[1] == 0xD8 && p[2] == 0xFF)
|
|
m = "image/jpeg";
|
|
else if (n >= 6 && memcmp(p, "GIF87a", 6) == 0)
|
|
m = "image/gif";
|
|
else if (n >= 6 && memcmp(p, "GIF89a", 6) == 0)
|
|
m = "image/gif";
|
|
else if (n >= 12 && memcmp(p, "RIFF", 4) == 0 && memcmp(p + 8, "WEBP", 4) == 0)
|
|
m = "image/webp";
|
|
else if (n >= 2 && p[0] == 'B' && p[1] == 'M')
|
|
m = "image/bmp";
|
|
if (!m)
|
|
return -1;
|
|
snprintf(mime, mime_sz, "%s", m);
|
|
return 0;
|
|
}
|
|
|
|
/* PNG / GIF / BMP are direct; JPEG needs a marker walk; WebP has three
|
|
* container flavours. */
|
|
int cst_attach_dimensions(const void *data, size_t n, int *w, int *h) {
|
|
const unsigned char *p = data;
|
|
*w = *h = 0;
|
|
char mime[32];
|
|
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0)
|
|
return -1;
|
|
|
|
if (strcmp(mime, "image/png") == 0) {
|
|
/* 8 signature + 4 length + "IHDR" + w + h */
|
|
if (n < 24 || memcmp(p + 12, "IHDR", 4) != 0)
|
|
return -1;
|
|
*w = be32(p + 16);
|
|
*h = be32(p + 20);
|
|
return 0;
|
|
}
|
|
if (strcmp(mime, "image/gif") == 0) {
|
|
if (n < 10)
|
|
return -1;
|
|
*w = le16(p + 6);
|
|
*h = le16(p + 8);
|
|
return 0;
|
|
}
|
|
if (strcmp(mime, "image/bmp") == 0) {
|
|
if (n < 26)
|
|
return -1;
|
|
int wi = le32(p + 18);
|
|
int hi = le32(p + 22);
|
|
if (wi < 0)
|
|
wi = -wi; /* bottom-up rows are stored with a negative height */
|
|
if (hi < 0)
|
|
hi = -hi;
|
|
*w = wi;
|
|
*h = hi;
|
|
return 0;
|
|
}
|
|
if (strcmp(mime, "image/webp") == 0) {
|
|
if (n < 30)
|
|
return -1;
|
|
const unsigned char *c = p + 12;
|
|
if (memcmp(c, "VP8 ", 4) == 0) {
|
|
/* 4 tag + 3 start code, then 16-bit width / height (14 bits each) */
|
|
if (n < 30 || !(c[7] == 0x9D && c[8] == 0x01 && c[9] == 0x2A))
|
|
return -1;
|
|
*w = le16(c + 10) & 0x3FFF;
|
|
*h = le16(c + 12) & 0x3FFF;
|
|
return 0;
|
|
}
|
|
if (memcmp(c, "VP8L", 4) == 0) {
|
|
if (n < 25 || c[8] != 0x2F)
|
|
return -1;
|
|
unsigned b = (unsigned)c[9] | ((unsigned)c[10] << 8) |
|
|
((unsigned)c[11] << 16) | ((unsigned)c[12] << 24);
|
|
*w = (int)(b & 0x3FFF) + 1;
|
|
*h = (int)((b >> 14) & 0x3FFF) + 1;
|
|
return 0;
|
|
}
|
|
if (memcmp(c, "VP8X", 4) == 0) {
|
|
if (n < 30)
|
|
return -1;
|
|
*w = (int)((unsigned)c[12] | ((unsigned)c[13] << 8) |
|
|
((unsigned)c[14] << 16)) + 1;
|
|
*h = (int)((unsigned)c[15] | ((unsigned)c[16] << 8) |
|
|
((unsigned)c[17] << 16)) + 1;
|
|
return 0;
|
|
}
|
|
return -1;
|
|
}
|
|
if (strcmp(mime, "image/jpeg") == 0) {
|
|
size_t i = 2;
|
|
while (i + 3 < n) {
|
|
if (p[i] != 0xFF)
|
|
return -1; /* lost sync */
|
|
unsigned marker = p[i + 1];
|
|
i += 2;
|
|
if (marker == 0xD8 || (marker >= 0xD0 && marker <= 0xD7))
|
|
continue; /* standalone markers: no payload */
|
|
if (marker == 0xD9 || marker == 0x01)
|
|
continue;
|
|
if (i + 1 >= n)
|
|
return -1;
|
|
size_t seg = (size_t)((p[i] << 8) | p[i + 1]);
|
|
if (seg < 2 || i + seg > n)
|
|
return -1;
|
|
if (jpeg_sof((int)marker) && seg >= 7) {
|
|
*h = (p[i + 3] << 8) | p[i + 4];
|
|
*w = (p[i + 5] << 8) | p[i + 6];
|
|
return 0;
|
|
}
|
|
i += seg;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/* --- storage ------------------------------------------------------------- */
|
|
|
|
/* Where a session's payloads live, beside its JSONL so moving the workspace
|
|
* keeps the two together:
|
|
* <uuid>/session.jsonl -> <uuid>/attachments/ (the store's shape
|
|
* since v0.9.5)
|
|
* <id>.jsonl -> <dir>/attachments/<id>/ (older sessions; the
|
|
* migration moves them)
|
|
* The directory is made here: every store() writes into it next. */
|
|
int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
|
|
if (!session_path || !session_path[0])
|
|
return -1;
|
|
char dir[OS_PATH_MAX];
|
|
snprintf(dir, sizeof(dir), "%s", session_path);
|
|
char *slash = strrchr(dir, '/');
|
|
#ifdef _WIN32
|
|
char *bslash = strrchr(dir, '\\');
|
|
if (bslash && (!slash || bslash > slash))
|
|
slash = bslash;
|
|
#endif
|
|
char leaf[256];
|
|
const char *base = slash ? slash + 1 : dir;
|
|
size_t bl = strlen(base);
|
|
if (bl >= sizeof(leaf))
|
|
bl = sizeof(leaf) - 1;
|
|
memcpy(leaf, base, bl);
|
|
leaf[bl] = 0;
|
|
if (slash)
|
|
*(slash + 1) = 0;
|
|
else {
|
|
dir[0] = '.';
|
|
dir[1] = 0;
|
|
}
|
|
char *dot = strstr(leaf, ".jsonl");
|
|
if (dot)
|
|
*dot = 0;
|
|
/* A session that owns its directory keeps its payloads directly inside it;
|
|
* only the flat layout needs the shared directory and its stem. */
|
|
char rel[320];
|
|
if (strcmp(leaf, "session") == 0)
|
|
snprintf(rel, sizeof(rel), "attachments");
|
|
else
|
|
snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session");
|
|
char full[OS_PATH_MAX];
|
|
if (os_path_join(full, sizeof(full), dir, rel) != 0)
|
|
return -1;
|
|
if (os_mkdir_p(full, 0700) != 0)
|
|
return -1;
|
|
snprintf(out, out_sz, "%s", full);
|
|
return 0;
|
|
}
|
|
|
|
/* Stored names are "<id>.<ext>" made by store(); anything else in a JSONL
|
|
* (hand-edited or crafted) could point outside the attachment directory. */
|
|
int cst_attach_rel_ok(const char *rel) {
|
|
if (!rel || !rel[0] || rel[0] == '.')
|
|
return 0;
|
|
for (const char *p = rel; *p; p++) {
|
|
char c = *p;
|
|
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
|
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'))
|
|
return 0;
|
|
}
|
|
return strstr(rel, "..") == NULL;
|
|
}
|
|
|
|
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
|
|
char *out, size_t out_sz) {
|
|
if (!a || !cst_attach_rel_ok(a->rel))
|
|
return -1;
|
|
char dir[OS_PATH_MAX];
|
|
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0)
|
|
return -1;
|
|
return os_path_join(out, out_sz, dir, a->rel);
|
|
}
|
|
|
|
static char *read_whole(const char *path, size_t *out_len, size_t max) {
|
|
FILE *f = fopen(path, "rb");
|
|
if (!f)
|
|
return NULL;
|
|
cst_buf_t b = {0};
|
|
char chunk[16384];
|
|
size_t n;
|
|
while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) {
|
|
if (b.len + n > max || cst_buf_append(&b, chunk, n) != 0) {
|
|
cst_buf_free(&b);
|
|
fclose(f);
|
|
return NULL;
|
|
}
|
|
}
|
|
fclose(f);
|
|
*out_len = b.len;
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
/* FNV over the bytes, the name and the clock: short, stable, unique enough
|
|
* inside one session. The prefix says what the name is for: "img" for what a
|
|
* model is shown, "art" for what a person is handed. */
|
|
static void make_id(const char *prefix, const char *name, const char *data,
|
|
size_t n, char *out, size_t out_sz) {
|
|
uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n);
|
|
h = cst_fnv1a64(h, name, strlen(name));
|
|
h = cst_fnv1a64(h, &n, sizeof(n));
|
|
double now = os_now_ms();
|
|
h = cst_fnv1a64(h, &now, sizeof(now));
|
|
h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int));
|
|
snprintf(out, out_sz, "%s-%08x", prefix && prefix[0] ? prefix : "img",
|
|
(unsigned)(h & 0xFFFFFFFFu));
|
|
}
|
|
|
|
/* n bytes, NUL-terminated: a text payload is handed around as a C string. */
|
|
static char *memdup_z(const char *data, size_t n) {
|
|
char *copy = malloc(n + 1);
|
|
if (!copy)
|
|
return NULL;
|
|
memcpy(copy, data, n);
|
|
copy[n] = 0;
|
|
return copy;
|
|
}
|
|
|
|
/* What a picked file is, plus the payload to store for it: an image stays as
|
|
* it is, a PDF is reduced to its text layer (the mime still says PDF, the
|
|
* stored file holds text), anything else must be UTF-8 text. The caller frees
|
|
* the result; *out_len is its length. */
|
|
static char *prepare(const char *data, size_t n, char *mime, size_t mime_sz,
|
|
int *pages, size_t *out_len, char *err, size_t err_sz) {
|
|
*pages = 0;
|
|
*out_len = n;
|
|
if (cst_attach_sniff(data, n, mime, mime_sz) == 0) {
|
|
if (n > CST_ATTACH_MAX_BYTES) {
|
|
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
|
|
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
|
|
return NULL;
|
|
}
|
|
char *copy = memdup_z(data, n);
|
|
if (!copy)
|
|
snprintf(err, err_sz, "out of memory");
|
|
return copy;
|
|
}
|
|
if (n >= 4 && memcmp(data, "%PDF", 4) == 0) {
|
|
int pg = 0;
|
|
char perr[256];
|
|
char *text = cst_pdf_text(data, n, &pg, perr, sizeof(perr));
|
|
if (!text) {
|
|
snprintf(err, err_sz, "cannot read the PDF: %.200s", perr);
|
|
return NULL;
|
|
}
|
|
snprintf(mime, mime_sz, "application/pdf");
|
|
*pages = pg;
|
|
*out_len = strlen(text);
|
|
return text;
|
|
}
|
|
if (n > CST_ATTACH_TEXT_MAX) {
|
|
snprintf(err, err_sz, "text is %.1f MB; the limit is %d MB",
|
|
(double)n / (1024 * 1024), (int)(CST_ATTACH_TEXT_MAX >> 20));
|
|
return NULL;
|
|
}
|
|
if (memchr(data, 0, n) || !cst_utf8_valid(data, n)) {
|
|
snprintf(err, err_sz,
|
|
"not an image (PNG, JPEG, GIF, WebP or BMP), a PDF or UTF-8 "
|
|
"text; the content decides, not the extension");
|
|
return NULL;
|
|
}
|
|
snprintf(mime, mime_sz, "text/plain");
|
|
char *copy = memdup_z(data, n);
|
|
if (!copy)
|
|
snprintf(err, err_sz, "out of memory");
|
|
return copy;
|
|
}
|
|
|
|
/* Write one payload whose kind is already known. id_prefix names it ("img" /
|
|
* "art"), for_user marks it as the person's rather than the model's, and
|
|
* ext_override is the stored file's extension when the caller knows better
|
|
* than the mime does. */
|
|
static cst_attachment_t *store(const char *session_path, const char *name,
|
|
const char *data, size_t n, const char *mime,
|
|
int pages, const char *origin, int for_user,
|
|
const char *id_prefix, const char *ext_override,
|
|
char *err, size_t err_sz) {
|
|
if (!name || !name[0])
|
|
name = "image";
|
|
char dir[OS_PATH_MAX];
|
|
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) {
|
|
snprintf(err, err_sz, "cannot create the attachment directory");
|
|
return NULL;
|
|
}
|
|
cst_attachment_t *a = calloc(1, sizeof(*a));
|
|
if (!a) {
|
|
snprintf(err, err_sz, "out of memory");
|
|
return NULL;
|
|
}
|
|
make_id(id_prefix, name, data, n, a->id, sizeof(a->id));
|
|
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id,
|
|
ext_override && ext_override[0] ? ext_override : mime_of_ext(mime));
|
|
snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image");
|
|
snprintf(a->mime, sizeof(a->mime), "%s", mime);
|
|
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
|
|
snprintf(a->detail, sizeof(a->detail), "auto");
|
|
a->for_user = for_user ? 1 : 0;
|
|
a->bytes = (long long)n;
|
|
a->pages = pages;
|
|
if (cst_attach_is_image(a))
|
|
cst_attach_dimensions(data, n, &a->width, &a->height);
|
|
|
|
char full[OS_PATH_MAX];
|
|
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
|
|
snprintf(err, err_sz, "attachment path is too long");
|
|
free(a);
|
|
return NULL;
|
|
}
|
|
FILE *f = fopen(full, "wb");
|
|
if (!f) {
|
|
snprintf(err, err_sz, "cannot write %.200s", full);
|
|
free(a);
|
|
return NULL;
|
|
}
|
|
size_t w = fwrite(data, 1, n, f);
|
|
if (fclose(f) != 0 || w != n) {
|
|
snprintf(err, err_sz, "cannot write %.200s", full);
|
|
free(a);
|
|
return NULL;
|
|
}
|
|
return a;
|
|
}
|
|
|
|
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
|
|
const char *name, const char *data,
|
|
size_t n, char *err, size_t err_sz) {
|
|
char dummy[8];
|
|
if (!err || !err_sz) {
|
|
err = dummy;
|
|
err_sz = sizeof(dummy);
|
|
}
|
|
err[0] = 0;
|
|
if (!session_path || !session_path[0]) {
|
|
snprintf(err, err_sz, "no session to store the file in");
|
|
return NULL;
|
|
}
|
|
if (!data || !n) {
|
|
snprintf(err, err_sz, "the file is empty");
|
|
return NULL;
|
|
}
|
|
char mime[32];
|
|
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
|
|
snprintf(err, err_sz,
|
|
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
|
|
"checked by content, not by extension)");
|
|
return NULL;
|
|
}
|
|
if (n > CST_ATTACH_MAX_BYTES) {
|
|
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
|
|
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
|
|
return NULL;
|
|
}
|
|
return store(session_path, name, data, n, mime, 0, "user", 0, "img", NULL,
|
|
err, err_sz);
|
|
}
|
|
|
|
cst_attachment_t *cst_attach_import_file(const char *session_path,
|
|
const char *src_path, char *err,
|
|
size_t err_sz) {
|
|
char dummy[8];
|
|
if (!err || !err_sz) {
|
|
err = dummy;
|
|
err_sz = sizeof(dummy);
|
|
}
|
|
err[0] = 0;
|
|
size_t n = 0;
|
|
char *data = read_whole(src_path, &n, CST_ATTACH_MAX_BYTES);
|
|
if (!data) {
|
|
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
|
|
src_path, (int)(CST_ATTACH_MAX_BYTES >> 20));
|
|
return NULL;
|
|
}
|
|
const char *leaf = src_path;
|
|
for (const char *p = src_path; *p; p++)
|
|
if (*p == '/' || *p == '\\')
|
|
leaf = p + 1;
|
|
char mime[32];
|
|
int pages = 0;
|
|
size_t pn = 0;
|
|
char *payload =
|
|
prepare(data, n, mime, sizeof(mime), &pages, &pn, err, err_sz);
|
|
free(data);
|
|
if (!payload)
|
|
return NULL;
|
|
cst_attachment_t *a =
|
|
store(session_path, leaf, payload, pn, mime, pages, "user", 0, "img",
|
|
NULL, err, err_sz);
|
|
free(payload);
|
|
return a;
|
|
}
|
|
|
|
/* The mime of a delivered file: its extension decides (the content is not
|
|
* sniffed -- a .zip is not a picture), and anything unknown is a plain blob
|
|
* the OS still opens by name. */
|
|
static const char *mime_from_name(const char *name) {
|
|
static const struct {
|
|
const char *ext, *mime;
|
|
} T[] = {
|
|
{"md", "text/markdown"}, {"markdown", "text/markdown"},
|
|
{"txt", "text/plain"}, {"log", "text/plain"},
|
|
{"json", "application/json"}, {"csv", "text/csv"},
|
|
{"html", "text/html"}, {"htm", "text/html"},
|
|
{"xml", "application/xml"}, {"yaml", "application/yaml"},
|
|
{"yml", "application/yaml"}, {"toml", "application/toml"},
|
|
{"pdf", "application/pdf"}, {"zip", "application/zip"},
|
|
{"gz", "application/gzip"}, {"tgz", "application/gzip"},
|
|
{"tar", "application/x-tar"}, {"sh", "text/x-shellscript"},
|
|
{"py", "text/x-python"}, {"c", "text/x-c"},
|
|
{"h", "text/x-c"}, {"cpp", "text/x-c++"},
|
|
{"java", "text/x-java"}, {"kt", "text/x-kotlin"},
|
|
{"js", "text/javascript"}, {"ts", "text/typescript"},
|
|
{"go", "text/x-go"}, {"rs", "text/x-rust"},
|
|
{"rb", "text/x-ruby"}, {"qml", "text/x-qml"},
|
|
{"css", "text/css"}, {"sql", "text/x-sql"},
|
|
{"diff", "text/x-diff"}, {"patch", "text/x-diff"},
|
|
{"png", "image/png"}, {"jpg", "image/jpeg"},
|
|
{"jpeg", "image/jpeg"}, {"gif", "image/gif"},
|
|
{"webp", "image/webp"}, {"bmp", "image/bmp"},
|
|
{"svg", "image/svg+xml"}, {"ico", "image/x-icon"},
|
|
{"mp3", "audio/mpeg"}, {"m4a", "audio/mp4"},
|
|
{"ogg", "audio/ogg"}, {"wav", "audio/wav"},
|
|
{"mp4", "video/mp4"}, {"webm", "video/webm"},
|
|
{"apk", "application/vnd.android.package-archive"},
|
|
{"docx",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
|
{"xlsx",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
|
|
{"pptx",
|
|
"application/vnd.openxmlformats-officedocument.presentationml."
|
|
"presentation"},
|
|
};
|
|
const char *dot = name ? strrchr(name, '.') : NULL;
|
|
if (!dot || !dot[1])
|
|
return "application/octet-stream";
|
|
const char *e = dot + 1;
|
|
size_t el = strlen(e);
|
|
for (size_t i = 0; i < sizeof(T) / sizeof(T[0]); i++) {
|
|
if (strlen(T[i].ext) != el)
|
|
continue;
|
|
size_t k = 0;
|
|
for (; k < el; k++) {
|
|
char c = e[k];
|
|
if (c >= 'A' && c <= 'Z')
|
|
c = (char)(c - 'A' + 'a');
|
|
if (c != T[i].ext[k])
|
|
break;
|
|
}
|
|
if (k == el)
|
|
return T[i].mime;
|
|
}
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
/* The stored file's extension: the name's own when it is plain ([a-z0-9], at
|
|
* most 8 characters -- a stored name has to stay portable), else whatever the
|
|
* mime maps to. */
|
|
static void ext_for_name(const char *name, const char *mime, char *out,
|
|
size_t out_sz) {
|
|
size_t k = 0;
|
|
const char *dot = name ? strrchr(name, '.') : NULL;
|
|
if (dot && dot[1] && out_sz > 1) {
|
|
for (const char *p = dot + 1; *p && k < 8 && k + 1 < out_sz; p++) {
|
|
char c = *p;
|
|
if (c >= 'A' && c <= 'Z')
|
|
c = (char)(c - 'A' + 'a');
|
|
if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')))
|
|
break;
|
|
out[k++] = c;
|
|
}
|
|
}
|
|
out[k] = 0;
|
|
if (!k)
|
|
snprintf(out, out_sz, "%s", mime_of_ext(mime));
|
|
}
|
|
|
|
cst_attachment_t *cst_attach_import_artifact(const char *session_path,
|
|
const char *src_path,
|
|
const char *title, char *err,
|
|
size_t err_sz) {
|
|
char dummy[8];
|
|
if (!err || !err_sz) {
|
|
err = dummy;
|
|
err_sz = sizeof(dummy);
|
|
}
|
|
err[0] = 0;
|
|
if (!session_path || !session_path[0]) {
|
|
snprintf(err, err_sz, "no session to store the file in");
|
|
return NULL;
|
|
}
|
|
if (!src_path || !src_path[0]) {
|
|
snprintf(err, err_sz, "no file to deliver");
|
|
return NULL;
|
|
}
|
|
size_t n = 0;
|
|
char *data = read_whole(src_path, &n, CST_ARTIFACT_MAX_BYTES);
|
|
if (!data) {
|
|
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
|
|
src_path, (int)(CST_ARTIFACT_MAX_BYTES >> 20));
|
|
return NULL;
|
|
}
|
|
const char *leaf = src_path;
|
|
for (const char *p = src_path; *p; p++)
|
|
if (*p == '/' || *p == '\\')
|
|
leaf = p + 1;
|
|
const char *name = (title && title[0]) ? title : leaf;
|
|
char mime[64], ext[16];
|
|
snprintf(mime, sizeof(mime), "%s", mime_from_name(name));
|
|
ext_for_name(name, mime, ext, sizeof(ext));
|
|
cst_attachment_t *a = store(session_path, name, data, n, mime, 0, "tool", 1,
|
|
"art", ext, err, err_sz);
|
|
free(data);
|
|
return a;
|
|
}
|
|
|
|
/* Images are opaque and travel as image parts; everything else is text the
|
|
* store holds and the request inlines. */
|
|
int cst_attach_is_image(const cst_attachment_t *a) {
|
|
return a && strncmp(a->mime, "image/", 6) == 0;
|
|
}
|
|
|
|
int cst_attach_is_text(const cst_attachment_t *a) {
|
|
/* A delivered file is the person's: it is never inlined into a request. */
|
|
return a && !a->for_user && !cst_attach_is_image(a);
|
|
}
|
|
|
|
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
|
|
size_t *out_len, char *err, size_t err_sz) {
|
|
char dummy[8];
|
|
if (!err || !err_sz) {
|
|
err = dummy;
|
|
err_sz = sizeof(dummy);
|
|
}
|
|
err[0] = 0;
|
|
char full[OS_PATH_MAX];
|
|
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
|
|
snprintf(err, err_sz, "attachment has no stored file");
|
|
return NULL;
|
|
}
|
|
char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES);
|
|
if (!data) {
|
|
snprintf(err, err_sz,
|
|
"%.200s is missing; it was deleted from the session store",
|
|
a->name);
|
|
return NULL;
|
|
}
|
|
if (!cst_attach_is_image(a)) /* a document is text, nothing to verify */
|
|
return data;
|
|
char mime[32];
|
|
if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) {
|
|
snprintf(err, err_sz, "%.200s in the session store is no longer an image",
|
|
a->name);
|
|
free(data);
|
|
return NULL;
|
|
}
|
|
return data;
|
|
}
|
|
|
|
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a) {
|
|
if (!cst_attach_is_image(a)) /* a document travels as text, not as a URL */
|
|
return NULL;
|
|
char err[256];
|
|
size_t n = 0;
|
|
char *data = cst_attach_read(session_path, a, &n, err, sizeof(err));
|
|
if (!data)
|
|
return NULL;
|
|
char mime[32];
|
|
cst_attach_sniff(data, n, mime, sizeof(mime)); /* the bytes decide */
|
|
char *b64 = cst_base64_encode(data, n);
|
|
free(data);
|
|
if (!b64)
|
|
return NULL;
|
|
cst_buf_t b = {0};
|
|
cst_buf_printf(&b, "data:%s;base64,", mime);
|
|
cst_buf_puts(&b, b64);
|
|
free(b64);
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) {
|
|
if (!a) {
|
|
snprintf(out, out_sz, "(none)");
|
|
return;
|
|
}
|
|
const char *kind = a->mime;
|
|
char dim[32] = "";
|
|
if (cst_attach_is_image(a)) {
|
|
if (strncmp(a->mime, "image/", 6) == 0)
|
|
kind = a->mime + 6;
|
|
if (a->width > 0 && a->height > 0)
|
|
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height);
|
|
} else if (strcmp(a->mime, "application/pdf") == 0) {
|
|
kind = "pdf";
|
|
if (a->pages > 0)
|
|
snprintf(dim, sizeof(dim), "%d page%s ", a->pages,
|
|
a->pages == 1 ? "" : "s");
|
|
} else if (strcmp(a->mime, "text/plain") == 0 || !a->mime[0]) {
|
|
kind = "text";
|
|
}
|
|
out[0] = 0;
|
|
if (a->bytes >= 1024 * 1024)
|
|
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
|
|
(double)a->bytes / (1024 * 1024));
|
|
else if (a->bytes >= 1024)
|
|
snprintf(out, out_sz, "%s%s %.1f KB", dim, kind, (double)a->bytes / 1024);
|
|
else
|
|
snprintf(out, out_sz, "%s%s %lld B", dim, kind, a->bytes);
|
|
}
|
|
|
|
/* --- lifetime ------------------------------------------------------------ */
|
|
|
|
void cst_attach_free(cst_attachment_t *a) { free(a); }
|
|
|
|
/* Entries always live in one array (a 1-element array for a single import),
|
|
* so freeing the block releases them all. */
|
|
void cst_attachments_free(cst_attachment_t *list, int n) {
|
|
(void)n;
|
|
free(list);
|
|
}
|
|
|
|
cst_attachment_t *cst_attachments_dup(const cst_attachment_t *list, int n) {
|
|
if (!list || n <= 0)
|
|
return NULL;
|
|
cst_attachment_t *out = calloc((size_t)n, sizeof(*out));
|
|
if (!out)
|
|
return NULL;
|
|
for (int i = 0; i < n; i++)
|
|
out[i] = list[i];
|
|
return out;
|
|
}
|
|
|
|
int cst_attachments_push(cst_attachment_t **list, int *n, int *cap,
|
|
const cst_attachment_t *src) {
|
|
if (!list || !n || !cap || !src)
|
|
return -1;
|
|
if (*n == *cap) {
|
|
int nc = *cap ? *cap * 2 : 4;
|
|
cst_attachment_t *nv = realloc(*list, (size_t)nc * sizeof(**list));
|
|
if (!nv)
|
|
return -1;
|
|
*list = nv;
|
|
*cap = nc;
|
|
}
|
|
(*list)[(*n)++] = *src;
|
|
return 0;
|
|
}
|
|
|
|
/* --- JSONL --------------------------------------------------------------- */
|
|
|
|
char *cst_attachments_to_json(const cst_attachment_t *list, int n) {
|
|
if (!list || n <= 0)
|
|
return NULL;
|
|
cJSON *arr = cJSON_CreateArray();
|
|
if (!arr)
|
|
return NULL;
|
|
for (int i = 0; i < n; i++) {
|
|
const cst_attachment_t *a = &list[i];
|
|
cJSON *o = cJSON_CreateObject();
|
|
cJSON_AddStringToObject(o, "id", a->id);
|
|
cJSON_AddStringToObject(o, "name", a->name);
|
|
cJSON_AddStringToObject(o, "mime", a->mime);
|
|
cJSON_AddStringToObject(o, "rel", a->rel);
|
|
cJSON_AddNumberToObject(o, "bytes", (double)a->bytes);
|
|
if (a->width > 0 && a->height > 0) {
|
|
cJSON_AddNumberToObject(o, "width", a->width);
|
|
cJSON_AddNumberToObject(o, "height", a->height);
|
|
}
|
|
if (a->pages > 0)
|
|
cJSON_AddNumberToObject(o, "pages", a->pages);
|
|
if (a->detail[0] && strcmp(a->detail, "auto") != 0)
|
|
cJSON_AddStringToObject(o, "detail", a->detail);
|
|
if (a->origin[0])
|
|
cJSON_AddStringToObject(o, "origin", a->origin);
|
|
if (a->for_user)
|
|
cJSON_AddNumberToObject(o, "for_user", 1);
|
|
cJSON_AddItemToArray(arr, o);
|
|
}
|
|
char *s = cJSON_PrintUnformatted(arr);
|
|
cJSON_Delete(arr);
|
|
return s;
|
|
}
|
|
|
|
cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out) {
|
|
if (n_out)
|
|
*n_out = 0;
|
|
const cJSON *arr = array_json;
|
|
if (!cJSON_IsArray(arr))
|
|
return NULL;
|
|
int n = cJSON_GetArraySize(arr);
|
|
if (n <= 0)
|
|
return NULL;
|
|
cst_attachment_t *list = calloc((size_t)n, sizeof(*list));
|
|
if (!list)
|
|
return NULL;
|
|
int k = 0;
|
|
const cJSON *it = NULL;
|
|
cJSON_ArrayForEach(it, arr) {
|
|
if (!cJSON_IsObject(it))
|
|
continue;
|
|
const cJSON *jid = cJSON_GetObjectItemCaseSensitive(it, "id");
|
|
const cJSON *jrel = cJSON_GetObjectItemCaseSensitive(it, "rel");
|
|
if (!cJSON_IsString(jid) || !cJSON_IsString(jrel) ||
|
|
!cst_attach_rel_ok(jrel->valuestring))
|
|
continue;
|
|
cst_attachment_t *a = &list[k];
|
|
snprintf(a->id, sizeof(a->id), "%s", jid->valuestring);
|
|
snprintf(a->rel, sizeof(a->rel), "%s", jrel->valuestring);
|
|
const cJSON *v = cJSON_GetObjectItemCaseSensitive(it, "name");
|
|
snprintf(a->name, sizeof(a->name), "%s",
|
|
cJSON_IsString(v) ? v->valuestring : a->id);
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "mime");
|
|
snprintf(a->mime, sizeof(a->mime), "%s",
|
|
cJSON_IsString(v) ? v->valuestring : "image/*");
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "bytes");
|
|
a->bytes = cJSON_IsNumber(v) ? (long long)v->valuedouble : 0;
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "width");
|
|
a->width = cJSON_IsNumber(v) ? v->valueint : 0;
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "height");
|
|
a->height = cJSON_IsNumber(v) ? v->valueint : 0;
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "pages");
|
|
a->pages = cJSON_IsNumber(v) ? v->valueint : 0;
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "detail");
|
|
snprintf(a->detail, sizeof(a->detail), "%s",
|
|
cJSON_IsString(v) ? v->valuestring : "auto");
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "origin");
|
|
snprintf(a->origin, sizeof(a->origin), "%s",
|
|
cJSON_IsString(v) ? v->valuestring : "");
|
|
v = cJSON_GetObjectItemCaseSensitive(it, "for_user");
|
|
a->for_user = cJSON_IsNumber(v) ? v->valueint : 0;
|
|
k++;
|
|
}
|
|
if (k == 0) {
|
|
free(list);
|
|
return NULL;
|
|
}
|
|
if (n_out)
|
|
*n_out = k;
|
|
return list;
|
|
}
|