╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │281 - // expire the flash cookie on the way out so a refresh doesn't keep │ │282 - // showing the failure label │ │283 - if (login_failure != null) headers.appendAssumeCapacity(.{ .name = "set-cookie", .value = login_failure_cookie ++ "=; Path=/; Max-Age=0" });│ │284 - if (sync_failure != null) headers.appendAssumeCapacity(.{ .name = "set-cookie", .value = sync_failure_cookie ++ "=; Path=/; Max-Age=0" }); │ │297 + if (expired_form_cookie) |cookie| headers.appendAssumeCapacity(.{ .name = "set-cookie", .value = cookie }); │ │298 + if (local_flash_seen) headers.appendAssumeCapacity(.{ .name = "set-cookie", .value = local_flash_cookie ++ "=; Path=/; Max-Age=0" }); │ │299 if (session_cookie) |cookie| headers.appendAssumeCapacity(.{ .name = "set-cookie", .value = cookie }); │ │300 try request.respond(html, .{ .extra_headers = headers.items }); │ │301 return; │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │363 }, │ │364 }); │ │365 }, │ │352 - .unknown_user => { │ │353 - try request.respond("", .{ │ │354 - .status = .see_other, │ │355 - .extra_headers = &.{ │ │356 - .{ .name = "location", .value = failure_location }, │ │357 - .{ .name = "set-cookie", .value = login_failure_cookie ++ "=unknown_user; Path=/; HttpOnly; SameSite=Strict" }, │ │358 - }, │ │359 - }); │ │366 + .unknown_user => try respondFormFailure(request, allocator, session_store, failure_location, .{ .login = .{ .failure = .unknown_user, .username = username } }), │ │367 + .wrong_password => try respondFormFailure(request, allocator, session_store, failure_location, .{ .login = .{ .failure = .wrong_password, .username = username } }), │ │368 + } │ │369 +} │ │370 + │ │371 +fn respondFormFailure(request: *std.http.Server.Request, allocator: std.mem.Allocator, session_store: SessionStore, location: []const u8, feedback: ui.Session.FormFeedback) !void { │ │372 + const token = try session_store.createFlash(allocator, feedback); │ │373 + const cookie = try std.fmt.allocPrint(allocator, form_flash_cookie ++ "={s}; Path={s}; HttpOnly; SameSite=Strict", .{ token, location }); │ │374 + defer allocator.free(cookie); │ │375 + try request.respond("", .{ │ │376 + .status = .see_other, │ │377 + .extra_headers = &.{ │ │378 + .{ .name = "location", .value = location }, │ │379 + .{ .name = "set-cookie", .value = cookie }, │ │380 }, │ │361 - .wrong_password => { │ │381 + }); │ │382 +} │ │383 + │ │384 +fn respondThreadFormFailure(request: *std.http.Server.Request, allocator: std.mem.Allocator, host: Host, location: []const u8, feedback: ui.Session.FormFeedback) !void { │ │385 + return switch (host) { │ │386 + .remote => |remote| respondFormFailure(request, allocator, remote.session_store, location, feedback), │ │387 + .local => { │ │388 + const tag = std.meta.activeTag(feedback); │ │389 + const required_title = switch (feedback) { │ │390 + .issue => |value| value.failure == .required_title, │ │391 + .discussion => |value| value.failure == .required_title, │ │392 + .patch => |value| value.failure == .required_title, │ │393 + else => false, │ │394 + }; │ │395 + if (!required_title) return error.InvalidFormFeedback; │ │396 + const cookie = try std.fmt.allocPrint(allocator, local_flash_cookie ++ "=" ++ local_required_title_prefix ++ "{s}; Path=/; HttpOnly; SameSite=Strict", .{@tagName(tag)});│ │397 + defer allocator.free(cookie); │ │398 try request.respond("", .{ │ │399 .status = .see_other, │ │400 .extra_headers = &.{ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │365 - .{ .name = "location", .value = failure_location }, │ │366 - .{ .name = "set-cookie", .value = login_failure_cookie ++ "=wrong_password; Path=/; HttpOnly; SameSite=Strict" },│ │401 + .{ .name = "location", .value = location }, │ │402 + .{ .name = "set-cookie", .value = cookie }, │ │403 }, │ │404 }); │ │405 }, │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │370 - } │ │406 + }; │ │407 +} │ │408 + │ │409 +fn requiredTitleFeedback(kind: evt.EventKind, title: []const u8, tags: []const u8, description: []const u8, target_branch: []const u8) ui.Session.FormFeedback {│ │410 + return switch (kind) { │ │411 + .issue => .{ .issue = .{ .failure = .required_title, .fields = .{ │ │412 + .title = title, │ │413 + .tags = tags, │ │414 + .description = description, │ │415 + } } }, │ │416 + .patch => .{ .patch = .{ .failure = .required_title, .fields = .{ │ │417 + .title = title, │ │418 + .tags = tags, │ │419 + .description = description, │ │420 + .target_branch = target_branch, │ │421 + } } }, │ │422 + .discuss => .{ .discussion = .{ .failure = .required_title, .fields = .{ │ │423 + .title = title, │ │424 + .tags = tags, │ │425 + .description = description, │ │426 + } } }, │ │427 + else => unreachable, │ │428 + }; │ │429 +} │ │430 + │ │431 +fn invalidTargetBranchFeedback(title: []const u8, tags: []const u8, description: []const u8, target_branch: []const u8) ui.Session.FormFeedback { │ │432 + return .{ .patch = .{ .failure = .invalid_target_branch, .fields = .{ │ │433 + .title = title, │ │434 + .tags = tags, │ │435 + .description = description, │ │436 + .target_branch = target_branch, │ │437 + } } }; │ │438 } │ │439 │ │440 fn handleLogout(request: *std.http.Server.Request, base: []const u8, session_store: SessionStore) !void { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │607 }; │ │608 const form_location = try std.fmt.allocPrint(allocator, "{s}/{s}/new", .{ base, list_name }); │ │609 defer allocator.free(form_location); │ │610 + if (!evt.titleValid(title)) { │ │611 + return respondThreadFormFailure(request, allocator, host, form_location, requiredTitleFeedback(kind, title, tags, description, target_branch));│ │612 + } │ │613 try request.respond("", .{ │ │614 .status = .see_other, │ │615 .extra_headers = &.{.{ .name = "location", .value = form_location }}, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │632 if (!try request_repo.source.hasBranch(io, allocator, target_branch)) { │ │633 const form_location = try std.fmt.allocPrint(allocator, "{s}/patches/new", .{base}); │ │634 defer allocator.free(form_location); │ │565 - try request.respond("", .{ .status = .see_other, .extra_headers = &.{.{ .name = "location", .value = form_location }} }); │ │566 - return; │ │635 + return respondThreadFormFailure(request, allocator, host, form_location, invalidTargetBranchFeedback(title, tags, description, target_branch));│ │636 } │ │637 const repo_id = evt.parseEventId(std.fs.path.basename(request_repo.source.path)) catch return respondRemoveNotFound(request); │ │638 var admin_repo = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1413 if (!valid) { │ │1414 const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); │ │1415 defer allocator.free(form_location); │ │1416 + if (!evt.titleValid(title)) { │ │1417 + return respondThreadFormFailure(request, allocator, host, form_location, requiredTitleFeedback(parts.thread_kind, title, tags, description, target_branch));│ │1418 + } │ │1419 try request.respond("", .{ │ │1420 .status = .see_other, │ │1421 .extra_headers = &.{.{ .name = "location", .value = form_location }}, │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1434 if (!try request_repo.source.hasBranch(io, allocator, target_branch)) { │ │1435 const form_location = try std.fmt.allocPrint(allocator, "{s}/edit", .{base}); │ │1436 defer allocator.free(form_location); │ │1365 - try request.respond("", .{ .status = .see_other, .extra_headers = &.{.{ .name = "location", .value = form_location }} }); │ │1366 - return; │ │1437 + return respondThreadFormFailure(request, allocator, host, form_location, invalidTargetBranchFeedback(title, tags, description, target_branch));│ │1438 } │ │1439 const repo_id = evt.parseEventId(std.fs.path.basename(request_repo.source.path)) catch return respondRemoveNotFound(request); │ │1440 var admin_repo = try rp.Repo(.xit, evt.admin_repo_opts).open(io, allocator, .{ .path = remote.admin_repo_path }); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1630 }.isUnreserved); │ │1631 const cookie = try std.fmt.allocPrint( │ │1632 allocator, │ │1562 - sync_failure_cookie ++ "={s}; Path=/; HttpOnly; SameSite=Strict", │ │1633 + local_flash_cookie ++ "=" ++ local_sync_failure_prefix ++ "{s}; Path=/; HttpOnly; SameSite=Strict",│ │1634 .{encoded.written()}, │ │1635 ); │ │1636 defer allocator.free(cookie); │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────╮ │1648 .status = .see_other, │ │1649 .extra_headers = &.{ │ │1650 .{ .name = "location", .value = "/events" }, │ │1580 - .{ .name = "set-cookie", .value = sync_failure_cookie ++ "=; Path=/; Max-Age=0" },│ │1651 + .{ .name = "set-cookie", .value = local_flash_cookie ++ "=; Path=/; Max-Age=0" }, │ │1652 }, │ │1653 }); │ │1654 } │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯