The hand-rolled sequential accept loop, its SO_RCVTIMEO band-aid, and the whole src/server/http.sx module are retired: distd is now a std.http handler. Server.init gets the store directory through the ctx word; route() fills a Response instead of writing to a socket; every handler ports mechanically (respond_error/load_or_503/respond_render take *Response; bodies allocate from the per-request arena, never the stack, since serialization happens after the handler returns). Downloads keep X-Checksum-SHA256 via extra_headers; auth takes the extracted Authorization value; the 411 contract (POST/PUT must declare Content-Length) moves into the handler, pinned as before. Config: 512 MiB read cap (whole-body artifact uploads), 120s request deadline, 5s keepalive, 200 requests per connection. Idle connections now cost nothing — timeouts evict, never block. http_client gains its own 10s read timeout (the old shared helper's secs->ms change had silently shrunk it to 10ms). tests/server_http.sx pins the architecture: a request answers within 1s while SIX idle preconnects are held open (the retired loop paid 250ms-2s per idle socket serially), and two requests ride one keep-alive connection. make test 24/24 green.
1060 lines
41 KiB
Plaintext
1060 lines
41 KiB
Plaintext
// =====================================================================
|
|
// distd.sx — the distribution server over the local store (subplan 04,
|
|
// Slices 1-4), run as `dist server run`.
|
|
//
|
|
// Serves the state the CLI publishes — the store database's metadata and
|
|
// the content-addressed objects — over HTTP (src/server/http.sx). Reads
|
|
// are public:
|
|
//
|
|
// GET / HTML index: apps, channels, releases, links
|
|
// GET /healthz {"status":"ok"} — no store access
|
|
// GET /api/apps {"apps":[...]} every app in the store
|
|
// GET /api/apps/<slug> {"app":..,"releases":[..],"channels":[..]}
|
|
// GET /download/<sha256> the object's bytes (application/octet-stream,
|
|
// X-Checksum-SHA256 header)
|
|
// GET /install/<slug>/<channel> install page (UA-aware,
|
|
// iOS actions honest per the app's IosMode)
|
|
// GET /install/<slug>/<channel>/manifest.plist enterprise OTA manifest
|
|
// (404 unless ios_mode is enterprise)
|
|
//
|
|
// Writes require `Authorization: Bearer <token>` with the `publish` scope
|
|
// (src/server/auth.sx; tokens are minted with `dist token create`):
|
|
//
|
|
// POST /api/upload raw bytes -> content-
|
|
// addressed object; responds {"status":"stored","sha256":..}
|
|
// POST /api/apps/<slug>/releases JSON body {version,
|
|
// channel, artifacts:[{platform, sha256, ...}]} over ALREADY-
|
|
// uploaded objects -> the same commit pipeline as `dist ci publish`
|
|
// POST /api/apps/<slug>/channels/<name>/promote {"release_id":..}
|
|
// POST /api/apps/<slug>/channels/<name>/rollback (empty body)
|
|
//
|
|
// The channel operations delegate to the P3.5 CLI pipelines and the
|
|
// release POST commits through publish.sx's `commit_publish`, so HTTP and
|
|
// CLI semantics cannot drift.
|
|
//
|
|
// Anything else is a JSON error in the CLI's error shape
|
|
// (`{"status":"error","error":{code,message}}`) with the matching HTTP
|
|
// status — the API and the CLI report failures identically.
|
|
//
|
|
// FRESHNESS: the store database is RELOADED on every /api request, so a
|
|
// `dist ci publish` / `release promote` between requests is visible
|
|
// immediately — the store on disk stays the single source of truth (no
|
|
// cache to invalidate, LAN-scale traffic).
|
|
//
|
|
// RESPONSE BUFFERS are heap slices from the per-request arena, never big
|
|
// stack arrays: a stack array of 64K+ in one frame crashes the sx LLVM
|
|
// backend (DAGCombiner segfault). Small fixed buffers (4K) are fine.
|
|
// =====================================================================
|
|
|
|
#import "modules/std.sx";
|
|
#import "modules/std/json.sx";
|
|
#import "modules/std/fs.sx";
|
|
#import "../domain/platform.sx";
|
|
#import "../domain/app.sx";
|
|
#import "../domain/release.sx";
|
|
#import "../domain/artifact.sx";
|
|
#import "../domain/channel.sx";
|
|
#import "../domain/token.sx";
|
|
#import "../domain/audit.sx";
|
|
#import "../domain/validate.sx";
|
|
#import "../repo/repo.sx";
|
|
#import "../store/store.sx";
|
|
#import "../validation/artifact_file.sx";
|
|
#import "../publish/publish.sx";
|
|
sock :: #import "modules/std/socket.sx";
|
|
au :: #import "auth.sx";
|
|
db :: #import "../repo/db.sx";
|
|
jout :: #import "../json_out.sx";
|
|
// Also reached through an alias so publish helpers read as `pl.…` at the
|
|
// call sites that mirror dist.sx's.
|
|
pl :: #import "../publish/publish.sx";
|
|
ops :: #import "../release/ops.sx";
|
|
adm :: #import "admin.sx";
|
|
// Aliased so the json reader is called as `jsrv.parse` — a bare `parse`
|
|
// would bind to `std.cli`'s once both modules share the `dist` program.
|
|
jsrv :: #import "modules/std/json.sx";
|
|
|
|
// Response-body capacity for the /api JSON renders (heap, per-request).
|
|
RENDER_CAP :: 262144;
|
|
|
|
ServeErr :: error {
|
|
Bind,
|
|
}
|
|
|
|
// Server-side stderr log line (fd 2 via the socket module's write; stdout
|
|
// stays clean for whoever launched the process).
|
|
slog :: (s: string) {
|
|
if s.len > 0 { sock.write(2, s.ptr, xx s.len); }
|
|
}
|
|
|
|
// True iff `s` is exactly 64 lowercase-hex chars (a storage key).
|
|
is_hex64 :: (s: string) -> bool {
|
|
if s.len != 64 { return false; }
|
|
i := 0;
|
|
while i < s.len {
|
|
c := s[i];
|
|
digit := c >= 48 and c <= 57; // '0'..'9'
|
|
lower := c >= 97 and c <= 102; // 'a'..'f'
|
|
if !digit and !lower { return false; }
|
|
i += 1;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
starts_with :: (s: string, prefix: string) -> bool {
|
|
if prefix.len > s.len { return false; }
|
|
i := 0;
|
|
while i < prefix.len {
|
|
if s[i] != prefix[i] { return false; }
|
|
i += 1;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// The path remainder after `prefix` (caller has checked starts_with).
|
|
tail_after :: (s: string, prefix: string) -> string {
|
|
return string.{ ptr = @s[prefix.len], len = s.len - prefix.len };
|
|
}
|
|
|
|
// A `RENDER_CAP` heap slice from the per-request context allocator.
|
|
render_buf :: () -> string {
|
|
raw : [*]u8 = xx context.allocator.alloc_bytes(RENDER_CAP);
|
|
return string.{ ptr = raw, len = RENDER_CAP };
|
|
}
|
|
|
|
// ── responses ─────────────────────────────────────────────────────────
|
|
|
|
// JSON error body in the CLI's error shape, sent with the HTTP status.
|
|
respond_error :: (resp: *http.Response, code: i64, fail_code: string, fail_message: string) {
|
|
f : jout.CliFailure = .{ code = fail_code, message = fail_message };
|
|
// The body must outlive this frame (the server serializes after the
|
|
// handler returns), so the render buffer comes from the per-request
|
|
// arena, never the stack.
|
|
raw : [*]u8 = xx context.allocator.alloc_bytes(4096);
|
|
werr := false;
|
|
n := jout.write_error(f, string.{ ptr = raw, len = 4096 }) catch { werr = true; 0 };
|
|
body := "{\"status\":\"error\"}";
|
|
if !werr { body = string.{ ptr = raw, len = n }; }
|
|
resp.status = code;
|
|
resp.content_type = "application/json";
|
|
resp.body = body;
|
|
}
|
|
|
|
// ── /api renders (builders own the `try`, callers catch) ─────────────
|
|
|
|
// Reload the persisted model. Null means the store has no readable
|
|
// database — the 503 error response has already been sent.
|
|
load_or_503 :: (resp: *http.Response, store_dir: string) -> ?Repo {
|
|
if !db.store_exists(store_dir) {
|
|
respond_error(resp, 503, "store.load",
|
|
concat("no store database (nothing published yet): ", store_dir));
|
|
return null;
|
|
}
|
|
loaded, le := db.load(store_dir);
|
|
if le {
|
|
respond_error(resp, 503, "store.load",
|
|
concat("the store database could not be loaded: ", store_dir));
|
|
return null;
|
|
}
|
|
return loaded;
|
|
}
|
|
|
|
// `{"apps":[...]}` into `dst`, returning the bytes written.
|
|
render_apps_json :: (repo: *Repo, dst: []u8) -> (i64, !JsonError) {
|
|
alloc := context.allocator;
|
|
root : Object = .{};
|
|
arr : Array = .{};
|
|
i := 0;
|
|
while i < repo.apps.len {
|
|
arr.add(db.app_to_json(repo.apps.items[i], alloc), alloc);
|
|
i += 1;
|
|
}
|
|
root.put("apps", .array(arr), alloc);
|
|
rootv : Value = .object(root);
|
|
n := try write_to_buffer(rootv, dst);
|
|
return n;
|
|
}
|
|
|
|
// `{"app":..,"releases":[..],"channels":[..]}` for `app` into `dst`.
|
|
render_app_detail_json :: (repo: *Repo, app: App, dst: []u8) -> (i64, !JsonError) {
|
|
alloc := context.allocator;
|
|
root : Object = .{};
|
|
root.put("app", db.app_to_json(app, alloc), alloc);
|
|
|
|
rels : Array = .{};
|
|
i := 0;
|
|
while i < repo.releases.len {
|
|
r := repo.releases.items[i];
|
|
if r.app_id == app.id { rels.add(db.release_to_json(r, alloc), alloc); }
|
|
i += 1;
|
|
}
|
|
root.put("releases", .array(rels), alloc);
|
|
|
|
chans : Array = .{};
|
|
i = 0;
|
|
while i < repo.channels.len {
|
|
c := repo.channels.items[i];
|
|
if c.app_id == app.id { chans.add(db.channel_to_json(c, alloc), alloc); }
|
|
i += 1;
|
|
}
|
|
root.put("channels", .array(chans), alloc);
|
|
|
|
rootv : Value = .object(root);
|
|
n := try write_to_buffer(rootv, dst);
|
|
return n;
|
|
}
|
|
|
|
// Send `n` rendered bytes of `buf` as 200 JSON — or the overflow error
|
|
// when the render didn't fit RENDER_CAP.
|
|
respond_render :: (resp: *http.Response, buf: string, n: i64, overflowed: bool) {
|
|
if overflowed {
|
|
respond_error(resp, 500, "http.response_overflow",
|
|
"response exceeded the server's render buffer");
|
|
return;
|
|
}
|
|
resp.status = 200;
|
|
resp.content_type = "application/json";
|
|
resp.body = string.{ ptr = buf.ptr, len = n };
|
|
}
|
|
|
|
// ── HTML index (the install-page seed, subplan 04 Slice 5) ───────────
|
|
|
|
// Escape &, <, >, " for HTML text/attribute positions. Returns `s`
|
|
// unchanged (no copy) when nothing needs escaping.
|
|
html_escape :: (s: string) -> string {
|
|
acc := "";
|
|
start := 0;
|
|
i := 0;
|
|
while i < s.len {
|
|
c := s[i];
|
|
rep := "";
|
|
if c == 38 { rep = "&"; } // &
|
|
if c == 60 { rep = "<"; } // <
|
|
if c == 62 { rep = ">"; } // >
|
|
if c == 34 { rep = """; } // "
|
|
if rep.len > 0 {
|
|
if i > start { acc = concat(acc, string.{ ptr = @s[start], len = i - start }); }
|
|
acc = concat(acc, rep);
|
|
start = i + 1;
|
|
}
|
|
i += 1;
|
|
}
|
|
if start == 0 { return s; }
|
|
if start < s.len { acc = concat(acc, string.{ ptr = @s[start], len = s.len - start }); }
|
|
return acc;
|
|
}
|
|
|
|
// First 12 hex chars of a digest, for display next to the full-link.
|
|
short_sha :: (sha: string) -> string {
|
|
if sha.len < 12 { return sha; }
|
|
return string.{ ptr = sha.ptr, len = 12 };
|
|
}
|
|
|
|
INDEX_HEAD :: "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>dist</title><style>body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#101216;color:#d6dae1;margin:2rem auto;max-width:60rem;padding:0 1rem}h1{font-size:1.1rem;letter-spacing:.04em}h2{font-size:1rem;margin-top:2rem;border-bottom:1px solid #2a2f3a;padding-bottom:.3rem}table{border-collapse:collapse;width:100%;font-size:.85rem}td,th{text-align:left;padding:.3rem .8rem .3rem 0;border-bottom:1px solid #1d212a;vertical-align:top}th{color:#8b93a3;font-weight:normal}a{color:#7ab7ff;text-decoration:none}a:hover{text-decoration:underline}small,.dim{color:#737b8c}</style></head><body><h1>dist — release console</h1>";
|
|
|
|
INDEX_FOOT :: "<p class=\"dim\"><small><a href=\"/admin\">admin console</a> · API: <a href=\"/api/apps\">/api/apps</a> · <a href=\"/healthz\">/healthz</a></small></p></body></html>";
|
|
|
|
// Server-rendered index: every app with its channels and its releases'
|
|
// artifacts as direct /download links. Dense and read-only, per the
|
|
// product shape — the full admin UI is subplan 06.
|
|
render_index :: (repo: *Repo) -> string {
|
|
page := INDEX_HEAD;
|
|
if repo.apps.len == 0 {
|
|
page = concat(page, "<p class=\"dim\">nothing published yet</p>");
|
|
}
|
|
a := 0;
|
|
while a < repo.apps.len {
|
|
app := repo.apps.items[a];
|
|
page = concat(page, concat("<h2>", concat(html_escape(app.slug), concat(" <small>", concat(html_escape(app.display_name), "</small></h2>")))));
|
|
|
|
// Channels: name (linked to the install page) -> current release.
|
|
page = concat(page, "<table><tr><th>channel</th><th>current release</th></tr>");
|
|
c := 0;
|
|
while c < repo.channels.len {
|
|
ch := repo.channels.items[c];
|
|
if ch.app_id == app.id {
|
|
link := concat("<a href=\"/install/", concat(html_escape(app.slug), concat("/", concat(html_escape(ch.name), concat("\">", concat(html_escape(ch.name), "</a>"))))));
|
|
page = concat(page, concat("<tr><td>", concat(link, concat("</td><td>", concat(html_escape(ch.current_release_id), "</td></tr>")))));
|
|
}
|
|
c += 1;
|
|
}
|
|
page = concat(page, "</table>");
|
|
|
|
// Releases with their artifacts as download links.
|
|
page = concat(page, "<table><tr><th>release</th><th>platform</th><th>artifact</th><th>size</th><th>sha256</th></tr>");
|
|
r := 0;
|
|
while r < repo.releases.len {
|
|
rel := repo.releases.items[r];
|
|
if rel.app_id == app.id {
|
|
k := 0;
|
|
while k < repo.artifacts.len {
|
|
art := repo.artifacts.items[k];
|
|
if art.release_id == rel.id {
|
|
row := concat("<tr><td>", concat(html_escape(rel.version), concat(" <small>", concat(html_escape(rel.id), "</small></td><td>"))));
|
|
row = concat(row, concat(db.platform_str(art.platform), "</td><td>"));
|
|
row = concat(row, concat("<a href=\"/download/", concat(art.sha256, concat("\">", concat(html_escape(art.filename), "</a></td><td>")))));
|
|
row = concat(row, concat(int_to_string(art.size_bytes), "</td><td><small>"));
|
|
row = concat(row, concat(short_sha(art.sha256), "…</small></td></tr>"));
|
|
page = concat(page, row);
|
|
}
|
|
k += 1;
|
|
}
|
|
}
|
|
r += 1;
|
|
}
|
|
page = concat(page, "</table>");
|
|
a += 1;
|
|
}
|
|
return concat(page, INDEX_FOOT);
|
|
}
|
|
|
|
handle_index :: (resp: *http.Response, store_dir: string) {
|
|
rq := load_or_503(resp, store_dir);
|
|
if rq == null { return; }
|
|
repo := rq!;
|
|
resp.content_type = "text/html; charset=utf-8";
|
|
resp.body = render_index(@repo);
|
|
}
|
|
|
|
// ── routes ────────────────────────────────────────────────────────────
|
|
|
|
handle_apps_index :: (resp: *http.Response, store_dir: string) {
|
|
rq := load_or_503(resp, store_dir);
|
|
if rq == null { return; }
|
|
repo := rq!;
|
|
|
|
buf := render_buf();
|
|
werr := false;
|
|
n := render_apps_json(@repo, buf) catch { werr = true; 0 };
|
|
respond_render(resp, buf, n, werr);
|
|
}
|
|
|
|
handle_app_detail :: (resp: *http.Response, store_dir: string, slug: string) {
|
|
rq := load_or_503(resp, store_dir);
|
|
if rq == null { return; }
|
|
repo := rq!;
|
|
|
|
aq := repo.find_app_by_slug(slug);
|
|
if aq == null {
|
|
respond_error(resp, 404, "api.unknown_app",
|
|
concat("no app with that slug in the store: ", slug));
|
|
return;
|
|
}
|
|
|
|
buf := render_buf();
|
|
werr := false;
|
|
n := render_app_detail_json(@repo, aq!, buf) catch { werr = true; 0 };
|
|
respond_render(resp, buf, n, werr);
|
|
}
|
|
|
|
handle_download :: (resp: *http.Response, store_dir: string, sha: string) {
|
|
if !is_hex64(sha) {
|
|
respond_error(resp, 404, "download.bad_key",
|
|
"download key must be a 64-char lowercase-hex sha256");
|
|
return;
|
|
}
|
|
opath := path_join(store_dir, concat("objects/", sha));
|
|
bq := read_file(opath);
|
|
if bq == null {
|
|
respond_error(resp, 404, "download.unknown_object",
|
|
concat("no object with that digest in the store: ", sha));
|
|
return;
|
|
}
|
|
resp.content_type = "application/octet-stream";
|
|
resp.extra_headers = concat("X-Checksum-SHA256: ", concat(sha, "\r\n"));
|
|
resp.body = bq!;
|
|
}
|
|
|
|
// ── install pages (subplan 04 Slice 5) ────────────────────────────────
|
|
|
|
// True iff `needle` occurs in `hay` (UA sniffing; both are short).
|
|
contains :: (hay: string, needle: string) -> bool {
|
|
if needle.len == 0 { return true; }
|
|
if needle.len > hay.len { return false; }
|
|
i := 0;
|
|
while i + needle.len <= hay.len {
|
|
j := 0;
|
|
ok := true;
|
|
while j < needle.len {
|
|
if hay[i + j] != needle[j] { ok = false; break; }
|
|
j += 1;
|
|
}
|
|
if ok { return true; }
|
|
i += 1;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// The requesting device's platform, sniffed from the User-Agent. Order
|
|
// matters: iPhone UAs contain "like Mac OS X" and Android UAs contain
|
|
// "Linux", so the mobile checks run first. Null = no idea.
|
|
ua_platform :: (ua: string) -> ?Platform {
|
|
if contains(ua, "iPhone") or contains(ua, "iPad") or contains(ua, "iPod") { return .ios; }
|
|
if contains(ua, "Android") { return .android_apk; }
|
|
if contains(ua, "Macintosh") { return .macos; }
|
|
if contains(ua, "Windows") { return .windows; }
|
|
if contains(ua, "Linux") { return .linux; }
|
|
return null;
|
|
}
|
|
|
|
platform_label :: (p: Platform) -> string {
|
|
if p == .ios { return "iOS"; }
|
|
if p == .android_apk { return "Android"; }
|
|
if p == .macos { return "macOS"; }
|
|
if p == .linux { return "Linux"; }
|
|
return "Windows";
|
|
}
|
|
|
|
// The release the (app, channel) pair currently serves, resolving each
|
|
// step or answering the precise 404. Null = response already sent.
|
|
// `host` (the request's Host header) feeds the absolute URLs the
|
|
// enterprise install flow needs.
|
|
InstallCtx :: struct {
|
|
app: App;
|
|
channel_name: string;
|
|
release: Release;
|
|
host: string = "localhost";
|
|
}
|
|
|
|
resolve_install :: (resp: *http.Response, repo: *Repo, slug: string, chan_name: string) -> ?InstallCtx {
|
|
aq := repo.find_app_by_slug(slug);
|
|
if aq == null {
|
|
respond_error(resp, 404, "install.unknown_app",
|
|
concat("no app with that slug in the store: ", slug));
|
|
return null;
|
|
}
|
|
app := aq!;
|
|
cq := repo.get_channel(app.id, chan_name);
|
|
if cq == null {
|
|
respond_error(resp, 404, "install.unknown_channel",
|
|
concat("the app has no channel with that name: ", chan_name));
|
|
return null;
|
|
}
|
|
rq := repo.get_release(cq!.current_release_id);
|
|
if rq == null {
|
|
respond_error(resp, 404, "install.no_release",
|
|
concat("the channel does not point at a published release: ", chan_name));
|
|
return null;
|
|
}
|
|
return InstallCtx.{ app = app, channel_name = chan_name, release = rq! };
|
|
}
|
|
|
|
// One artifact row: filename (download link), size, full sha256 — the
|
|
// digest is part of the contract, not decoration.
|
|
install_artifact_row :: (art: Artifact, label: string) -> string {
|
|
row := concat("<p class=\"artifact\"><a href=\"/download/", concat(art.sha256, "\">"));
|
|
row = concat(row, concat(html_escape(art.filename), "</a>"));
|
|
if label.len > 0 { row = concat(row, concat(" <small>", concat(label, "</small>"))); }
|
|
row = concat(row, concat(" <small>", concat(int_to_string(art.size_bytes), " bytes</small>")));
|
|
row = concat(row, concat("<br><small class=\"sha\">sha256 ", concat(art.sha256, "</small></p>")));
|
|
return row;
|
|
}
|
|
|
|
// The iOS section body for the app's install mode — the honest version
|
|
// of "install on iPhone" (see IosMode in the domain).
|
|
render_ios_actions :: (ctx: *InstallCtx, art: Artifact) -> string {
|
|
app := ctx.app;
|
|
if app.ios_mode == .testflight {
|
|
s := concat("<p><a class=\"action\" href=\"", concat(html_escape(app.testflight_url), "\">Open in TestFlight</a></p>"));
|
|
s = concat(s, "<p><small>Installation runs through Apple's TestFlight flow.</small></p>");
|
|
return concat(s, install_artifact_row(art, "(IPA artifact)"));
|
|
}
|
|
if app.ios_mode == .enterprise {
|
|
plist := concat("https://", concat(ctx.host, concat("/install/", concat(app.slug, concat("/", concat(ctx.channel_name, "/manifest.plist"))))));
|
|
itms := concat("itms-services://?action=download-manifest&url=", plist);
|
|
s := concat("<p><a class=\"action\" href=\"", concat(html_escape(itms), "\">Install on enrolled device</a></p>"));
|
|
s = concat(s, "<p><small>Over-the-air enterprise install; requires MDM enrollment and HTTPS.</small></p>");
|
|
return concat(s, install_artifact_row(art, "(IPA artifact)"));
|
|
}
|
|
// artifact_only — a download, never an install action
|
|
s := install_artifact_row(art, "");
|
|
return concat(s, "<p><small>IPA artifact download only — it cannot be installed on an iPhone or iPad from this page.</small></p>");
|
|
}
|
|
|
|
INSTALL_HEAD :: "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>install</title><style>body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#101216;color:#d6dae1;margin:2rem auto;max-width:40rem;padding:0 1rem}h1{font-size:1.1rem}h2{font-size:1rem;margin-top:1.6rem;border-bottom:1px solid #2a2f3a;padding-bottom:.3rem}a{color:#7ab7ff;text-decoration:none}a:hover{text-decoration:underline}a.action{display:inline-block;border:1px solid #7ab7ff;border-radius:4px;padding:.4rem .8rem;margin:.2rem 0}small,.dim{color:#737b8c}.sha{word-break:break-all}.detected h2{color:#9fe09f}</style></head><body>";
|
|
|
|
// The install page: the channel's current release, one section per
|
|
// platform artifact, the requester's platform first and marked.
|
|
render_install :: (ctx: *InstallCtx, repo: *Repo, detected: ?Platform) -> string {
|
|
app := ctx.app;
|
|
rel := ctx.release;
|
|
|
|
page := INSTALL_HEAD;
|
|
page = concat(page, concat("<h1>", concat(html_escape(app.display_name), concat(" <small>", concat(html_escape(rel.version), concat(" · ", concat(html_escape(ctx.channel_name), "</small></h1>")))))));
|
|
|
|
// platform sections in fixed order — except the detected platform,
|
|
// which moves to the front
|
|
order : [5]Platform = .[ .ios, .android_apk, .macos, .linux, .windows ];
|
|
if detected != null {
|
|
d := detected!;
|
|
order[0] = d;
|
|
slot := 1;
|
|
j := 0;
|
|
while j < 5 {
|
|
cand : Platform = .ios;
|
|
if j == 1 { cand = .android_apk; }
|
|
if j == 2 { cand = .macos; }
|
|
if j == 3 { cand = .linux; }
|
|
if j == 4 { cand = .windows; }
|
|
if cand != d {
|
|
order[slot] = cand;
|
|
slot += 1;
|
|
}
|
|
j += 1;
|
|
}
|
|
}
|
|
|
|
k := 0;
|
|
while k < 5 {
|
|
p := order[k];
|
|
// the release's artifact for this platform, if any
|
|
ai := 0;
|
|
while ai < repo.artifacts.len {
|
|
art := repo.artifacts.items[ai];
|
|
if art.release_id == rel.id and art.platform == p {
|
|
marked := detected != null and k == 0;
|
|
if marked { page = concat(page, "<section class=\"detected\">"); }
|
|
else { page = concat(page, "<section>"); }
|
|
page = concat(page, concat("<h2>", platform_label(p)));
|
|
if marked { page = concat(page, " <small>your device</small>"); }
|
|
page = concat(page, "</h2>");
|
|
if p == .ios {
|
|
page = concat(page, render_ios_actions(ctx, art));
|
|
} else {
|
|
label := "";
|
|
if p == .android_apk { label = "(APK)"; }
|
|
page = concat(page, install_artifact_row(art, label));
|
|
}
|
|
page = concat(page, "</section>");
|
|
}
|
|
ai += 1;
|
|
}
|
|
k += 1;
|
|
}
|
|
|
|
page = concat(page, "<p class=\"dim\"><small><a href=\"/\">← all apps</a></small></p>");
|
|
return concat(page, "</body></html>");
|
|
}
|
|
|
|
handle_install_page :: (resp: *http.Response, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
|
rq := load_or_503(resp, store_dir);
|
|
if rq == null { return; }
|
|
repo := rq!;
|
|
ctxq := resolve_install(resp, @repo, slug, chan_name);
|
|
if ctxq == null { return; }
|
|
ctx := ctxq!;
|
|
|
|
ua := http.find_header(req, "user-agent");
|
|
hostv := http.find_header(req, "host");
|
|
if hostv.len > 0 { ctx.host = hostv; }
|
|
|
|
resp.content_type = "text/html; charset=utf-8";
|
|
resp.body = render_install(@ctx, @repo, ua_platform(ua));
|
|
}
|
|
|
|
// The enterprise OTA manifest: Apple's plist shape, with the software
|
|
// package URL pointing back at this server over HTTPS (itms-services
|
|
// refuses plain http; TLS terminates at the reverse proxy).
|
|
handle_manifest_plist :: (resp: *http.Response, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
|
rq := load_or_503(resp, store_dir);
|
|
if rq == null { return; }
|
|
repo := rq!;
|
|
ctxq := resolve_install(resp, @repo, slug, chan_name);
|
|
if ctxq == null { return; }
|
|
ctx := ctxq!;
|
|
|
|
if ctx.app.ios_mode != .enterprise {
|
|
respond_error(resp, 404, "install.not_enterprise",
|
|
"the app's iOS install mode is not enterprise; no OTA manifest exists");
|
|
return;
|
|
}
|
|
bid := bundle_id_for(ctx.app, .ios);
|
|
if bid.len == 0 {
|
|
respond_error(resp, 404, "install.no_bundle_id",
|
|
"the app has no iOS bundle id; set one with: dist app set --ios-bundle-id");
|
|
return;
|
|
}
|
|
// the release's ios artifact
|
|
found := false;
|
|
art : Artifact = .{};
|
|
ai := 0;
|
|
while ai < repo.artifacts.len {
|
|
a := repo.artifacts.items[ai];
|
|
if a.release_id == ctx.release.id and a.platform == .ios { art = a; found = true; break; }
|
|
ai += 1;
|
|
}
|
|
if !found {
|
|
respond_error(resp, 404, "install.no_ios_artifact",
|
|
"the channel's current release carries no iOS artifact");
|
|
return;
|
|
}
|
|
|
|
host := "localhost";
|
|
hostv2 := http.find_header(req, "host");
|
|
if hostv2.len > 0 { host = hostv2; }
|
|
url := concat("https://", concat(host, concat("/download/", art.sha256)));
|
|
|
|
x := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\"><dict><key>items</key><array><dict>";
|
|
x = concat(x, "<key>assets</key><array><dict><key>kind</key><string>software-package</string><key>url</key><string>");
|
|
x = concat(x, html_escape(url));
|
|
x = concat(x, "</string></dict></array><key>metadata</key><dict><key>bundle-identifier</key><string>");
|
|
x = concat(x, html_escape(bid));
|
|
x = concat(x, "</string><key>bundle-version</key><string>");
|
|
x = concat(x, html_escape(ctx.release.version));
|
|
x = concat(x, "</string><key>kind</key><string>software</string><key>title</key><string>");
|
|
x = concat(x, html_escape(ctx.app.display_name));
|
|
x = concat(x, "</string></dict></dict></array></dict></plist>\n");
|
|
|
|
resp.content_type = "application/xml";
|
|
resp.body = x;
|
|
}
|
|
|
|
// GET /install/<slug>/<channel>[/manifest.plist]
|
|
handle_install_route :: (resp: *http.Response, store_dir: string, req: *http.Request, tail: string) {
|
|
s1 := seg_split(tail);
|
|
if s1.head.len == 0 or s1.rest.len == 0 {
|
|
respond_error(resp, 404, "http.not_found",
|
|
"install pages live at /install/<slug>/<channel>");
|
|
return;
|
|
}
|
|
s2 := seg_split(s1.rest);
|
|
if s2.rest.len == 0 {
|
|
handle_install_page(resp, store_dir, req, s1.head, s2.head);
|
|
return;
|
|
}
|
|
if s2.rest == "manifest.plist" {
|
|
handle_manifest_plist(resp, store_dir, req, s1.head, s2.head);
|
|
return;
|
|
}
|
|
respond_error(resp, 404, "http.not_found",
|
|
concat("no install route for ", tail));
|
|
}
|
|
|
|
// ── write surface (POST, token-gated) ─────────────────────────────────
|
|
|
|
// `s` split at its first '/': head before it, rest after it ("" when no
|
|
// slash exists).
|
|
Seg :: struct {
|
|
head: string;
|
|
rest: string;
|
|
}
|
|
|
|
seg_split :: (s: string) -> Seg {
|
|
i := 0;
|
|
while i < s.len {
|
|
if s[i] == 47 { // '/'
|
|
return Seg.{
|
|
head = string.{ ptr = s.ptr, len = i },
|
|
rest = string.{ ptr = @s[i + 1], len = s.len - i - 1 },
|
|
};
|
|
}
|
|
i += 1;
|
|
}
|
|
return Seg.{ head = s, rest = "" };
|
|
}
|
|
|
|
// Authenticate a write request or answer it. Null means the refusal
|
|
// response has already been sent.
|
|
auth_or_respond :: (resp: *http.Response, store_dir: string, req: *http.Request, app_slug: string, channel: string) -> ?Token {
|
|
fail : jout.CliFailure = .{};
|
|
t, ae := au.authenticate(store_dir, http.find_header(req, "authorization"), "publish", app_slug, channel, @fail);
|
|
if ae {
|
|
status : i64 = 401;
|
|
if ae == error.Forbidden { status = 403; }
|
|
if ae == error.Unavailable { status = 503; }
|
|
respond_error(resp, status, fail.code, fail.message);
|
|
return null;
|
|
}
|
|
return t;
|
|
}
|
|
|
|
// JSON-object body, or null after answering 400.
|
|
body_object :: (resp: *http.Response, body: string) -> ?Object {
|
|
v, pe := jsrv.parse(body, context.allocator);
|
|
if pe {
|
|
respond_error(resp, 400, "api.bad_json", "request body is not valid JSON");
|
|
return null;
|
|
}
|
|
if v != .object {
|
|
respond_error(resp, 400, "api.bad_json", "request body must be a JSON object");
|
|
return null;
|
|
}
|
|
return v.object;
|
|
}
|
|
|
|
wb_find :: (o: Object, key: string) -> ?Value {
|
|
i := 0;
|
|
while i < o.len {
|
|
if o.items[i].key == key { return o.items[i].val; }
|
|
i += 1;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Required string member, or null after answering 400.
|
|
wb_req_str :: (resp: *http.Response, o: Object, key: string) -> ?string {
|
|
vq := wb_find(o, key);
|
|
if vq != null {
|
|
v := vq!;
|
|
if v == .str {
|
|
if v.str.len > 0 { return v.str; }
|
|
}
|
|
}
|
|
respond_error(resp, 400, "api.missing_field",
|
|
concat("body requires a non-empty string member: ", key));
|
|
return null;
|
|
}
|
|
|
|
wb_opt_str :: (o: Object, key: string) -> string {
|
|
vq := wb_find(o, key);
|
|
if vq == null { return ""; }
|
|
v := vq!;
|
|
if v != .str { return ""; }
|
|
return v.str;
|
|
}
|
|
|
|
wb_opt_int :: (o: Object, key: string, default_: i64) -> i64 {
|
|
vq := wb_find(o, key);
|
|
if vq == null { return default_; }
|
|
v := vq!;
|
|
if v != .int_ { return default_; }
|
|
return v.int_;
|
|
}
|
|
|
|
// POST /api/upload — content-address the raw body into the store. The
|
|
// upload is app-agnostic (the bytes are inert until a release references
|
|
// them), so auth demands only the publish scope.
|
|
handle_upload :: (resp: *http.Response, store_dir: string, req: *http.Request) {
|
|
if auth_or_respond(resp, store_dir, req, "", "") == null { return; }
|
|
if req.body.len == 0 {
|
|
respond_error(resp, 400, "upload.empty", "upload body is empty");
|
|
return;
|
|
}
|
|
st := Store.init(store_dir);
|
|
werr := false;
|
|
key := "";
|
|
k, se := st.put_bytes(req.body);
|
|
if se { werr = true; }
|
|
if !se { key = k; }
|
|
if werr {
|
|
respond_error(resp, 500, "store.write",
|
|
"upload bytes could not be content-addressed into the store");
|
|
return;
|
|
}
|
|
resp.content_type = "application/json";
|
|
resp.body = concat("{\"status\":\"stored\",\"sha256\":\"", concat(key, concat("\",\"size_bytes\":", concat(int_to_string(req.body.len), "}"))));
|
|
}
|
|
|
|
// POST /api/apps/<slug>/releases — publish a release whose artifacts name
|
|
// already-uploaded objects by digest. Mirrors the CLI manifest checks
|
|
// (platform, digest, size, content type, filename extension) against the
|
|
// STORE rather than local files, then commits through the shared pipeline.
|
|
handle_release_create :: (resp: *http.Response, store_dir: string, req: *http.Request, slug: string) {
|
|
oq := body_object(resp, req.body);
|
|
if oq == null { return; }
|
|
o := oq!;
|
|
|
|
versionq := wb_req_str(resp, o, "version");
|
|
if versionq == null { return; }
|
|
version := versionq!;
|
|
channelq := wb_req_str(resp, o, "channel");
|
|
if channelq == null { return; }
|
|
channel := channelq!;
|
|
|
|
tokq := auth_or_respond(resp, store_dir, req, slug, channel);
|
|
if tokq == null { return; }
|
|
tok := tokq!;
|
|
|
|
artsq := wb_find(o, "artifacts");
|
|
arts_ok := false;
|
|
arts_arr : Array = .{};
|
|
if artsq != null {
|
|
av := artsq!;
|
|
if av == .array {
|
|
if av.array.len > 0 { arts_ok = true; arts_arr = av.array; }
|
|
}
|
|
}
|
|
if !arts_ok {
|
|
respond_error(resp, 400, "api.missing_field",
|
|
"body requires a non-empty artifacts array");
|
|
return;
|
|
}
|
|
|
|
alloc := context.allocator;
|
|
resolved : List(ResolvedArtifact) = .{};
|
|
i := 0;
|
|
while i < arts_arr.len {
|
|
if arts_arr.items[i] != .object {
|
|
respond_error(resp, 400, "api.bad_json", "each artifact must be a JSON object");
|
|
return;
|
|
}
|
|
ao := arts_arr.items[i].object;
|
|
|
|
pidq := wb_req_str(resp, ao, "platform");
|
|
if pidq == null { return; }
|
|
platform, perr := parse_platform(pidq!);
|
|
if perr {
|
|
respond_error(resp, 400, "api.unknown_platform",
|
|
concat("unknown platform id: ", pidq!));
|
|
return;
|
|
}
|
|
|
|
shaq := wb_req_str(resp, ao, "sha256");
|
|
if shaq == null { return; }
|
|
sha := shaq!;
|
|
if !is_hex64(sha) {
|
|
respond_error(resp, 400, "api.bad_digest",
|
|
"artifact sha256 must be a 64-char lowercase-hex digest");
|
|
return;
|
|
}
|
|
|
|
// The named object must already live in the store (uploaded via
|
|
// /api/upload or an earlier publish).
|
|
opath := path_join(store_dir, concat("objects/", sha));
|
|
ob := read_file(opath);
|
|
if ob == null {
|
|
respond_error(resp, 404, "api.unknown_object",
|
|
concat("no object with that digest in the store (upload it first): ", sha));
|
|
return;
|
|
}
|
|
actual_size := ob!.len;
|
|
declared := wb_opt_int(ao, "size_bytes", -1);
|
|
if declared >= 0 and declared != actual_size {
|
|
respond_error(resp, 400, "api.size_mismatch",
|
|
concat("declared size_bytes does not match the stored object: ", sha));
|
|
return;
|
|
}
|
|
|
|
ct := wb_opt_str(ao, "content_type");
|
|
if ct.len == 0 { ct = pl.default_content_type(platform); }
|
|
if !is_allowed_content_type(ct) {
|
|
respond_error(resp, 400, "api.content_type_denied",
|
|
concat("artifact content type is not on the allow-list: ", ct));
|
|
return;
|
|
}
|
|
|
|
fname := wb_opt_str(ao, "filename");
|
|
if fname.len == 0 {
|
|
fname = concat(slug, concat("-", concat(version, concat(".", expected_ext(platform)))));
|
|
}
|
|
if !ext_matches_platform(fname, platform) {
|
|
respond_error(resp, 400, "api.extension_mismatch",
|
|
concat("artifact filename extension does not match its platform: ", fname));
|
|
return;
|
|
}
|
|
|
|
resolved.append(ResolvedArtifact.{
|
|
platform = platform, key = sha, size_bytes = actual_size,
|
|
filename = fname, content_type = ct,
|
|
metadata = wb_opt_str(ao, "metadata"),
|
|
}, alloc);
|
|
i += 1;
|
|
}
|
|
|
|
fail : jout.CliFailure = .{};
|
|
actor := concat("token:", tok.name);
|
|
arts_view : []ResolvedArtifact = .{ ptr = resolved.items, len = resolved.len };
|
|
outcome, ce := pl.commit_publish(store_dir, slug, version, channel, actor, "/download/", arts_view, @fail);
|
|
if ce {
|
|
status : i64 = 500;
|
|
if ce == error.Transaction {
|
|
status = 400;
|
|
if fail.code == "transaction.integrity" { status = 409; }
|
|
}
|
|
if ce == error.Persist {
|
|
if fail.code == "persist.load" { status = 503; }
|
|
}
|
|
respond_error(resp, status, fail.code, fail.message);
|
|
return;
|
|
}
|
|
if !ce {
|
|
buf := render_buf();
|
|
werr := false;
|
|
n := pl.write_json(@outcome, buf) catch { werr = true; 0 };
|
|
respond_render(resp, buf, n, werr);
|
|
}
|
|
}
|
|
|
|
// HTTP status for a failed P3.5 channel operation.
|
|
op_http_status :: (e: ops.OpError) -> i64 {
|
|
if e == error.Load { return 503; }
|
|
if e == error.NotFound { return 404; }
|
|
if e == error.Invalid { return 409; }
|
|
return 500;
|
|
}
|
|
|
|
// POST /api/apps/<slug>/channels/<name>/promote — body {"release_id":..};
|
|
// delegates to the CLI's promote pipeline.
|
|
handle_promote :: (resp: *http.Response, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
|
oq := body_object(resp, req.body);
|
|
if oq == null { return; }
|
|
relq := wb_req_str(resp, oq!, "release_id");
|
|
if relq == null { return; }
|
|
|
|
if auth_or_respond(resp, store_dir, req, slug, chan_name) == null { return; }
|
|
|
|
fail : jout.CliFailure = .{};
|
|
o, e := ops.run_promote(store_dir, slug, chan_name, relq!, @fail);
|
|
if e {
|
|
respond_error(resp, op_http_status(e), fail.code, fail.message);
|
|
return;
|
|
}
|
|
if !e {
|
|
buf := render_buf();
|
|
werr := false;
|
|
n := ops.write_promote_json(@o, buf) catch { werr = true; 0 };
|
|
respond_render(resp, buf, n, werr);
|
|
}
|
|
}
|
|
|
|
// POST /api/apps/<slug>/channels/<name>/rollback — empty body; delegates
|
|
// to the CLI's rollback pipeline.
|
|
handle_rollback :: (resp: *http.Response, store_dir: string, req: *http.Request, slug: string, chan_name: string) {
|
|
if auth_or_respond(resp, store_dir, req, slug, chan_name) == null { return; }
|
|
|
|
fail : jout.CliFailure = .{};
|
|
o, e := ops.run_rollback(store_dir, slug, chan_name, @fail);
|
|
if e {
|
|
respond_error(resp, op_http_status(e), fail.code, fail.message);
|
|
return;
|
|
}
|
|
if !e {
|
|
buf := render_buf();
|
|
werr := false;
|
|
n := ops.write_rollback_json(@o, buf) catch { werr = true; 0 };
|
|
respond_render(resp, buf, n, werr);
|
|
}
|
|
}
|
|
|
|
// Route a write request under POST /api/. Returns false when no write
|
|
// route matches (the caller 404s).
|
|
route_post :: (resp: *http.Response, store_dir: string, req: *http.Request) -> bool {
|
|
path := req.path;
|
|
if path == "/api/upload" {
|
|
handle_upload(resp, store_dir, req);
|
|
return true;
|
|
}
|
|
if starts_with(path, "/api/apps/") {
|
|
s1 := seg_split(tail_after(path, "/api/apps/"));
|
|
if s1.head.len == 0 { return false; }
|
|
if s1.rest == "releases" {
|
|
handle_release_create(resp, store_dir, req, s1.head);
|
|
return true;
|
|
}
|
|
if starts_with(s1.rest, "channels/") {
|
|
s2 := seg_split(tail_after(s1.rest, "channels/"));
|
|
if s2.head.len == 0 { return false; }
|
|
if s2.rest == "promote" {
|
|
handle_promote(resp, store_dir, req, s1.head, s2.head);
|
|
return true;
|
|
}
|
|
if s2.rest == "rollback" {
|
|
handle_rollback(resp, store_dir, req, s1.head, s2.head);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Route one parsed request into `resp`: GET reads, POST writes. The
|
|
// outcome status lives in resp.status (respond_error sets it on every
|
|
// refusal path).
|
|
route :: (store_dir: string, req: *http.Request, resp: *http.Response) {
|
|
method := req.method;
|
|
path := req.path;
|
|
if method == "GET" {
|
|
if path == "/" {
|
|
handle_index(resp, store_dir);
|
|
return;
|
|
}
|
|
if path == "/healthz" {
|
|
resp.content_type = "application/json";
|
|
resp.body = "{\"status\":\"ok\"}";
|
|
return;
|
|
}
|
|
if path == "/api/apps" {
|
|
handle_apps_index(resp, store_dir);
|
|
return;
|
|
}
|
|
if starts_with(path, "/api/apps/") {
|
|
handle_app_detail(resp, store_dir, tail_after(path, "/api/apps/"));
|
|
return;
|
|
}
|
|
if starts_with(path, "/download/") {
|
|
handle_download(resp, store_dir, tail_after(path, "/download/"));
|
|
return;
|
|
}
|
|
if starts_with(path, "/install/") {
|
|
handle_install_route(resp, store_dir, req, tail_after(path, "/install/"));
|
|
return;
|
|
}
|
|
if path == "/admin" or starts_with(path, "/admin/") {
|
|
adm.handle_admin(store_dir, path, resp);
|
|
return;
|
|
}
|
|
respond_error(resp, 404, "http.not_found",
|
|
concat("no route for ", path));
|
|
return;
|
|
}
|
|
if method == "POST" {
|
|
if route_post(resp, store_dir, req) { return; }
|
|
respond_error(resp, 404, "http.not_found",
|
|
concat("no write route for ", path));
|
|
return;
|
|
}
|
|
respond_error(resp, 405, "http.method_not_allowed",
|
|
"distd routes are GET (reads) or POST (token-gated writes)");
|
|
}
|
|
|
|
// ── server loop (std.http, PLAN-HTTPZ A1) ─────────────────────────────
|
|
|
|
// The std.http handler: thread the store directory through the ctx
|
|
// word, keep the 411 contract (POST/PUT must declare Content-Length —
|
|
// std.http treats an absent header as a zero-length body), route, log.
|
|
// Per-request allocations land in std.http's per-dispatch arena.
|
|
DistdCtx :: struct {
|
|
store_dir: string;
|
|
}
|
|
|
|
distd_handle :: (req: *http.Request, resp: *http.Response, ctx: usize) {
|
|
dctx : *DistdCtx = xx ctx;
|
|
store_dir := dctx.store_dir;
|
|
|
|
refused := false;
|
|
if req.method == "POST" or req.method == "PUT" {
|
|
if http.find_header(req, "content-length").len == 0 {
|
|
respond_error(resp, 411, "http.length_required",
|
|
"POST/PUT requires a Content-Length header");
|
|
refused = true;
|
|
}
|
|
}
|
|
if !refused {
|
|
route(store_dir, req, resp);
|
|
}
|
|
line := concat("distd: ", concat(req.method, concat(" ", concat(req.path, concat(" -> ", concat(int_to_string(resp.status), "\n"))))));
|
|
slog(line);
|
|
}
|
|
|
|
// Serve the store over std.http's readiness loop (PLAN-HTTPZ A1): idle
|
|
// connections cost nothing, timeouts evict instead of blocking,
|
|
// keep-alive holds between requests. Returns only when the socket
|
|
// can't be opened.
|
|
run_server :: (store_dir: string, port: i64) -> !ServeErr {
|
|
dctx : DistdCtx = .{ store_dir = store_dir };
|
|
cfg : http.Config = .{
|
|
port = port,
|
|
max_conn = 256,
|
|
read_buf_cap = 536870912, // 512 MiB: artifact uploads arrive whole-body
|
|
timeout_request_ms = 120000, // a large upload must complete within this
|
|
timeout_keepalive_ms = 5000,
|
|
request_count = 200,
|
|
};
|
|
srv, se := http.Server.init(cfg, distd_handle, xx @dctx);
|
|
if se { raise error.Bind; }
|
|
|
|
slog(concat("distd: serving store ", concat(store_dir, concat(" on http://0.0.0.0:", concat(int_to_string(port), "\n")))));
|
|
srv.run();
|
|
return;
|
|
}
|