╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │157 return; │ │158 } │ │159 │ │160 + // an attachment serves raw bytes rather than a page, so it is matched │ │161 + // before the routable pages │ │162 + if (attachmentRequest(path)) |attachment| return serveAttachment(io, request, allocator, attachment, host);│ │163 + │ │164 const current_page_maybe = switch (host) { │ │165 .remote => ui.RoutablePage.fromUrl(path), │ │166 .local => ui.RoutablePage.fromUrlLocal(path), │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │682 .status = .see_other, │ │683 .extra_headers = &.{.{ .name = "location", .value = location }}, │ │684 }); │ │685 +} │ │686 + │ │687 +const AttachmentRequest = struct { │ │688 + repo_base: []const u8, │ │689 + id: [evt.event_id_size]u8, │ │690 +}; │ │691 + │ │692 +// an attachment url is its repo's base and the attachment's event id │ │693 +fn attachmentRequest(path: []const u8) ?AttachmentRequest { │ │694 + const infix = "/attachment:"; │ │695 + const id_len = evt.event_id_size * 2; │ │696 + const at = std.mem.lastIndexOf(u8, path, infix) orelse return null; │ │697 + const tail = path[at + infix.len ..]; │ │698 + if (tail.len != id_len) return null; │ │699 + var id: [evt.event_id_size]u8 = undefined; │ │700 + _ = std.fmt.hexToBytes(&id, tail) catch return null; │ │701 + return .{ .repo_base = path[0..at], .id = id }; │ │702 +} │ │703 + │ │704 +// serve an attachment's bytes from the repo its url names │ │705 +fn serveAttachment( │ │706 + io: std.Io, │ │707 + request: *std.http.Server.Request, │ │708 + allocator: std.mem.Allocator, │ │709 + attachment: AttachmentRequest, │ │710 + host: Host, │ │711 +) !void { │ │712 + switch (host) { │ │713 + .remote => |remote| { │ │714 + const repo_prefix = "/repo/"; │ │715 + if (!std.mem.startsWith(u8, attachment.repo_base, repo_prefix)) return respondAttachmentNotFound(request); │ │716 + │ │717 + const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); │ │718 + defer allocator.free(repos_dir); │ │719 + const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, attachment.repo_base[repo_prefix.len..], false)) { │ │720 + .ok => |p| p, │ │721 + .invalid, .not_found => return respondAttachmentNotFound(request), │ │722 + }; │ │723 + defer allocator.free(repo_path); │ │724 + │ │725 + var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); │ │726 + defer repo.deinit(io, allocator); │ │727 + try sendAttachment(.xit, .{}, io, request, allocator, &repo, &attachment.id); │ │728 + }, │ │729 + .local => |src| { │ │730 + // local routes elide the repo base │ │731 + if (attachment.repo_base.len != 0) return respondAttachmentNotFound(request); │ │732 + switch (src.repo_kind) { │ │733 + inline else => |repo_kind| { │ │734 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); │ │735 + defer any_repo.deinit(io, allocator); │ │736 + switch (any_repo) { │ │737 + inline else => |*repo| try sendAttachment(repo_kind, repo.self_repo_opts, io, request, allocator, repo, &attachment.id), │ │738 + } │ │739 + }, │ │740 + } │ │741 + }, │ │742 + } │ │743 } │ │744 │ │745 +fn sendAttachment( │ │746 + comptime repo_kind: rp.RepoKind, │ │747 + comptime repo_opts: rp.RepoOpts(repo_kind), │ │748 + io: std.Io, │ │749 + request: *std.http.Server.Request, │ │750 + allocator: std.mem.Allocator, │ │751 + repo: *rp.Repo(repo_kind, repo_opts), │ │752 + id: *const [evt.event_id_size]u8, │ │753 +) !void { │ │754 + var arena = std.heap.ArenaAllocator.init(allocator); │ │755 + defer arena.deinit(); │ │756 + │ │757 + const DB = evt.EventDB(repo_opts.hash); │ │758 + var event_db_maybe: ?evt.LocalEventDB(repo_opts.hash) = if (repo_kind == .git) try evt.LocalEventDB(repo_opts.hash).openReadOnly(io, allocator, repo.core.repo_dir) else null; │ │759 + defer if (event_db_maybe) |*event_db| event_db.deinit(io, allocator); │ │760 + const haxy_moment = (if (event_db_maybe) |*event_db| │ │761 + evt.currentMomentFromDb(repo_opts.hash, event_db.db) │ │762 + else if (repo_kind == .git) │ │763 + return respondAttachmentNotFound(request) │ │764 + else │ │765 + evt.currentMoment(repo_opts, repo)) catch return respondAttachmentNotFound(request); │ │766 + │ │767 + const record = (try evt.Attachment.readById(DB, repo_opts.hash, haxy_moment, &arena, id)) orelse return respondAttachmentNotFound(request); │ │768 + if (record.deleted) return respondAttachmentNotFound(request); │ │769 + if (record.blob_oid.len != hash.hexLen(repo_opts.hash)) return respondAttachmentNotFound(request); │ │770 + const blob_oid: *const [hash.hexLen(repo_opts.hash)]u8 = @ptrCast(record.blob_oid.ptr); │ │771 + │ │772 + var moment = repo.core.latestMoment() catch return respondAttachmentNotFound(request); │ │773 + const state = rp.Repo(repo_kind, repo_opts).State(.read_only){ .core = &repo.core, .extra = .{ .moment = &moment } }; │ │774 + var object_reader = obj.ObjectReader(repo_kind, repo_opts).init(state, io, allocator, blob_oid) catch return respondAttachmentNotFound(request); │ │775 + defer object_reader.deinit(); │ │776 + if (object_reader.header().kind != .blob) return respondAttachmentNotFound(request); │ │777 + │ │778 + const content_type = attachmentContentType(record.name); │ │779 + const encoded_name = try ui.urlEncodeRef(allocator, record.name); │ │780 + defer allocator.free(encoded_name); │ │781 + const disposition = try std.fmt.allocPrint(allocator, "{s}; filename*=UTF-8''{s}", .{ if (content_type == null) "attachment" else "inline", encoded_name }); │ │782 + defer allocator.free(disposition); │ │783 + │ │784 + var send_buf: [8192]u8 = undefined; │ │785 + var body = try request.respondStreaming(&send_buf, .{ │ │786 + .content_length = object_reader.header().size, │ │787 + .respond_options = .{ │ │788 + .extra_headers = &.{ │ │789 + .{ .name = "content-type", .value = content_type orelse "application/octet-stream" }, │ │790 + .{ .name = "content-disposition", .value = disposition }, │ │791 + // uploaded bytes are never sniffed into a type that could run │ │792 + // as script on this origin │ │793 + .{ .name = "x-content-type-options", .value = "nosniff" }, │ │794 + }, │ │795 + }, │ │796 + }); │ │797 + _ = try object_reader.interface.streamRemaining(&body.writer); │ │798 + try body.end(); │ │799 +} │ │800 + │ │801 +// the images a browser renders safely inline. anything else downloads as │ │802 +// opaque bytes, so an uploaded page can't run on this origin. │ │803 +fn attachmentContentType(name: []const u8) ?[]const u8 { │ │804 + const types = [_]struct { ext: []const u8, value: []const u8 }{ │ │805 + .{ .ext = ".png", .value = "image/png" }, │ │806 + .{ .ext = ".jpg", .value = "image/jpeg" }, │ │807 + .{ .ext = ".jpeg", .value = "image/jpeg" }, │ │808 + .{ .ext = ".gif", .value = "image/gif" }, │ │809 + .{ .ext = ".webp", .value = "image/webp" }, │ │810 + }; │ │811 + for (types) |entry| { │ │812 + if (name.len > entry.ext.len and std.ascii.eqlIgnoreCase(name[name.len - entry.ext.len ..], entry.ext)) return entry.value; │ │813 + } │ │814 + return null; │ │815 +} │ │816 + │ │817 +fn respondAttachmentNotFound(request: *std.http.Server.Request) !void { │ │818 + try request.respond("attachment not found", .{ │ │819 + .status = .not_found, │ │820 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │821 + }); │ │822 +} │ │823 + │ │824 +fn respondAttachmentParentNotFound(request: *std.http.Server.Request) !void { │ │825 + try request.respond("attachment parent not found", .{ │ │826 + .status = .not_found, │ │827 + .keep_alive = false, │ │828 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │829 + }); │ │830 +} │ │831 + │ │832 +// the upload declares its file's name here, since the body is the file itself │ │833 +const attachment_name_header = "x-attachment-name"; │ │834 + │ │835 +const AttachParentParts = struct { │ │836 + repo_base: []const u8, │ │837 + id_bytes: [evt.event_id_size]u8, │ │838 +}; │ │839 + │ │840 +// what an attachment hangs off: the `<kind>:<id>` segment the form's page ends │ │841 +// with, whatever kind that is │ │842 +fn attachParentParts(base: []const u8) ?AttachParentParts { │ │843 + const id_len = evt.event_id_size * 2; │ │844 + if (base.len < id_len + 1) return null; │ │845 + const head = base[0 .. base.len - id_len]; │ │846 + if (head[head.len - 1] != ':') return null; │ │847 + var id_bytes: [evt.event_id_size]u8 = undefined; │ │848 + _ = std.fmt.hexToBytes(&id_bytes, base[base.len - id_len ..]) catch return null; │ │849 + const segment_at = std.mem.lastIndexOfScalar(u8, head, '/') orelse return null; │ │850 + return .{ .repo_base = head[0..segment_at], .id_bytes = id_bytes }; │ │851 +} │ │852 + │ │853 +// attach the posted file to the event the url names, then reload. the file is │ │854 +// the whole request body, so it streams from the socket into the object store │ │855 +// without being buffered. │ │856 +fn handleAttach( │ │857 + io: std.Io, │ │858 + request: *std.http.Server.Request, │ │859 + allocator: std.mem.Allocator, │ │860 + base: []const u8, │ │861 + host: Host, │ │862 +) !void { │ │863 + // reading the body invalidates the request head's memory, and the url is │ │864 + // still needed to resolve the repo afterward │ │865 + const base_copy = try allocator.dupe(u8, base); │ │866 + defer allocator.free(base_copy); │ │867 + │ │868 + const parts = attachParentParts(base_copy) orelse return respondAttachmentParentNotFound(request); │ │869 + │ │870 + var author_arena = std.heap.ArenaAllocator.init(allocator); │ │871 + defer author_arena.deinit(); │ │872 + const author = (try eventAuthor(io, allocator, &author_arena, request, host)) orelse return respondLoginRequired(request); │ │873 + │ │874 + const name = (try attachmentName(allocator, request)) orelse return respondUploadError(request, .bad_request, "attachment needs a name"); │ │875 + defer allocator.free(name); │ │876 + if (!evt.Attachment.nameValid(name)) return respondUploadError(request, .bad_request, "invalid attachment name"); │ │877 + │ │878 + const size = request.head.content_length orelse return respondUploadError(request, .length_required, "attachments must declare a content length"); │ │879 + if (size == 0) return respondUploadError(request, .bad_request, "attachment is empty"); │ │880 + if (size > evt.Attachment.max_size) return respondUploadError(request, .payload_too_large, "attachment is too large"); │ │881 + │ │882 + const parent_id_hex = std.fmt.bytesToHex(parts.id_bytes, .lower); │ │883 + │ │884 + var body_buf: [4096]u8 = undefined; │ │885 + const blob: evt.Blob = .{ .name = name, .size = size, .reader = try request.readerExpectContinue(&body_buf) }; │ │886 + │ │887 + switch (host) { │ │888 + .remote => |remote| { │ │889 + const repo_prefix = "/repo/"; │ │890 + if (!std.mem.startsWith(u8, parts.repo_base, repo_prefix)) return respondAttachmentParentNotFound(request); │ │891 + const repos_dir = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(remote.admin_repo_path) orelse ".", "repos" }); │ │892 + defer allocator.free(repos_dir); │ │893 + const repo_path = switch (try serve_common.resolveRepoPath(io, allocator, repos_dir, remote.admin_repo_path, parts.repo_base[repo_prefix.len..], false)) { │ │894 + .ok => |p| p, │ │895 + .invalid, .not_found => return respondAttachmentParentNotFound(request), │ │896 + }; │ │897 + defer allocator.free(repo_path); │ │898 + │ │899 + var repo = try rp.Repo(.xit, .{}).open(io, allocator, .{ .path = repo_path }); │ │900 + defer repo.deinit(io, allocator); │ │901 + _ = evt.Attachment.create(.xit, .{}, io, allocator, &repo, &parent_id_hex, blob, author) catch |err| switch (err) { │ │902 + error.ParentNotFound => return respondAttachmentParentNotFound(request), │ │903 + else => return err, │ │904 + }; │ │905 + }, │ │906 + .local => |src| { │ │907 + if (parts.repo_base.len != 0) return respondAttachmentParentNotFound(request); │ │908 + switch (src.repo_kind) { │ │909 + inline else => |repo_kind| { │ │910 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, src.localInitOpts()); │ │911 + defer any_repo.deinit(io, allocator); │ │912 + switch (any_repo) { │ │913 + inline else => |*repo| _ = evt.Attachment.create(repo_kind, repo.self_repo_opts, io, allocator, repo, &parent_id_hex, blob, author) catch |err| switch (err) {│ │914 + error.ParentNotFound => return respondAttachmentParentNotFound(request), │ │915 + else => return err, │ │916 + }, │ │917 + } │ │918 + }, │ │919 + } │ │920 + }, │ │921 + } │ │922 + │ │923 + // the client reloads the page itself │ │924 + try request.respond("", .{ .status = .no_content, .keep_alive = false }); │ │925 +} │ │926 + │ │927 +// the name the upload declares for its file, percent-encoded so it survives a │ │928 +// header │ │929 +fn attachmentName(allocator: std.mem.Allocator, request: *std.http.Server.Request) !?[]u8 { │ │930 + var iter = request.iterateHeaders(); │ │931 + while (iter.next()) |header| { │ │932 + if (!std.ascii.eqlIgnoreCase(header.name, attachment_name_header)) continue; │ │933 + return try decodeFormValue(allocator, header.value); │ │934 + } │ │935 + return null; │ │936 +} │ │937 + │ │938 +// an upload that failed leaves the body partly read, so the connection closes │ │939 +fn respondUploadError(request: *std.http.Server.Request, status: std.http.Status, message: []const u8) !void { │ │940 + try request.respond(message, .{ │ │941 + .status = status, │ │942 + .keep_alive = false, │ │943 + .extra_headers = &.{.{ .name = "content-type", .value = "text/plain" }}, │ │944 + }); │ │945 +} │ │946 + │ │947 // re-emit the comment `parts` names with a new body, in the repo the host │ │948 // resolves the base to. │ │949 fn updateComment( │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────╮ │1511 const inpage_prefix = "ai:"; │ │1512 │ │1513 fn init(str: []const u8) @This() { │ │1514 + if (std.mem.startsWith(u8, str, ui.raw_link_prefix)) {│ │1515 + return .{ .a = str[ui.raw_link_prefix.len..] }; │ │1516 + } │ │1517 if (std.mem.startsWith(u8, str, inpage_prefix)) { │ │1518 return .{ .a = str[inpage_prefix.len..] }; │ │1519 } │ ╰────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1791 try out.appendSlice(allocator, "</form>"); │ │1792 } │ │1793 │ │1794 + // a file picker isn't part of a form: it posts its file straight to the url │ │1795 + // in its marker. it sits transparent over the button the tui drew, so │ │1796 + // clicking that button opens the browser's own dialog. │ │1797 + var file_iter = root_focus.children.iterator(); │ │1798 + while (file_iter.next()) |entry| { │ │1799 + const child = entry.value_ptr.*; │ │1800 + const action_url = switch (child.focus.kind) { │ │1801 + .custom => |custom| if (std.mem.startsWith(u8, custom, ui.file_input_prefix)) │ │1802 + custom[ui.file_input_prefix.len..] │ │1803 + else │ │1804 + continue, │ │1805 + else => continue, │ │1806 + }; │ │1807 + const r = child.rect; │ │1808 + │ │1809 + try out.appendSlice(allocator, "<input type=\"file\" data-action=\""); │ │1810 + try appendEscapedHtml(allocator, &out, action_url); │ │1811 + try out.appendSlice(allocator, "\" data-focus-id=\""); │ │1812 + var id_buf: [32]u8 = undefined; │ │1813 + try out.appendSlice(allocator, try std.fmt.bufPrint(&id_buf, "{d}", .{entry.key_ptr.*})); │ │1814 + try out.appendSlice(allocator, "\" style=\"opacity:0;left:"); │ │1815 + var pos_buf: [128]u8 = undefined; │ │1816 + try out.appendSlice(allocator, try std.fmt.bufPrint(&pos_buf, "{d}ch;top:{d}em;width:{d}ch;height:{d}em", .{ r.x, r.y, r.size.width, r.size.height }));│ │1817 + try out.appendSlice(allocator, "\">"); │ │1818 + } │ │1819 + │ │1820 return try out.toOwnedSlice(allocator); │ │1821 } │ │1822 │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯