╭ changed ──╮ │src/web.zig│ ╰───────────╯ ╭───────────╮ │← all files│ ╰───────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────╮ │17 // the session cookie a login or a claimed auto-login sets; both must scope │ │18 // it the same way. │ │19 const session_cookie_fmt = cookie_name ++ "={s}; Path=/; HttpOnly; SameSite=Strict";│ │20 -// flash cookie for surfacing the outcome of the most recent /login POST. │ │21 -// set on the failure redirect, read and immediately expired on the next │ │22 -// GET / so refreshing the page doesn't keep showing the error. │ │23 -const login_failure_cookie = "haxy_login_failure"; │ │24 -const sync_failure_cookie = "haxy_sync_failure"; │ │20 +const form_flash_cookie = "haxy_form_flash"; │ │21 +const local_flash_cookie = "haxy_local_flash"; │ │22 +const local_required_title_prefix = "required_title:"; │ │23 +const local_sync_failure_prefix = "sync:"; │ │24 │ │25 const Embed = struct { │ │26 path: []const u8, │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────╮ │210 // accounts, so it is always logged out. │ │211 var user_id_buf: [evt.event_id_size]u8 = undefined; │ │212 var user_id: ?[]const u8 = null; │ │214 - var login_failure: ?ui.Home.Auth.Login.Failure = null; │ │213 + var form_arena = std.heap.ArenaAllocator.init(allocator); │ │214 + defer form_arena.deinit(); │ │215 + var form_feedback: ?ui.Session.FormFeedback = null; │ │216 + var form_cookie_seen = false; │ │217 + var local_flash_seen = false; │ │218 var sync_failure: ?[]const u8 = null; │ │219 var sync_failure_allocated: ?[]u8 = null; │ │220 defer if (sync_failure_allocated) |value| allocator.free(value);│ ╰─────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │236 user_id = user_id_buf[0..evt.event_id_size]; │ │237 } │ │238 } │ │236 - login_failure = if (getCookieValue(request, login_failure_cookie)) |raw| │ │237 - if (std.mem.eql(u8, raw, "unknown_user")) │ │238 - .unknown_user │ │239 - else if (std.mem.eql(u8, raw, "wrong_password")) │ │240 - .wrong_password │ │241 - else │ │242 - null │ │243 - else │ │244 - null; │ │239 + if (getCookieValue(request, form_flash_cookie)) |token| { │ │240 + form_cookie_seen = true; │ │241 + form_feedback = remote.session_store.takeFlash(form_arena.allocator(), token); │ │242 + } │ │243 }, │ │246 - .local => if (getCookieValue(request, sync_failure_cookie)) |raw| { │ │247 - const value = try allocator.dupe(u8, raw); │ │248 - sync_failure_allocated = value; │ │249 - sync_failure = std.Uri.percentDecodeInPlace(value); │ │244 + .local => { │ │245 + if (getCookieValue(request, local_flash_cookie)) |raw| { │ │246 + local_flash_seen = true; │ │247 + if (std.mem.startsWith(u8, raw, local_required_title_prefix)) { │ │248 + const tag = std.meta.stringToEnum(std.meta.Tag(ui.Session.FormFeedback), raw[local_required_title_prefix.len..]);│ │249 + form_feedback = if (tag) |feedback_tag| switch (feedback_tag) { │ │250 + .issue => .{ .issue = .{ .failure = .required_title } }, │ │251 + .patch => .{ .patch = .{ .failure = .required_title } }, │ │252 + .discussion => .{ .discussion = .{ .failure = .required_title } }, │ │253 + else => null, │ │254 + } else null; │ │255 + } else if (std.mem.startsWith(u8, raw, local_sync_failure_prefix)) { │ │256 + const value = try allocator.dupe(u8, raw[local_sync_failure_prefix.len..]); │ │257 + sync_failure_allocated = value; │ │258 + sync_failure = std.Uri.percentDecodeInPlace(value); │ │259 + } │ │260 + } │ │261 }, │ │262 } │ │263 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────╮ │267 }; │ │268 const html = renderIndexHtml(io, allocator, host, .{│ │269 .user_id = user_id, │ │259 - .login_failure = login_failure, │ │270 + .form_feedback = form_feedback, │ │271 .sync_failure = sync_failure, │ │272 .current_page = current_page, │ │273 .is_local = host == .local, │ ╰─────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │286 }; │ │287 defer allocator.free(html); │ │288 │ │289 + const expired_form_cookie = if (form_cookie_seen) │ │290 + try std.fmt.allocPrint(allocator, form_flash_cookie ++ "=; Path={s}; Max-Age=0", .{path}) │ │291 + else │ │292 + null; │ │293 + defer if (expired_form_cookie) |cookie| allocator.free(cookie); │ │294 var header_buf: [4]std.http.Header = undefined; │ │295 var headers: std.ArrayList(std.http.Header) = .initBuffer(&header_buf); │ │296 headers.appendAssumeCapacity(.{ .name = "content-type", .value = "text/html; charset=utf-8" });│ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │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 }}, │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯