╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭──────────────────────────────────────────────────────────╮ │21 const local_flash_cookie = "haxy_local_flash"; │ │22 const local_required_title_prefix = "required_title:";│ │23 const local_sync_failure_prefix = "sync:"; │ │24 +const local_assignment_prefix = "assignment:"; │ │25 │ │26 const Embed = struct { │ │27 path: []const u8, │ ╰──────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │157 switch (host) { │ │158 .server => |server| { │ │159 // "repo/new" and "user/new" come before "new", which would claim them │ │159 - const PostRoute = enum { login, logout, @"repo/new", @"user/new", user, settings, grant, revoke, promote, demote, new, edit, remove, open, close, resolve, publish, merge, squash, attach, undo, clear }; │ │160 + const PostRoute = enum { login, logout, @"repo/new", @"user/new", user, settings, grant, revoke, promote, demote, new, edit, remove, open, close, resolve, publish, merge, squash, attach, assign, unassign, undo, clear };│ │161 inline for (@typeInfo(PostRoute).@"enum".field_names) |name| { │ │162 const suffix = "/" ++ name; │ │163 if (std.mem.endsWith(u8, path, suffix)) { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │185 .undo => handleUndo(io, request, allocator, base, host, .undo), │ │186 .clear => handleUndo(io, request, allocator, base, host, .clear),│ │187 .attach => handleAttach(io, request, allocator, base, host), │ │188 + .assign => handleAssign(io, request, allocator, base, host), │ │189 + .unassign => handleUnassign(io, request, allocator, base, host), │ │190 }; │ │191 } │ │192 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │193 }, │ │194 .local => |local| { │ │192 - const PostRoute = enum { new, edit, remove, open, close, resolve, sync, attach, undo, clear }; │ │195 + const PostRoute = enum { new, edit, remove, open, close, resolve, sync, attach, assign, unassign, undo, clear };│ │196 inline for (@typeInfo(PostRoute).@"enum".field_names) |name| { │ │197 const suffix = "/" ++ name; │ │198 if (std.mem.endsWith(u8, path, suffix)) { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │208 .undo => handleUndo(io, request, allocator, base, host, .undo), │ │209 .clear => handleUndo(io, request, allocator, base, host, .clear),│ │210 .attach => handleAttach(io, request, allocator, base, host), │ │211 + .assign => handleAssign(io, request, allocator, base, host), │ │212 + .unassign => handleUnassign(io, request, allocator, base, host), │ │213 }; │ │214 } │ │215 } │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │305 .discussion => .{ .discussion = .{ .failure = .required_title } }, │ │306 else => null, │ │307 } else null; │ │308 + } else if (std.mem.startsWith(u8, raw, local_assignment_prefix)) { │ │309 + const rest = raw[local_assignment_prefix.len..]; │ │310 + if (std.mem.indexOfScalar(u8, rest, ':')) |colon| { │ │311 + if (std.meta.stringToEnum(ui.Session.FormFeedback.AssignmentFailure, rest[0..colon])) |failure| { │ │312 + const text = std.Uri.percentDecodeInPlace(try form_arena.allocator().dupe(u8, rest[colon + 1 ..]));│ │313 + form_feedback = .{ .assignment = .{ .failure = failure, .text = text } }; │ │314 + } │ │315 + } │ │316 } else if (std.mem.startsWith(u8, raw, local_sync_failure_prefix)) { │ │317 const value = try allocator.dupe(u8, raw[local_sync_failure_prefix.len..]); │ │318 sync_failure_allocated = value; │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │430 return switch (host) { │ │431 .server => |server| respondFormFailure(request, allocator, server.session_store, location, feedback), │ │432 .local => { │ │420 - const tag = std.meta.activeTag(feedback); │ │421 - const required_title = switch (feedback) { │ │422 - .issue => |value| value.failure == .required_title, │ │423 - .discussion => |value| value.failure == .required_title, │ │424 - .patch => |value| value.failure == .required_title, │ │425 - else => false, │ │433 + var arena = std.heap.ArenaAllocator.init(allocator); │ │434 + defer arena.deinit(); │ │435 + const cookie = switch (feedback) { │ │436 + .assignment => |value| try arena.allocator().print( │ │437 + local_flash_cookie ++ "=" ++ local_assignment_prefix ++ "{s}:{s}; Path=/; HttpOnly; SameSite=Strict", │ │438 + .{ @tagName(value.failure), try ui.urlEncodeRef(arena.allocator(), value.text) }, │ │439 + ), │ │440 + else => blk: { │ │441 + const required_title = switch (feedback) { │ │442 + .issue => |value| value.failure == .required_title, │ │443 + .discussion => |value| value.failure == .required_title, │ │444 + .patch => |value| value.failure == .required_title, │ │445 + else => false, │ │446 + }; │ │447 + if (!required_title) return error.InvalidFormFeedback; │ │448 + break :blk try arena.allocator().print(local_flash_cookie ++ "=" ++ local_required_title_prefix ++ "{s}; Path=/; HttpOnly; SameSite=Strict", .{@tagName(std.meta.activeTag(feedback))});│ │449 + }, │ │450 }; │ │427 - if (!required_title) return error.InvalidFormFeedback; │ │428 - const cookie = try allocator.print(local_flash_cookie ++ "=" ++ local_required_title_prefix ++ "{s}; Path=/; HttpOnly; SameSite=Strict", .{@tagName(tag)}); │ │429 - defer allocator.free(cookie); │ │451 try request.respond("", .{ │ │452 .status = .see_other, │ │453 .extra_headers = &.{ │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1411 try request.respond("", .{ .status = .no_content, .keep_alive = false }); │ │1412 } │ │1413 │ │1414 +// assign the posted email, or the email of the user it names, to the thread the │ │1415 +// url names, then reload it │ │1416 +fn handleAssign( │ │1417 + io: std.Io, │ │1418 + request: *std.http.Server.Request, │ │1419 + allocator: std.mem.Allocator, │ │1420 + base: []const u8, │ │1421 + host: Host, │ │1422 +) !void { │ │1423 + const parts = commentBaseParts(host, base) orelse return respondRemoveNotFound(request); │ │1424 + if (parts.comment_id != null) return respondRemoveNotFound(request); │ │1425 + │ │1426 + var arena = std.heap.ArenaAllocator.init(allocator); │ │1427 + defer arena.deinit(); │ │1428 + const actor = (try authorizeWrite(io, allocator, &arena, request, host, parts.repo_base, .read, parts.thread_kind)) orelse return; │ │1429 + const request_repo = (try requestRepoSource(io, allocator, host, parts.repo_base)) orelse return respondRemoveNotFound(request); │ │1430 + defer request_repo.deinit(allocator); │ │1431 + const source = request_repo.source; │ │1432 + if (!try source.canModify(io, allocator, actor, parts.thread_kind, &parts.thread_id)) return respondForbidden(request); │ │1433 + │ │1434 + const text = blk: { │ │1435 + const body = try readFormBody(request, allocator); │ │1436 + defer allocator.free(body); │ │1437 + break :blk (try parseFormField(arena.allocator(), body, "assignee")) orelse ""; │ │1438 + }; │ │1439 + const failure: ?ui.Session.FormFeedback.AssignmentFailure = blk: { │ │1440 + const lookup = switch (host) { │ │1441 + .local => try ui.assigneeEmail(null, &arena, text), │ │1442 + .server => |server| server: { │ │1443 + var admin_repo = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = server.admin_repo_path }); │ │1444 + defer admin_repo.deinit(io, allocator); │ │1445 + break :server try ui.assigneeEmail(try evt.currentMoment(evt.admin_repo_opts, &admin_repo), &arena, text); │ │1446 + }, │ │1447 + }; │ │1448 + const email = switch (lookup) { │ │1449 + .email => |email| email, │ │1450 + .failure => |value| break :blk value, │ │1451 + }; │ │1452 + const thread_id_hex = std.fmt.bytesToHex(parts.thread_id, .lower); │ │1453 + switch (source.repo_kind) { │ │1454 + inline else => |repo_kind| { │ │1455 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); │ │1456 + defer any_repo.deinit(io, allocator); │ │1457 + switch (any_repo) { │ │1458 + inline else => |*repo| _ = evt.Assignment.create(host.event(), repo_kind, repo.self_repo_opts, io, allocator, repo, parts.thread_kind, &thread_id_hex, email, actor.author) catch |err| switch (err) { │ │1459 + error.AlreadyAssigned => break :blk .already_assigned, │ │1460 + error.InvalidFields => break :blk .not_an_email, │ │1461 + error.NotFound => return respondRemoveNotFound(request), │ │1462 + else => |e| return e, │ │1463 + }, │ │1464 + } │ │1465 + }, │ │1466 + } │ │1467 + break :blk null; │ │1468 + }; │ │1469 + if (failure) |value| return respondThreadFormFailure(request, allocator, host, base, .{ .assignment = .{ .failure = value, .text = text } }); │ │1470 + try request.respond("", .{ .status = .see_other, .keep_alive = false, .extra_headers = &.{.{ .name = "location", .value = base }} }); │ │1471 +} │ │1472 + │ │1473 +// unassign the email the url's last segment carries from the thread before it, │ │1474 +// then reload the thread │ │1475 +fn handleUnassign( │ │1476 + io: std.Io, │ │1477 + request: *std.http.Server.Request, │ │1478 + allocator: std.mem.Allocator, │ │1479 + base: []const u8, │ │1480 + host: Host, │ │1481 +) !void { │ │1482 + const infix = "/assignee:"; │ │1483 + const at = std.mem.lastIndexOf(u8, base, infix) orelse return respondRemoveNotFound(request); │ │1484 + const thread_url = base[0..at]; │ │1485 + const parts = commentBaseParts(host, thread_url) orelse return respondRemoveNotFound(request); │ │1486 + if (parts.comment_id != null) return respondRemoveNotFound(request); │ │1487 + │ │1488 + var arena = std.heap.ArenaAllocator.init(allocator); │ │1489 + defer arena.deinit(); │ │1490 + const email = std.Uri.percentDecodeInPlace(try arena.allocator().dupe(u8, base[at + infix.len ..])); │ │1491 + const actor = (try authorizeWrite(io, allocator, &arena, request, host, parts.repo_base, .read, parts.thread_kind)) orelse return; │ │1492 + const request_repo = (try requestRepoSource(io, allocator, host, parts.repo_base)) orelse return respondRemoveNotFound(request); │ │1493 + defer request_repo.deinit(allocator); │ │1494 + const source = request_repo.source; │ │1495 + if (!try source.canModify(io, allocator, actor, parts.thread_kind, &parts.thread_id)) return respondForbidden(request); │ │1496 + │ │1497 + switch (source.repo_kind) { │ │1498 + inline else => |repo_kind| { │ │1499 + var any_repo = try rp.AnyRepo(repo_kind, .{}).open(io, allocator, source.localInitOpts()); │ │1500 + defer any_repo.deinit(io, allocator); │ │1501 + switch (any_repo) { │ │1502 + inline else => |*repo| evt.removeInThread(host.event(), repo_kind, repo.self_repo_opts, io, allocator, repo, parts.thread_kind, &parts.thread_id, .assign, &evt.Assignment.idOf(&parts.thread_id, email), actor.author) catch |err| switch (err) {│ │1503 + error.EventNotFound => return respondRemoveNotFound(request), │ │1504 + else => |e| return e, │ │1505 + }, │ │1506 + } │ │1507 + }, │ │1508 + } │ │1509 + try request.respond("", .{ .status = .see_other, .keep_alive = false, .extra_headers = &.{.{ .name = "location", .value = thread_url }} }); │ │1510 +} │ │1511 + │ │1512 // the name the upload declares for its file, percent-encoded so it survives a │ │1513 // header │ │1514 fn attachmentName(allocator: std.mem.Allocator, request: *std.http.Server.Request) !?[]u8 { │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯