| @@ -4,7 +4,6 @@ import ( |
| 4 | 4 | "fmt" |
| 5 | 5 | "net/http" |
| 6 | 6 | "slices" |
| 7 | | "strconv" |
| 8 | 7 | "strings" |
| 9 | 8 | "time" |
| 10 | 9 | |
| @@ -13,6 +12,7 @@ import ( |
| 13 | 12 | "gitbay.org/gitbay/internal/control" |
| 14 | 13 | "gitbay.org/gitbay/internal/gitutil" |
| 15 | 14 | "gitbay.org/gitbay/internal/policy" |
| 15 | "gitbay.org/gitbay/internal/protocol" |
| 16 | 16 | "gitbay.org/gitbay/internal/store" |
| 17 | 17 | ) |
| 18 | 18 | |
| @@ -133,44 +133,17 @@ func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.Use |
| 133 | 133 | } |
| 134 | 134 | |
| 135 | 135 | func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 136 | | name := r.FormValue("name") |
| 137 | | visibility := "public" |
| 138 | | if r.FormValue("visibility") == "private" { |
| 139 | | visibility = "private" |
| 140 | | } |
| 141 | | fail := func(msg string) { s.renderNewRepo(w, u, msg) } |
| 142 | | if err := policy.ValidateName(name); err != nil { |
| 143 | | fail(err.Error()) |
| 144 | | return |
| 145 | | } |
| 146 | | // Owner: yourself, or an org you admin — same rule as repo create. |
| 147 | 136 | owner := r.FormValue("owner") |
| 148 | | ownerKind, ownerID := "user", u.ID |
| 149 | 137 | if owner == "" { |
| 150 | 138 | owner = u.Username |
| 151 | 139 | } |
| 152 | | if owner != u.Username { |
| 153 | | org, err := s.st.OrgByName(owner) |
| 154 | | if err != nil { |
| 155 | | fail("no such organization") |
| 156 | | return |
| 157 | | } |
| 158 | | role, _ := s.st.OrgRole(org.ID, u.ID) |
| 159 | | if role != "admin" { |
| 160 | | fail("only admins of " + owner + " can create repositories there") |
| 161 | | return |
| 162 | | } |
| 163 | | ownerKind, ownerID = "org", org.ID |
| 164 | | } |
| 165 | | id, err := s.st.CreateRepo(ownerKind, ownerID, name, visibility) |
| 166 | | if err != nil { |
| 167 | | fail(err.Error()) |
| 168 | | return |
| 140 | name := r.FormValue("name") |
| 141 | argv := []string{"repo", "create", owner + "/" + name} |
| 142 | if r.FormValue("visibility") == "private" { |
| 143 | argv = append(argv, "--private") |
| 169 | 144 | } |
| 170 | | dir := control.RepoDir(s.cfg.Server.Root, owner, name) |
| 171 | | if err := gitutil.InitBare(dir, "main", control.HooksDir(s.cfg.Server.Root)); err != nil { |
| 172 | | s.st.DeleteRepo(id) |
| 173 | | fail("initializing repository failed") |
| 145 | if _, msg, ok := s.runControl(u, argv); !ok { |
| 146 | s.renderNewRepo(w, u, msg) |
| 174 | 147 | return |
| 175 | 148 | } |
| 176 | 149 | http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther) |
| @@ -288,155 +261,91 @@ func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store |
| 288 | 261 | }{p, body, tplName, templates}) |
| 289 | 262 | } |
| 290 | 263 | |
| 264 | // Issue and merge request writes run the command the CLI runs, so the |
| 265 | // archived check, notifications, body format and the audit entry have one |
| 266 | // implementation. Bodies travel on stdin, the way --file - does. |
| 267 | |
| 291 | 268 | func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 292 | | repo, ok := s.repoForUser(w, r, u, policy.CanRead) |
| 293 | | if !ok { |
| 294 | | return |
| 295 | | } |
| 269 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 296 | 270 | title := strings.TrimSpace(r.FormValue("title")) |
| 297 | | if title == "" { |
| 298 | | http.Error(w, "title required", http.StatusBadRequest) |
| 271 | code, data, msg := s.dispatchJSON(u, []string{"issue", "create", repoPath, "--title", title, "--file", "-"}, r.FormValue("body")) |
| 272 | if code != protocol.ExitOK { |
| 273 | http.Error(w, msg, statusForExit(code)) |
| 299 | 274 | return |
| 300 | 275 | } |
| 301 | | n, err := s.st.CreateIssue(repo.ID, u.ID, title, r.FormValue("body"), "md") |
| 302 | | if err != nil { |
| 303 | | http.Error(w, "internal error", http.StatusInternalServerError) |
| 304 | | return |
| 305 | | } |
| 306 | | s.st.RecordEvent(repo.ID, u.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n)) |
| 307 | | // Labels need write access, matching the SSH rule; ignored otherwise. |
| 308 | | if labels := strings.Fields(r.FormValue("labels")); len(labels) > 0 { |
| 309 | | grant, _ := s.st.AccessRole(repo.ID, u.ID) |
| 310 | | if policy.CanWrite(u, repo, grant) { |
| 311 | | if iss, err := s.st.IssueByNumber(repo.ID, n); err == nil { |
| 312 | | for _, l := range labels { |
| 313 | | s.st.SetIssueLabel(repo.ID, iss.ID, l, true) |
| 314 | | } |
| 315 | | } |
| 316 | | } |
| 276 | n := int64(data["number"].(float64)) |
| 277 | // Labels need write access, matching the SSH rule; the command refuses |
| 278 | // otherwise and the issue stands without them. |
| 279 | if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 { |
| 280 | s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...)) |
| 317 | 281 | } |
| 318 | | http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther) |
| 282 | http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther) |
| 319 | 283 | } |
| 320 | 284 | |
| 321 | 285 | // issueEditSubmit edits title/body (author or write) and, with write |
| 322 | 286 | // access, replaces the label set. |
| 323 | 287 | func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 324 | | repo, ok := s.repoForUser(w, r, u, policy.CanRead) |
| 325 | | if !ok { |
| 326 | | return |
| 327 | | } |
| 328 | | n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64) |
| 329 | | iss, err := s.st.IssueByNumber(repo.ID, n) |
| 330 | | if err != nil { |
| 331 | | http.NotFound(w, r) |
| 332 | | return |
| 333 | | } |
| 334 | | grant, _ := s.st.AccessRole(repo.ID, u.ID) |
| 335 | | canWrite := policy.CanWrite(u, repo, grant) |
| 336 | | if iss.Author != u.Username && !canWrite { |
| 337 | | http.Error(w, "only the author or users with write access can edit", http.StatusForbidden) |
| 338 | | return |
| 339 | | } |
| 288 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 289 | n := r.PathValue("n") |
| 340 | 290 | title := strings.TrimSpace(r.FormValue("title")) |
| 341 | | if title == "" { |
| 342 | | http.Error(w, "title required", http.StatusBadRequest) |
| 291 | code, _, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body")) |
| 292 | if code != protocol.ExitOK { |
| 293 | http.Error(w, msg, statusForExit(code)) |
| 343 | 294 | return |
| 344 | 295 | } |
| 345 | | body := r.FormValue("body") |
| 346 | | if err := s.st.UpdateIssueText(iss.ID, &title, &body, nil); err != nil { |
| 347 | | http.Error(w, "internal error", http.StatusInternalServerError) |
| 348 | | return |
| 296 | var cur struct { |
| 297 | Labels []string `json:"labels"` |
| 349 | 298 | } |
| 350 | | if canWrite { |
| 299 | if _, ok := s.runControlInto(u, []string{"issue", "show", repoPath, n}, &cur); ok { |
| 351 | 300 | want := strings.Fields(r.FormValue("labels")) |
| 352 | | for _, l := range iss.Labels { |
| 301 | var args []string |
| 302 | for _, l := range cur.Labels { |
| 353 | 303 | if !slices.Contains(want, l) { |
| 354 | | s.st.SetIssueLabel(repo.ID, iss.ID, l, false) |
| 304 | args = append(args, "--remove", l) |
| 355 | 305 | } |
| 356 | 306 | } |
| 357 | 307 | for _, l := range want { |
| 358 | | s.st.SetIssueLabel(repo.ID, iss.ID, l, true) |
| 308 | if !slices.Contains(cur.Labels, l) { |
| 309 | args = append(args, "--add", l) |
| 310 | } |
| 311 | } |
| 312 | if len(args) > 0 { |
| 313 | s.runControl(u, append([]string{"issue", "label", repoPath, n}, args...)) |
| 359 | 314 | } |
| 360 | 315 | } |
| 361 | | http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther) |
| 316 | http.Redirect(w, r, fmt.Sprintf("/%s/issues/%s", repoPath, n), http.StatusSeeOther) |
| 362 | 317 | } |
| 363 | 318 | |
| 364 | 319 | // mrEditSubmit edits an MR's title/body (author or write). |
| 365 | 320 | func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 366 | | repo, ok := s.repoForUser(w, r, u, policy.CanRead) |
| 367 | | if !ok { |
| 368 | | return |
| 369 | | } |
| 370 | | n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64) |
| 371 | | m, err := s.st.MRByNumber(repo.ID, n) |
| 372 | | if err != nil { |
| 373 | | http.NotFound(w, r) |
| 374 | | return |
| 375 | | } |
| 376 | | grant, _ := s.st.AccessRole(repo.ID, u.ID) |
| 377 | | if m.Author != u.Username && !policy.CanWrite(u, repo, grant) { |
| 378 | | http.Error(w, "only the author or users with write access can edit", http.StatusForbidden) |
| 379 | | return |
| 380 | | } |
| 321 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 322 | n := r.PathValue("n") |
| 381 | 323 | title := strings.TrimSpace(r.FormValue("title")) |
| 382 | | if title == "" { |
| 383 | | http.Error(w, "title required", http.StatusBadRequest) |
| 324 | code, _, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body")) |
| 325 | if code != protocol.ExitOK { |
| 326 | http.Error(w, msg, statusForExit(code)) |
| 384 | 327 | return |
| 385 | 328 | } |
| 386 | | body := r.FormValue("body") |
| 387 | | if err := s.st.UpdateMRText(m.ID, &title, &body, nil); err != nil { |
| 388 | | http.Error(w, "internal error", http.StatusInternalServerError) |
| 389 | | return |
| 390 | | } |
| 391 | | http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther) |
| 329 | http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%s", repoPath, n), http.StatusSeeOther) |
| 392 | 330 | } |
| 393 | 331 | |
| 394 | 332 | func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 395 | | repo, ok := s.repoForUser(w, r, u, policy.CanRead) |
| 396 | | if !ok { |
| 397 | | return |
| 398 | | } |
| 399 | | n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64) |
| 400 | | iss, err := s.st.IssueByNumber(repo.ID, n) |
| 401 | | if err != nil { |
| 402 | | http.NotFound(w, r) |
| 403 | | return |
| 404 | | } |
| 405 | | body := strings.TrimSpace(r.FormValue("body")) |
| 406 | | if body == "" { |
| 407 | | http.Error(w, "empty comment", http.StatusBadRequest) |
| 408 | | return |
| 409 | | } |
| 410 | | if err := s.st.AddIssueComment(iss.ID, u.ID, body, "md"); err != nil { |
| 411 | | http.Error(w, "internal error", http.StatusInternalServerError) |
| 412 | | return |
| 413 | | } |
| 414 | | s.st.RecordEvent(repo.ID, u.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, n)) |
| 415 | | http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repo.Path(), n), http.StatusSeeOther) |
| 333 | s.commentSubmit(w, r, u, "issue", "issues") |
| 416 | 334 | } |
| 417 | 335 | |
| 418 | 336 | func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 419 | | repo, ok := s.repoForUser(w, r, u, policy.CanRead) |
| 420 | | if !ok { |
| 421 | | return |
| 422 | | } |
| 423 | | n, _ := strconv.ParseInt(r.PathValue("n"), 10, 64) |
| 424 | | m, err := s.st.MRByNumber(repo.ID, n) |
| 425 | | if err != nil { |
| 426 | | http.NotFound(w, r) |
| 427 | | return |
| 428 | | } |
| 429 | | body := strings.TrimSpace(r.FormValue("body")) |
| 430 | | if body == "" { |
| 431 | | http.Error(w, "empty comment", http.StatusBadRequest) |
| 432 | | return |
| 433 | | } |
| 434 | | if err := s.st.AddMRComment(m.ID, u.ID, body, "md"); err != nil { |
| 435 | | http.Error(w, "internal error", http.StatusInternalServerError) |
| 337 | s.commentSubmit(w, r, u, "mr", "mrs") |
| 338 | } |
| 339 | |
| 340 | func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) { |
| 341 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 342 | n := r.PathValue("n") |
| 343 | code, _, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body"))) |
| 344 | if code != protocol.ExitOK { |
| 345 | http.Error(w, msg, statusForExit(code)) |
| 436 | 346 | return |
| 437 | 347 | } |
| 438 | | s.st.RecordEvent(repo.ID, u.ID, "mr.commented", fmt.Sprintf(`{"number":%d}`, n)) |
| 439 | | http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%d", repo.Path(), n), http.StatusSeeOther) |
| 348 | http.Redirect(w, r, fmt.Sprintf("/%s/%s/%s", repoPath, segment, n), http.StatusSeeOther) |
| 440 | 349 | } |
| 441 | 350 | |
| 442 | 351 | type editPage struct { |