vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 1 | package web |
| 2 | |
| 3 | // Editing what an issue or a change says. |
| 4 | // |
| 5 | // The operation is the same on both surfaces: replace the title or the |
| 6 | // description through the metadata writer, leaving the discussion, labels, |
| 7 | // assignees, and review state alone. The two surfaces share the in-place forms |
| 8 | // and their refusal shapes; the standalone /edit page stays as the no-script |
| 9 | // fallback. |
| 10 | |
| 11 | import ( |
| 12 | "errors" |
| 13 | "net/http" |
| 14 | "strings" |
| 15 | |
| 16 | voleid "rodent.software/vole/core/id" |
| 17 | "rodent.software/vole/forge/index" |
| 18 | "rodent.software/vole/forge/repo" |
| 19 | "rodent.software/vole/metadata" |
| 20 | ) |
| 21 | |
| 22 | // fieldEdit is one in-place field: whether it is open, what the reader typed, and |
| 23 | // why a write was refused. The title and description carry one each because they |
| 24 | // are separate controls now; saving one must not replace or reopen the other. |
| 25 | type fieldEdit struct { |
| 26 | Active bool |
| 27 | Value string |
| 28 | Error string |
| 29 | } |
| 30 | |
| 31 | // detailsEdit is the in-place state of the heading and description card on an |
| 32 | // issue or change page. A full page leaves it zero. |
| 33 | type detailsEdit struct { |
| 34 | Title fieldEdit |
| 35 | Description fieldEdit |
| 36 | // Inline says this page carries the title and description forms. The change |
| 37 | // head is also used on the Files and Runs tabs, which do not, so their title |
| 38 | // pencil stays an ordinary link to the standalone page. |
| 39 | Inline bool |
| 40 | } |
| 41 | |
| 42 | // issueDetailsView is the data for an issue page fragment: the heading or the |
| 43 | // description card, depending on which template renders it. |
| 44 | func (w *Web) issueDetailsView(r *http.Request, m *repo.Meta, iss *metadata.Issue, edit detailsEdit) issuePageView { |
| 45 | sidebar := w.issueSidebar(r, m, iss, "") |
| 46 | sidebar.Details = edit |
| 47 | return issuePageView{ |
| 48 | issueSidebarView: sidebar, |
| 49 | Description: w.renderMarkdown(m.Name, iss.Description), |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // requestDetailsView is the change page's counterpart to issueDetailsView. |
| 54 | func (w *Web) requestDetailsView(r *http.Request, m *repo.Meta, req *metadata.Request, edit detailsEdit) requestPageView { |
| 55 | edit.Inline = true |
| 56 | sidebar := w.requestSidebar(r, m, req, "", "") |
| 57 | sidebar.Details = edit |
| 58 | return requestPageView{ |
| 59 | requestSidebarView: sidebar, |
| 60 | Tab: "conversation", |
| 61 | Description: w.renderMarkdown(m.Name, req.Description), |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // detailsForm is the data the edit page and a refused write share: the prose to |
| 66 | // put back in the fields, the record it belongs to, and the reason it did not |
| 67 | // save. Keeping the typed values here is what lets a correction keep its work. |
| 68 | type detailsForm struct { |
| 69 | Page |
| 70 | Name string |
| 71 | ID string |
| 72 | Title string |
| 73 | Description string |
| 74 | Error string |
| 75 | } |
| 76 | |
| 77 | // detailsData builds the form and names the page the same way the item's own |
| 78 | // page does: the number and current title first, then Edit, then the repository. |
| 79 | func (w *Web) detailsData(r *http.Request, m *repo.Meta, id, title, description, errMsg string) detailsForm { |
| 80 | number := 0 |
| 81 | if w.Index != nil { |
| 82 | if err := w.ensureIndex(memoOf(r), m); err == nil { |
| 83 | number, _, _ = w.Index.Number(m.ID, id) |
| 84 | } |
| 85 | } |
| 86 | return detailsForm{ |
| 87 | Page: w.page(r, numberedTitle(number, title, "edit", m.Name)), |
| 88 | Name: m.Name, |
| 89 | ID: id, |
| 90 | Title: title, |
| 91 | Description: description, |
| 92 | Error: errMsg, |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | // IssueEditPage serves the form that rewrites an issue's title and description. |
| 97 | // Only its author and a maintainer may open it, which is the same rule the write |
| 98 | // handler and the push boundary apply. |
| 99 | func (w *Web) IssueEditPage(rw http.ResponseWriter, r *http.Request) { |
| 100 | m, ok := w.open(rw, r) |
| 101 | if !ok { |
| 102 | return |
| 103 | } |
| 104 | if _, ok := w.requireHTMLUser(rw, r); !ok { |
| 105 | return |
| 106 | } |
| 107 | id := r.PathValue("id") |
| 108 | if !voleid.Valid(id) { |
| 109 | w.notFound(rw, r) |
| 110 | return |
| 111 | } |
| 112 | iss, err := w.issueAt(memoOf(r), m, id) |
| 113 | if err != nil { |
| 114 | w.notFound(rw, r) |
| 115 | return |
| 116 | } |
| 117 | acl, _ := m.ACL() |
| 118 | if !canTriage(acl, w.accountName(r), iss.Author) { |
| 119 | http.Error(rw, "only the author or a maintainer may edit this issue", http.StatusForbidden) |
| 120 | return |
| 121 | } |
| 122 | w.render(rw, "issue_edit", w.detailsData(r, m, id, iss.Title, iss.Description, ""), 0) |
| 123 | } |
| 124 | |
| 125 | // UpdateIssueDetails walks the two in-place forms on the issue page and the |
| 126 | // combined form on the standalone page. A title-only or description-only write |
| 127 | // leaves the other field as the file has it now, so a draft in the other editor |
| 128 | // is not overwritten by a save the reader did not make. |
| 129 | func (w *Web) UpdateIssueDetails(rw http.ResponseWriter, r *http.Request) { |
| 130 | ctx, ok := w.beginWrite(rw, r, maxFormBody) |
| 131 | if !ok { |
| 132 | return |
| 133 | } |
| 134 | m := ctx.Repo |
| 135 | id := r.PathValue("id") |
| 136 | iss, err := index.LoadIssue(m, id) |
| 137 | if err != nil { |
| 138 | w.notFound(rw, r) |
| 139 | return |
| 140 | } |
| 141 | if !ctx.canTriage(iss.Author) { |
| 142 | w.refuse(rw, r, "you do not have permission to edit this issue") |
| 143 | return |
| 144 | } |
| 145 | field := strings.TrimSpace(r.PostFormValue("field")) |
| 146 | title := strings.TrimSpace(r.PostFormValue("title")) |
| 147 | description := strings.TrimSpace(r.PostFormValue("body")) |
| 148 | titleFragment := func(msg string) (string, any) { |
| 149 | return "issue_head", w.issueDetailsView(r, m, iss, detailsEdit{ |
| 150 | Title: fieldEdit{Active: true, Value: title, Error: msg}, |
| 151 | }) |
| 152 | } |
| 153 | bodyFragment := func(msg string) (string, any) { |
| 154 | return "issue_description", w.issueDetailsView(r, m, iss, detailsEdit{ |
| 155 | Description: fieldEdit{Active: true, Value: description, Error: msg}, |
| 156 | }) |
| 157 | } |
| 158 | fullFragment := func(msg string) (string, any) { |
| 159 | return "issue_edit", w.detailsData(r, m, id, title, description, msg) |
| 160 | } |
| 161 | switch field { |
| 162 | case "title": |
| 163 | if title == "" { |
| 164 | w.fail(rw, r, http.StatusBadRequest, "an issue needs a title", titleFragment) |
| 165 | return |
| 166 | } |
| 167 | updated, err := metadata.UpdateIssue(m.Git(), id, func(cur *metadata.Issue) error { |
| 168 | if cur.Title == title { |
| 169 | return metadata.ErrNoChange |
| 170 | } |
| 171 | cur.Title = title |
| 172 | return nil |
| 173 | }, ctx.Name) |
| 174 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { |
| 175 | w.fail(rw, r, http.StatusBadRequest, "cannot update the issue: "+err.Error(), titleFragment) |
| 176 | return |
| 177 | } |
| 178 | w.audit("web.issue.edit", ctx.Name, m.Name, id) |
| 179 | if !htmxRequest(r) { |
| 180 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther) |
| 181 | return |
| 182 | } |
| 183 | if updated == nil { |
| 184 | updated = iss |
| 185 | } |
| 186 | w.render(rw, "issue_head", w.issueDetailsView(r, m, updated, detailsEdit{}), 0) |
| 187 | case "body": |
| 188 | updated, err := metadata.UpdateIssue(m.Git(), id, func(cur *metadata.Issue) error { |
| 189 | if cur.Description == description { |
| 190 | return metadata.ErrNoChange |
| 191 | } |
| 192 | cur.Description = description |
| 193 | return nil |
| 194 | }, ctx.Name) |
| 195 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { |
| 196 | w.fail(rw, r, http.StatusBadRequest, "cannot update the issue: "+err.Error(), bodyFragment) |
| 197 | return |
| 198 | } |
| 199 | w.audit("web.issue.edit", ctx.Name, m.Name, id) |
| 200 | if !htmxRequest(r) { |
| 201 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther) |
| 202 | return |
| 203 | } |
| 204 | if updated == nil { |
| 205 | updated = iss |
| 206 | } |
| 207 | w.render(rw, "issue_description", w.issueDetailsView(r, m, updated, detailsEdit{}), 0) |
| 208 | default: |
| 209 | if field != "" { |
| 210 | w.fail(rw, r, http.StatusBadRequest, "unknown part of the issue to edit", nil) |
| 211 | return |
| 212 | } |
| 213 | if title == "" { |
| 214 | w.fail(rw, r, http.StatusBadRequest, "an issue needs a title", fullFragment) |
| 215 | return |
| 216 | } |
| 217 | _, err := metadata.UpdateIssue(m.Git(), id, func(cur *metadata.Issue) error { |
| 218 | if cur.Title == title && cur.Description == description { |
| 219 | return metadata.ErrNoChange |
| 220 | } |
| 221 | cur.Title, cur.Description = title, description |
| 222 | return nil |
| 223 | }, ctx.Name) |
| 224 | if err != nil && !errors.Is(err, metadata.ErrNoChange) { |
| 225 | w.fail(rw, r, http.StatusBadRequest, "cannot update the issue: "+err.Error(), fullFragment) |
| 226 | return |
| 227 | } |
| 228 | w.audit("web.issue.edit", ctx.Name, m.Name, id) |
| 229 | http.Redirect(rw, r, "/"+m.Name+"/issues/"+id, http.StatusSeeOther) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | // RequestEditPage serves the form that rewrites a change's title and description. |
| 234 | // The prose is not part of what an approval names, so an open, closed, or merged |
| 235 | // change may all be corrected by its author or a maintainer. |
| 236 | func (w *Web) RequestEditPage(rw http.ResponseWriter, r *http.Request) { |
| 237 | m, ok := w.open(rw, r) |
| 238 | if !ok { |
| 239 | return |
| 240 | } |
| 241 | if _, ok := w.requireHTMLUser(rw, r); !ok { |
| 242 | return |
| 243 | } |
| 244 | id := r.PathValue("id") |
| 245 | if !voleid.Valid(id) { |
| 246 | w.notFound(rw, r) |
| 247 | return |
| 248 | } |
| 249 | req, err := w.requestAt(memoOf(r), m, id) |
| 250 | if err != nil { |
| 251 | w.notFound(rw, r) |
| 252 | return |
| 253 | } |
| 254 | acl, _ := m.ACL() |
| 255 | if !canTriage(acl, w.accountName(r), req.Author) { |
| 256 | http.Error(rw, "only the author or a maintainer may edit this change", http.StatusForbidden) |
| 257 | return |
| 258 | } |
| 259 | w.render(rw, "request_edit", w.detailsData(r, m, id, req.Title, req.Description, ""), 0) |
| 260 | } |
| 261 | |
| 262 | // UpdateRequestDetails walks the two in-place forms on the change page and the |
| 263 | // combined form on the standalone page. The writer validates the whole record, |
| 264 | // and the tree is checked afterwards like every other write that touches a |
| 265 | // change. |
| 266 | func (w *Web) UpdateRequestDetails(rw http.ResponseWriter, r *http.Request) { |
| 267 | ctx, ok := w.beginWrite(rw, r, maxFormBody) |
| 268 | if !ok { |
| 269 | return |
| 270 | } |
| 271 | m := ctx.Repo |
| 272 | id := r.PathValue("id") |
| 273 | req, err := w.requestAt(memoOf(r), m, id) |
| 274 | if err != nil { |
| 275 | w.notFound(rw, r) |
| 276 | return |
| 277 | } |
| 278 | if !ctx.canTriage(req.Author) { |
| 279 | w.refuse(rw, r, "you do not have permission to edit this change") |
| 280 | return |
| 281 | } |
| 282 | field := strings.TrimSpace(r.PostFormValue("field")) |
| 283 | title := strings.TrimSpace(r.PostFormValue("title")) |
| 284 | description := strings.TrimSpace(r.PostFormValue("body")) |
| 285 | titleFragment := func(msg string) (string, any) { |
| 286 | return "request_title_head", w.requestDetailsView(r, m, req, detailsEdit{ |
| 287 | Title: fieldEdit{Active: true, Value: title, Error: msg}, |
| 288 | }) |
| 289 | } |
| 290 | bodyFragment := func(msg string) (string, any) { |
| 291 | return "request_description", w.requestDetailsView(r, m, req, detailsEdit{ |
| 292 | Description: fieldEdit{Active: true, Value: description, Error: msg}, |
| 293 | }) |
| 294 | } |
| 295 | fullFragment := func(msg string) (string, any) { |
| 296 | return "request_edit", w.detailsData(r, m, id, title, description, msg) |
| 297 | } |
| 298 | switch field { |
| 299 | case "title": |
| 300 | if title == "" { |
| 301 | w.fail(rw, r, http.StatusBadRequest, "a change needs a title", titleFragment) |
| 302 | return |
| 303 | } |
| 304 | updated, err := metadata.SetRequestDetails(m.Git(), id, title, req.Description, ctx.Name) |
| 305 | if err != nil { |
| 306 | w.fail(rw, r, http.StatusBadRequest, "cannot update the change: "+err.Error(), titleFragment) |
| 307 | return |
| 308 | } |
| 309 | w.audit("web.request.edit", ctx.Name, m.Name, id) |
| 310 | w.checkRequestsAfterEdit(m, id) |
| 311 | if !htmxRequest(r) { |
| 312 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
| 313 | return |
| 314 | } |
| 315 | if updated == nil { |
| 316 | updated = req |
| 317 | } |
| 318 | w.render(rw, "request_title_head", w.requestDetailsView(r, m, updated, detailsEdit{}), 0) |
| 319 | case "body": |
| 320 | updated, err := metadata.SetRequestDetails(m.Git(), id, req.Title, description, ctx.Name) |
| 321 | if err != nil { |
| 322 | w.fail(rw, r, http.StatusBadRequest, "cannot update the change: "+err.Error(), bodyFragment) |
| 323 | return |
| 324 | } |
| 325 | w.audit("web.request.edit", ctx.Name, m.Name, id) |
| 326 | w.checkRequestsAfterEdit(m, id) |
| 327 | if !htmxRequest(r) { |
| 328 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
| 329 | return |
| 330 | } |
| 331 | if updated == nil { |
| 332 | updated = req |
| 333 | } |
| 334 | w.render(rw, "request_description", w.requestDetailsView(r, m, updated, detailsEdit{}), 0) |
| 335 | default: |
| 336 | if field != "" { |
| 337 | w.fail(rw, r, http.StatusBadRequest, "unknown part of the change to edit", nil) |
| 338 | return |
| 339 | } |
| 340 | if title == "" { |
| 341 | w.fail(rw, r, http.StatusBadRequest, "a change needs a title", fullFragment) |
| 342 | return |
| 343 | } |
| 344 | if _, err := metadata.SetRequestDetails(m.Git(), id, title, description, ctx.Name); err != nil { |
| 345 | w.fail(rw, r, http.StatusBadRequest, "cannot update the change: "+err.Error(), fullFragment) |
| 346 | return |
| 347 | } |
| 348 | w.audit("web.request.edit", ctx.Name, m.Name, id) |
| 349 | w.checkRequestsAfterEdit(m, id) |
| 350 | http.Redirect(rw, r, "/"+m.Name+"/changes/"+id, http.StatusSeeOther) |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | // checkRequestsAfterEdit logs a request tree the writer left invalid. It is a |
| 355 | // report, not a refusal: the edit committed, and the next write will say what is |
| 356 | // wrong. |
| 357 | func (w *Web) checkRequestsAfterEdit(m *repo.Meta, id string) { |
| 358 | if problems := metadata.ValidateRequests(m.Git(), metadata.Ref); len(problems) > 0 { |
| 359 | w.Logger("web: request edit %s left the request tree invalid: %v", id, problems) |
| 360 | } |
| 361 | } |
| 362 |