GameOver1337 / GitClone Public
К репозиторию
32.1 KB
internal/handlers/handlers.go
32.1 KB
1package handlers
2
3import (
4 "fmt"
5 "html/template"
6 "io/fs"
7 "log"
8 "net/http"
9 "net/url"
10 "os"
11 "path"
12 "path/filepath"
13 "regexp"
14 "strconv"
15 "strings"
16 "time"
17
18 "github.com/clancore/repo/internal/auth"
19 "github.com/clancore/repo/internal/db"
20 "github.com/clancore/repo/internal/models"
21 "github.com/clancore/repo/internal/services"
22)
23
24// Templates — набор HTML-шаблонов. Каждый шаблон собирается из layout.html + страницы,
25// чтобы страницы определяли только {{define "title"}} и {{define "content"}}.
26type Templates struct {
27 pages map[string]*template.Template
28}
29
30var tmplFuncs = template.FuncMap{
31 "truncate": truncateString,
32 "buildStatus": buildStatusBadge,
33 "repoName": repoShortName,
34 "formatSize": formatSize,
35 "escapePath": escapePath,
36 "numberedCode": numberedCode,
37 "langClass": langClass,
38 "relTime": relTime,
39 "pluralRu": pluralRu,
40}
41
42// langClass подбирает CSS-класс языка highlight.js по расширению файла.
43func langClass(path string) string {
44 switch strings.ToLower(filepath.Ext(path)) {
45 case ".go":
46 return "language-go"
47 case ".js", ".mjs", ".cjs", ".jsx":
48 return "language-javascript"
49 case ".ts", ".tsx":
50 return "language-typescript"
51 case ".py":
52 return "language-python"
53 case ".html", ".htm", ".xml":
54 return "language-xml"
55 case ".css", ".scss":
56 return "language-css"
57 case ".json":
58 return "language-json"
59 case ".md", ".markdown":
60 return "language-markdown"
61 case ".yml", ".yaml":
62 return "language-yaml"
63 case ".sh", ".bash":
64 return "language-bash"
65 case ".rs":
66 return "language-rust"
67 case ".java":
68 return "language-java"
69 case ".c", ".h":
70 return "language-c"
71 case ".cpp", ".cc", ".hpp", ".cxx":
72 return "language-cpp"
73 case ".cs":
74 return "language-csharp"
75 case ".rb":
76 return "language-ruby"
77 case ".php":
78 return "language-php"
79 case ".sql":
80 return "language-sql"
81 case ".toml", ".ini", ".cfg", ".conf":
82 return "language-ini"
83 case ".dockerfile":
84 return "language-dockerfile"
85 default:
86 if strings.EqualFold(filepath.Base(path), "dockerfile") {
87 return "language-dockerfile"
88 }
89 return ""
90 }
91}
92
93// escapePath кодирует путь по сегментам, сохраняя разделители "/".
94func escapePath(p string) string {
95 parts := strings.Split(p, "/")
96 for i, part := range parts {
97 parts[i] = url.PathEscape(part)
98 }
99 return strings.Join(parts, "/")
100}
101
102func NewTemplates() *Templates {
103 return &Templates{pages: make(map[string]*template.Template)}
104}
105
106// LoadDir читает шаблоны из каталога на диске.
107func (t *Templates) LoadDir(dir string) error {
108 return t.loadFrom(os.DirFS(dir), ".")
109}
110
111// LoadFS читает шаблоны из переданной файловой системы (например, embed.FS).
112func (t *Templates) LoadFS(fsys fs.FS, dir string) error {
113 return t.loadFrom(fsys, dir)
114}
115
116func (t *Templates) loadFrom(fsys fs.FS, dir string) error {
117 layoutPath := path.Join(dir, "layout.html")
118 layoutData, err := fs.ReadFile(fsys, layoutPath)
119 if err != nil {
120 return fmt.Errorf("read layout: %w", err)
121 }
122
123 entries, err := fs.ReadDir(fsys, dir)
124 if err != nil {
125 return err
126 }
127
128 for _, e := range entries {
129 if e.IsDir() || !strings.HasSuffix(e.Name(), ".html") {
130 continue
131 }
132 name := strings.TrimSuffix(e.Name(), ".html")
133 if name == "layout" {
134 continue
135 }
136 pageData, err := fs.ReadFile(fsys, path.Join(dir, e.Name()))
137 if err != nil {
138 return fmt.Errorf("read page %s: %w", e.Name(), err)
139 }
140 tmpl, err := template.New(name).Funcs(tmplFuncs).Parse(string(layoutData))
141 if err != nil {
142 return fmt.Errorf("parse layout for %s: %w", name, err)
143 }
144 tmpl, err = tmpl.Parse(string(pageData))
145 if err != nil {
146 return fmt.Errorf("parse page %s: %w", name, err)
147 }
148 t.pages[name] = tmpl
149 }
150 return nil
151}
152
153// Render выполняет HTML-шаблон с данными.
154func (t *Templates) Render(w http.ResponseWriter, name string, data interface{}) {
155 tmpl, ok := t.pages[name]
156 if !ok {
157 http.Error(w, "template not found: "+name, http.StatusInternalServerError)
158 return
159 }
160 w.Header().Set("Content-Type", "text/html; charset=utf-8")
161 if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
162 fmt.Fprintf(os.Stderr, "render %s: %v\n", name, err)
163 }
164}
165
166// --- App ---
167
168type App struct {
169 Tmpl *Templates
170 GitSvc *services.GitService
171 BuildSvc *services.BuildService
172 StaticFS http.Handler
173}
174
175func NewApp(tmpl *Templates, gitSvc *services.GitService, buildSvc *services.BuildService, staticFS http.Handler) *App {
176 return &App{Tmpl: tmpl, GitSvc: gitSvc, BuildSvc: buildSvc, StaticFS: staticFS}
177}
178
179// RegisterRoutes регистрирует все HTTP-маршруты в mux.
180func (a *App) RegisterRoutes(mux *http.ServeMux) {
181 mux.Handle("/static/", http.StripPrefix("/static/", a.StaticFS))
182 mux.HandleFunc("/", a.router)
183}
184
185// router — единая точка входа: разбираем URL и отправляем нужному хендлеру.
186func (a *App) router(w http.ResponseWriter, r *http.Request) {
187 p := r.URL.Path
188
189 // git-запросы (push/pull) ограничиваются отдельно в GitService.ServeHTTP.
190 if r.Method == http.MethodPost && !isGitPath(p) {
191 r.Body = http.MaxBytesReader(w, r.Body, maxFormBody)
192 }
193
194 switch {
195 case p == "/" && r.Method == http.MethodGet:
196 a.Home(w, r)
197 case p == "/login":
198 a.Login(w, r)
199 case p == "/register":
200 a.Register(w, r)
201 case p == "/logout":
202 if !requireMethod(w, r, http.MethodPost) {
203 return
204 }
205 a.Logout(w, r)
206 case p == "/dashboard":
207 a.Dashboard(w, r)
208 case p == "/profile":
209 a.Profile(w, r)
210 case p == "/repo/new":
211 a.RepoCreate(w, r)
212 case p == "/builds":
213 a.BuildsList(w, r)
214 case strings.HasPrefix(p, "/build/"):
215 a.BuildLog(w, r)
216 case strings.HasPrefix(p, "/api/build/"):
217 if !requireMethod(w, r, http.MethodPost) {
218 return
219 }
220 a.BuildTrigger(w, r)
221 case strings.HasPrefix(p, "/files/"):
222 a.FileView(w, r)
223 case isGitPath(p):
224 a.GitHandler(w, r)
225 default:
226 // /{owner}/{repo}[.git][/releases[/new|/{id}/delete]] — разделы репозитория
227 trimmed := strings.Trim(p, "/")
228 parts := strings.Split(trimmed, "/")
229 if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
230 http.NotFound(w, r)
231 return
232 }
233 cloneName := parts[0] + "/" + strings.TrimSuffix(parts[1], ".git")
234
235 if len(parts) == 2 {
236 a.RepoView(w, r, cloneName)
237 return
238 }
239 if parts[2] == "releases" {
240 switch {
241 case len(parts) == 3:
242 a.Releases(w, r, cloneName)
243 case len(parts) == 4 && parts[3] == "new":
244 a.ReleaseNew(w, r, cloneName)
245 case len(parts) == 5 && parts[4] == "delete":
246 id, err := strconv.Atoi(parts[3])
247 if err != nil || id <= 0 {
248 http.NotFound(w, r)
249 return
250 }
251 a.ReleaseDelete(w, r, cloneName, id)
252 default:
253 http.NotFound(w, r)
254 }
255 return
256 }
257 http.NotFound(w, r)
258 }
259}
260
261// ============================================================
262// Handlers
263// ============================================================
264
265// base собирает общие данные шаблона: текущего пользователя и CSRF-токен.
266func (a *App) base(w http.ResponseWriter, r *http.Request) map[string]interface{} {
267 userID := auth.SessionUserID(r)
268 var user *models.User
269 if userID > 0 {
270 u, err := db.GetUserByID(userID)
271 if err == nil && u != nil {
272 user = u
273 } else {
274 // сессия ссылается на удалённого пользователя
275 userID = 0
276 }
277 }
278 // Базовый URL берём из запроса, чтобы ссылки на клонирование были
279 // корректными при доступе по IP/домену, а не только с localhost.
280 scheme := "http"
281 if r.TLS != nil {
282 scheme = "https"
283 }
284 if proto := r.Header.Get("X-Forwarded-Proto"); proto == "http" || proto == "https" {
285 scheme = proto
286 }
287 host := r.Host
288 if host == "" {
289 host = "localhost:5758"
290 }
291
292 return map[string]interface{}{
293 "UserID": userID,
294 "User": user,
295 "CSRF": auth.CSRFToken(w, r),
296 "Query": r.URL.Query().Get("q"),
297 "BaseURL": scheme + "://" + host,
298 }
299}
300
301// requireUser возвращает текущего пользователя или редиректит на /login.
302func (a *App) requireUser(w http.ResponseWriter, r *http.Request) (*models.User, bool) {
303 userID := auth.SessionUserID(r)
304 if userID == 0 {
305 http.Redirect(w, r, "/login", http.StatusSeeOther)
306 return nil, false
307 }
308 user, err := db.GetUserByID(userID)
309 if err != nil || user == nil {
310 http.Redirect(w, r, "/login", http.StatusSeeOther)
311 return nil, false
312 }
313 return user, true
314}
315
316// canViewRepo проверяет доступ к репозиторию: приватный виден только владельцу.
317func canViewRepo(repo *models.Repo, userID int) bool {
318 return !repo.IsPrivate || repo.OwnerID == userID
319}
320
321func (a *App) Home(w http.ResponseWriter, r *http.Request) {
322 repos, _ := db.ListAllPublicRepos()
323
324 // Поиск по публичным репозиториям: имя, описание и владелец.
325 if q := strings.TrimSpace(r.URL.Query().Get("q")); q != "" {
326 needle := strings.ToLower(q)
327 filtered := make([]*models.Repo, 0, len(repos))
328 for _, repo := range repos {
329 owner := ""
330 if repo.Owner != nil {
331 owner = repo.Owner.Username
332 }
333 haystack := strings.ToLower(repo.Name + " " + repo.Description + " " + owner)
334 if strings.Contains(haystack, needle) {
335 filtered = append(filtered, repo)
336 }
337 }
338 repos = filtered
339 }
340
341 d := a.base(w, r)
342 d["PublicRepos"] = repos
343 a.Tmpl.Render(w, "home", d)
344}
345
346func (a *App) Login(w http.ResponseWriter, r *http.Request) {
347 if r.Method == http.MethodGet {
348 d := a.base(w, r)
349 d["Error"] = ""
350 a.Tmpl.Render(w, "login", d)
351 return
352 }
353 if r.Method != http.MethodPost {
354 requireMethod(w, r, http.MethodGet, http.MethodPost)
355 return
356 }
357
358 d := a.base(w, r)
359 if !auth.CheckCSRF(r) {
360 w.WriteHeader(http.StatusForbidden)
361 d["Error"] = "Сессия устарела, попробуйте ещё раз"
362 a.Tmpl.Render(w, "login", d)
363 return
364 }
365
366 username := strings.TrimSpace(r.FormValue("username"))
367 password := r.FormValue("password")
368
369 user, err := db.GetUserByUsername(username)
370 if err != nil || user == nil || !auth.CheckPassword(password, user.PasswordHash) {
371 d["Error"] = "Неверный логин или пароль"
372 a.Tmpl.Render(w, "login", d)
373 return
374 }
375
376 auth.CreateSession(w, user.ID)
377 http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
378}
379
380func (a *App) Register(w http.ResponseWriter, r *http.Request) {
381 if r.Method == http.MethodGet {
382 d := a.base(w, r)
383 d["Error"] = ""
384 a.Tmpl.Render(w, "register", d)
385 return
386 }
387 if r.Method != http.MethodPost {
388 requireMethod(w, r, http.MethodGet, http.MethodPost)
389 return
390 }
391
392 d := a.base(w, r)
393 fail := func(msg string) {
394 d["Error"] = msg
395 a.Tmpl.Render(w, "register", d)
396 }
397
398 if !auth.CheckCSRF(r) {
399 w.WriteHeader(http.StatusForbidden)
400 fail("Сессия устарела, попробуйте ещё раз")
401 return
402 }
403
404 username := strings.TrimSpace(r.FormValue("username"))
405 email := strings.TrimSpace(r.FormValue("email"))
406 password := r.FormValue("password")
407 confirm := r.FormValue("confirm")
408
409 switch {
410 case username == "" || email == "" || password == "":
411 fail("Заполните все поля")
412 return
413 case len(username) < 3:
414 fail("Имя пользователя: минимум 3 символа")
415 return
416 case !validUsername.MatchString(username):
417 fail("Имя пользователя: только буквы, цифры, . _ -")
418 return
419 case len(password) < 6:
420 fail("Пароль: минимум 6 символов")
421 return
422 case password != confirm:
423 fail("Пароли не совпадают")
424 return
425 }
426
427 hash, err := auth.HashPassword(password)
428 if err != nil {
429 fail("Ошибка хеширования")
430 return
431 }
432
433 user := &models.User{Username: username, Email: email, PasswordHash: hash}
434 if err := db.CreateUser(user); err != nil {
435 if strings.Contains(strings.ToLower(err.Error()), "unique") {
436 fail("Пользователь с таким именем или email уже существует")
437 } else {
438 fail("Не удалось создать пользователя")
439 }
440 return
441 }
442
443 auth.CreateSession(w, user.ID)
444 http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
445}
446
447func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
448 if !auth.CheckCSRF(r) {
449 http.Error(w, "forbidden", http.StatusForbidden)
450 return
451 }
452 auth.DestroySession(r, w)
453 http.Redirect(w, r, "/", http.StatusSeeOther)
454}
455
456func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
457 user, ok := a.requireUser(w, r)
458 if !ok {
459 return
460 }
461 repos, _ := db.ListReposByOwner(user.ID)
462 d := a.base(w, r)
463 d["Repos"] = repos
464 a.Tmpl.Render(w, "dashboard", d)
465}
466
467func (a *App) Profile(w http.ResponseWriter, r *http.Request) {
468 user, ok := a.requireUser(w, r)
469 if !ok {
470 return
471 }
472 repos, _ := db.ListReposByOwner(user.ID)
473 d := a.base(w, r)
474 d["ProfileUser"] = user
475 d["Repos"] = repos
476 a.Tmpl.Render(w, "profile", d)
477}
478
479func (a *App) RepoCreate(w http.ResponseWriter, r *http.Request) {
480 user, ok := a.requireUser(w, r)
481 if !ok {
482 return
483 }
484
485 d := a.base(w, r)
486 fail := func(msg string) {
487 d["Error"] = msg
488 a.Tmpl.Render(w, "repo-create", d)
489 }
490
491 if r.Method == http.MethodGet {
492 fail("")
493 return
494 }
495 if r.Method != http.MethodPost {
496 requireMethod(w, r, http.MethodGet, http.MethodPost)
497 return
498 }
499 if !auth.CheckCSRF(r) {
500 w.WriteHeader(http.StatusForbidden)
501 fail("Сессия устарела, обновите страницу")
502 return
503 }
504
505 name := strings.TrimSpace(r.FormValue("name"))
506 desc := strings.TrimSpace(r.FormValue("description"))
507 isPrivate := r.FormValue("private") == "on"
508
509 if name == "" {
510 fail("Имя репозитория не может быть пустым")
511 return
512 }
513 name = strings.ReplaceAll(name, " ", "-")
514 if !validRepoName.MatchString(name) || strings.Contains(name, "..") {
515 fail("Недопустимое имя (только буквы, цифры, . _ -; без «..»)")
516 return
517 }
518
519 if existing, _ := db.GetRepoByName(user.Username, name); existing != nil {
520 fail("Репозиторий с таким именем уже существует")
521 return
522 }
523
524 cloneName := user.Username + "/" + name
525 repo := &models.Repo{
526 Name: name,
527 OwnerID: user.ID,
528 Description: desc,
529 IsPrivate: isPrivate,
530 CloneURL: cloneName,
531 Owner: user,
532 }
533
534 if err := a.GitSvc.CreateRepo(cloneName); err != nil {
535 fail("Не удалось создать: " + err.Error())
536 return
537 }
538 if err := db.CreateRepo(repo); err != nil {
539 // не оставляем осиротевший bare-репозиторий
540 _ = a.GitSvc.DeleteRepo(cloneName)
541 fail("Не удалось сохранить: " + err.Error())
542 return
543 }
544
545 http.Redirect(w, r, "/"+cloneName, http.StatusSeeOther)
546}
547
548func (a *App) RepoView(w http.ResponseWriter, r *http.Request, cloneName string) {
549 repo, err := db.GetRepoByCloneName(cloneName)
550 if err != nil || repo == nil {
551 http.NotFound(w, r)
552 return
553 }
554
555 userID := auth.SessionUserID(r)
556 if !canViewRepo(repo, userID) {
557 http.NotFound(w, r)
558 return
559 }
560
561 branches := a.GitSvc.ListBranches(cloneName)
562 branch := r.URL.Query().Get("branch")
563 if branch == "" || !a.GitSvc.HasBranch(cloneName, branch) {
564 branch = a.GitSvc.DefaultBranch(cloneName)
565 }
566
567 dir := cleanRepoPath(r.URL.Query().Get("path"))
568 tree, treeErr := a.GitSvc.ListTree(cloneName, branch, dir)
569 commits := a.GitSvc.LastCommits(cloneName, branch, 20)
570
571 // последний коммит для каждого элемента каталога — одним git log
572 if len(tree) > 0 {
573 paths := make([]string, 0, len(tree))
574 for _, e := range tree {
575 paths = append(paths, e.Path)
576 }
577 perPath := a.GitSvc.LastCommitPerPath(cloneName, branch, paths)
578 for i := range tree {
579 if c, ok := perPath[tree[i].Path]; ok {
580 cc := c
581 tree[i].Commit = &cc
582 }
583 }
584 }
585
586 releases, _ := db.ListReleasesByRepo(repo.ID, 3)
587 releaseCount, _ := db.CountReleasesByRepo(repo.ID)
588
589 d := a.base(w, r)
590 d["Repo"] = repo
591 d["IsOwner"] = userID == repo.OwnerID
592 d["Branches"] = branches
593 d["ActiveBranch"] = branch
594 d["Commits"] = commits
595 d["Tree"] = tree
596 d["Path"] = dir
597 d["Crumbs"] = buildCrumbs(dir)
598 d["TreeError"] = treeErr != nil && len(commits) > 0
599 d["BranchCount"] = len(branches)
600 d["TagCount"] = len(a.GitSvc.ListTags(cloneName))
601 d["CommitCount"] = a.GitSvc.CountCommits(cloneName, branch)
602 d["Releases"] = releases
603 d["ReleaseCount"] = releaseCount
604 a.Tmpl.Render(w, "repo", d)
605}
606
607// Releases показывает список релизов репозитория.
608func (a *App) Releases(w http.ResponseWriter, r *http.Request, cloneName string) {
609 repo, err := db.GetRepoByCloneName(cloneName)
610 if err != nil || repo == nil {
611 http.NotFound(w, r)
612 return
613 }
614
615 userID := auth.SessionUserID(r)
616 if !canViewRepo(repo, userID) {
617 http.NotFound(w, r)
618 return
619 }
620
621 releases, _ := db.ListReleasesByRepo(repo.ID, 100)
622
623 d := a.base(w, r)
624 d["Repo"] = repo
625 d["Releases"] = releases
626 d["IsOwner"] = userID == repo.OwnerID
627 a.Tmpl.Render(w, "releases", d)
628}
629
630// ReleaseNew создаёт релиз (GET — форма, POST — сохранение).
631func (a *App) ReleaseNew(w http.ResponseWriter, r *http.Request, cloneName string) {
632 user, ok := a.requireUser(w, r)
633 if !ok {
634 return
635 }
636
637 repo, err := db.GetRepoByCloneName(cloneName)
638 if err != nil || repo == nil {
639 http.NotFound(w, r)
640 return
641 }
642 if repo.OwnerID != user.ID {
643 http.Error(w, "forbidden", http.StatusForbidden)
644 return
645 }
646
647 d := a.base(w, r)
648 d["Repo"] = repo
649 d["Error"] = ""
650 d["Tag"] = ""
651 d["Title"] = ""
652 d["Body"] = ""
653 d["IsPrerelease"] = false
654 fail := func(msg string) {
655 d["Error"] = msg
656 a.Tmpl.Render(w, "release-new", d)
657 }
658
659 if r.Method == http.MethodGet {
660 fail("")
661 return
662 }
663 if r.Method != http.MethodPost {
664 requireMethod(w, r, http.MethodGet, http.MethodPost)
665 return
666 }
667 if !auth.CheckCSRF(r) {
668 w.WriteHeader(http.StatusForbidden)
669 fail("Сессия устарела, обновите страницу")
670 return
671 }
672
673 tag := strings.TrimSpace(r.FormValue("tag"))
674 title := strings.TrimSpace(r.FormValue("title"))
675 body := strings.TrimSpace(r.FormValue("body"))
676 isPrerelease := r.FormValue("prerelease") == "on"
677
678 // сохраняем введённые значения при ошибке
679 d["Tag"] = tag
680 d["Title"] = title
681 d["Body"] = body
682 d["IsPrerelease"] = isPrerelease
683
684 switch {
685 case tag == "":
686 fail("Укажите тег релиза")
687 return
688 case !validReleaseTag.MatchString(tag) || strings.Contains(tag, ".."):
689 fail("Недопустимый тег: используйте буквы, цифры, точку, дефис, подчёркивание и «/»")
690 return
691 }
692 if title == "" {
693 title = tag
694 }
695
696 rel := &models.Release{
697 RepoID: repo.ID,
698 Tag: tag,
699 Title: title,
700 Body: body,
701 IsPrerelease: isPrerelease,
702 }
703 if err := db.CreateRelease(rel); err != nil {
704 if strings.Contains(strings.ToLower(err.Error()), "unique") {
705 fail("Релиз с таким тегом уже существует")
706 } else {
707 fail("Не удалось сохранить релиз: " + err.Error())
708 }
709 return
710 }
711
712 http.Redirect(w, r, "/"+cloneName+"/releases", http.StatusSeeOther)
713}
714
715// ReleaseDelete удаляет релиз (только владелец репозитория).
716func (a *App) ReleaseDelete(w http.ResponseWriter, r *http.Request, cloneName string, id int) {
717 if !requireMethod(w, r, http.MethodPost) {
718 return
719 }
720
721 user, ok := a.requireUser(w, r)
722 if !ok {
723 return
724 }
725
726 repo, err := db.GetRepoByCloneName(cloneName)
727 if err != nil || repo == nil {
728 http.NotFound(w, r)
729 return
730 }
731 if repo.OwnerID != user.ID {
732 http.Error(w, "forbidden", http.StatusForbidden)
733 return
734 }
735 if !auth.CheckCSRF(r) {
736 http.Error(w, "forbidden", http.StatusForbidden)
737 return
738 }
739
740 rel, err := db.GetRelease(id)
741 if err != nil || rel == nil || rel.RepoID != repo.ID {
742 http.NotFound(w, r)
743 return
744 }
745 if err := db.DeleteRelease(id); err != nil {
746 http.Error(w, "db error", http.StatusInternalServerError)
747 return
748 }
749
750 http.Redirect(w, r, "/"+cloneName+"/releases", http.StatusSeeOther)
751}
752
753func (a *App) BuildTrigger(w http.ResponseWriter, r *http.Request) {
754 userID := auth.SessionUserID(r)
755 if userID == 0 {
756 http.Error(w, "unauthorized", http.StatusUnauthorized)
757 return
758 }
759 if !auth.CheckCSRF(r) {
760 http.Error(w, "forbidden", http.StatusForbidden)
761 return
762 }
763
764 cloneName := strings.Trim(r.URL.Path[len("/api/build/"):], "/")
765 if strings.Count(cloneName, "/") != 1 || strings.Contains(cloneName, "//") {
766 http.NotFound(w, r)
767 return
768 }
769
770 repo, err := db.GetRepoByCloneName(cloneName)
771 if err != nil || repo == nil {
772 http.NotFound(w, r)
773 return
774 }
775 if repo.OwnerID != userID {
776 http.Error(w, "forbidden", http.StatusForbidden)
777 return
778 }
779
780 branch := strings.TrimSpace(r.FormValue("branch"))
781 if branch == "" {
782 branch = a.GitSvc.DefaultBranch(cloneName)
783 }
784 if !a.GitSvc.HasBranch(cloneName, branch) {
785 http.Error(w, "branch not found (repository is empty?)", http.StatusBadRequest)
786 return
787 }
788
789 build := &models.Build{
790 RepoID: repo.ID,
791 Branch: branch,
792 BuildType: "auto",
793 Status: "running",
794 }
795 if err := db.CreateBuild(build); err != nil {
796 http.Error(w, "db error", http.StatusInternalServerError)
797 return
798 }
799
800 go func() {
801 unlock := a.BuildSvc.LockRepo(cloneName)
802 defer unlock()
803
804 buildType, ok := a.BuildSvc.RunBuildForRepo(cloneName, branch, build.ID)
805 status := "success"
806 if !ok {
807 status = "failed"
808 }
809 if buildType != "" {
810 if err := db.UpdateBuildType(build.ID, buildType); err != nil {
811 log.Printf("build %d: update build type: %v", build.ID, err)
812 }
813 }
814 if err := db.UpdateBuildStatus(build.ID, status, true); err != nil {
815 log.Printf("build %d: update status: %v", build.ID, err)
816 }
817 }()
818
819 http.Redirect(w, r, "/build/"+strconv.Itoa(build.ID), http.StatusSeeOther)
820}
821
822func (a *App) BuildsList(w http.ResponseWriter, r *http.Request) {
823 user, ok := a.requireUser(w, r)
824 if !ok {
825 return
826 }
827
828 var (
829 builds []*models.Build
830 filterRepo *models.Repo
831 )
832 cloneName := r.URL.Query().Get("repo")
833 if cloneName != "" {
834 repo, err := db.GetRepoByCloneName(cloneName)
835 if err != nil || repo == nil || repo.OwnerID != user.ID {
836 http.NotFound(w, r)
837 return
838 }
839 filterRepo = repo
840 builds, _ = db.ListBuildsByRepo(repo.ID, 50)
841 } else {
842 builds, _ = db.ListBuildsByOwner(user.ID, 50)
843 }
844
845 // preload repos to avoid N+1
846 repoMap := map[int]*models.Repo{}
847 if filterRepo != nil {
848 repoMap[filterRepo.ID] = filterRepo
849 } else if len(builds) > 0 {
850 repos, _ := db.ListReposByOwner(user.ID)
851 for _, r := range repos {
852 repoMap[r.ID] = r
853 }
854 }
855
856 type row struct {
857 B *models.Build
858 Repo *models.Repo
859 }
860 rows := make([]row, 0, len(builds))
861 for _, b := range builds {
862 repo := repoMap[b.RepoID]
863 rows = append(rows, row{B: b, Repo: repo})
864 }
865
866 d := a.base(w, r)
867 d["Rows"] = rows
868 d["FilterRepo"] = filterRepo
869 a.Tmpl.Render(w, "builds", d)
870}
871
872func (a *App) BuildLog(w http.ResponseWriter, r *http.Request) {
873 user, ok := a.requireUser(w, r)
874 if !ok {
875 return
876 }
877
878 buildID, _ := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/build/"))
879 if buildID == 0 {
880 http.NotFound(w, r)
881 return
882 }
883
884 b, err := db.GetBuild(buildID)
885 if err != nil || b == nil {
886 http.NotFound(w, r)
887 return
888 }
889 repo, err := db.GetRepoByID(b.RepoID)
890 if err != nil || repo == nil || repo.OwnerID != user.ID {
891 http.NotFound(w, r)
892 return
893 }
894
895 d := a.base(w, r)
896 d["Build"] = b
897 d["Repo"] = repo
898 d["BuildID"] = buildID
899 d["BuildLog"] = a.BuildSvc.ReadLog(buildID)
900 a.Tmpl.Render(w, "build", d)
901}
902
903func (a *App) FileView(w http.ResponseWriter, r *http.Request) {
904 p := strings.TrimPrefix(r.URL.Path, "/files/")
905 parts := strings.SplitN(p, "/", 3)
906 if len(parts) < 3 {
907 http.NotFound(w, r)
908 return
909 }
910
911 cloneName := parts[0] + "/" + parts[1]
912 filePath := cleanRepoPath(parts[2])
913 if filePath == "" {
914 http.NotFound(w, r)
915 return
916 }
917
918 repo, err := db.GetRepoByCloneName(cloneName)
919 if err != nil || repo == nil {
920 http.NotFound(w, r)
921 return
922 }
923 userID := auth.SessionUserID(r)
924 if !canViewRepo(repo, userID) {
925 http.NotFound(w, r)
926 return
927 }
928
929 branch := r.URL.Query().Get("branch")
930 if branch == "" || !a.GitSvc.HasBranch(cloneName, branch) {
931 branch = a.GitSvc.DefaultBranch(cloneName)
932 }
933
934 data, err := a.GitSvc.ReadFile(cloneName, branch, filePath)
935 if err != nil {
936 http.NotFound(w, r)
937 return
938 }
939
940 ext := strings.ToLower(filepath.Ext(filePath))
941 contentType := "text/plain; charset=utf-8"
942 switch ext {
943 case ".png":
944 contentType = "image/png"
945 case ".jpg", ".jpeg":
946 contentType = "image/jpeg"
947 case ".gif":
948 contentType = "image/gif"
949 case ".svg":
950 contentType = "image/svg+xml"
951 case ".ico":
952 contentType = "image/x-icon"
953 }
954
955 // картинки отдаём напрямую
956 if strings.HasPrefix(contentType, "image/") {
957 w.Header().Set("Content-Type", contentType)
958 w.Header().Set("Cache-Control", "private, max-age=60")
959 w.Header().Set("X-Content-Type-Options", "nosniff")
960 // SVG может содержать скрипты — изолируем его в песочнице
961 w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
962 _, _ = w.Write(data)
963 return
964 }
965
966 const maxTextFile = 2 << 20
967 if len(data) > maxTextFile {
968 http.Error(w, "file is too large to display (limit 2 MB)", http.StatusRequestEntityTooLarge)
969 return
970 }
971
972 d := a.base(w, r)
973 d["Repo"] = repo
974 d["Path"] = filePath
975 d["Crumbs"] = buildCrumbs(filePath)
976 d["Branch"] = branch
977 d["Content"] = string(data)
978 d["FileSize"] = formatSize(int64(len(data)))
979 d["LangClass"] = langClass(filePath)
980 a.Tmpl.Render(w, "file", d)
981}
982
983func (a *App) GitHandler(w http.ResponseWriter, r *http.Request) {
984 // path вида /owner/repo.git/info/refs
985 p := strings.TrimPrefix(r.URL.Path, "/")
986 first := strings.Index(p, "/")
987 if first < 0 {
988 http.NotFound(w, r)
989 return
990 }
991 rest := p[first+1:]
992 second := strings.Index(rest, "/")
993 if second < 0 {
994 http.NotFound(w, r)
995 return
996 }
997 cloneName := p[:first] + "/" + strings.TrimSuffix(rest[:second], ".git")
998 if cloneName == "/" {
999 http.NotFound(w, r)
1000 return
1001 }
1002
1003 repo, err := db.GetRepoByCloneName(cloneName)
1004 if err != nil || repo == nil {
1005 http.NotFound(w, r)
1006 return
1007 }
1008
1009 isPush := strings.HasSuffix(p, "git-receive-pack") ||
1010 r.URL.Query().Get("service") == "git-receive-pack"
1011
1012 // приватные репо и любой push требуют авторизации
1013 needAuth := repo.IsPrivate || isPush
1014 if needAuth {
1015 username, pass, ok := auth.CheckBasicAuth(r)
1016 if !ok {
1017 w.Header().Set("WWW-Authenticate", `Basic realm="Clancore Git"`)
1018 http.Error(w, "authentication required", http.StatusUnauthorized)
1019 return
1020 }
1021 u, err := db.GetUserByUsername(username)
1022 if err != nil || u == nil || !auth.CheckPassword(pass, u.PasswordHash) {
1023 http.Error(w, "invalid credentials", http.StatusForbidden)
1024 return
1025 }
1026 if repo.IsPrivate && u.ID != repo.OwnerID {
1027 http.Error(w, "forbidden: private repository", http.StatusForbidden)
1028 return
1029 }
1030 if isPush && u.ID != repo.OwnerID {
1031 http.Error(w, "push forbidden: you are not the owner", http.StatusForbidden)
1032 return
1033 }
1034 }
1035
1036 a.GitSvc.ServeHTTP(w, r, cloneName)
1037}
1038
1039// ============================================================
1040// Helpers
1041// ============================================================
1042
1043// requireMethod проверяет HTTP-метод и отвечает 405 при несовпадении.
1044func requireMethod(w http.ResponseWriter, r *http.Request, allowed ...string) bool {
1045 for _, m := range allowed {
1046 if r.Method == m {
1047 return true
1048 }
1049 }
1050 w.Header().Set("Allow", strings.Join(allowed, ", "))
1051 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
1052 return false
1053}
1054
1055// cleanRepoPath нормализует путь внутри репозитория и не даёт выйти за его пределы.
1056func cleanRepoPath(p string) string {
1057 p = strings.ReplaceAll(p, "\\", "/")
1058 p = strings.Trim(p, "/")
1059 if p == "" {
1060 return ""
1061 }
1062 cleaned := path.Clean(p)
1063 if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
1064 return ""
1065 }
1066 return cleaned
1067}
1068
1069type Crumb struct {
1070 Name string
1071 Path string
1072}
1073
1074func buildCrumbs(p string) []Crumb {
1075 if p == "" {
1076 return nil
1077 }
1078 var crumbs []Crumb
1079 acc := ""
1080 for _, part := range strings.Split(p, "/") {
1081 if part == "" {
1082 continue
1083 }
1084 if acc == "" {
1085 acc = part
1086 } else {
1087 acc += "/" + part
1088 }
1089 crumbs = append(crumbs, Crumb{Name: part, Path: acc})
1090 }
1091 return crumbs
1092}
1093
1094func truncateString(s string, n int) string {
1095 runes := []rune(s)
1096 if len(runes) <= n {
1097 return s
1098 }
1099 return string(runes[:n]) + "…"
1100}
1101
1102// numberedCode превращает текст файла в таблицу с номерами строк.
1103func numberedCode(content string) template.HTML {
1104 content = strings.ReplaceAll(content, "\r\n", "\n")
1105 content = strings.TrimSuffix(content, "\n")
1106 lines := strings.Split(content, "\n")
1107
1108 var b strings.Builder
1109 b.WriteString(`<table class="blob-code"><tbody>`)
1110 for i, line := range lines {
1111 b.WriteString(`<tr><td class="blob-num">`)
1112 b.WriteString(strconv.Itoa(i + 1))
1113 b.WriteString(`</td><td class="blob-code-cell"><span class="blob-code-inner">`)
1114 if line == "" {
1115 b.WriteString(" ")
1116 } else {
1117 b.WriteString(template.HTMLEscapeString(line))
1118 }
1119 b.WriteString(`</span></td></tr>`)
1120 }
1121 b.WriteString(`</tbody></table>`)
1122 return template.HTML(b.String())
1123}
1124
1125func buildStatusBadge(status string) template.HTML {
1126 switch status {
1127 case "success":
1128 return `<span class="badge success">✓ Успех</span>`
1129 case "failed":
1130 return `<span class="badge failed">✗ Ошибка</span>`
1131 case "running":
1132 return `<span class="badge running">⟳ Сборка</span>`
1133 default:
1134 return `<span class="badge pending">◌ Ожидание</span>`
1135 }
1136}
1137
1138func repoShortName(cloneName string) string {
1139 parts := strings.SplitN(cloneName, "/", 2)
1140 if len(parts) == 2 {
1141 return parts[1]
1142 }
1143 return cloneName
1144}
1145
1146// relTime превращает время в строку вида «3 дня назад» (как в GitHub).
1147func relTime(t time.Time) string {
1148 if t.IsZero() {
1149 return ""
1150 }
1151 d := time.Since(t)
1152 if d < 0 {
1153 d = 0
1154 }
1155 switch {
1156 case d < time.Minute:
1157 return "только что"
1158 case d < time.Hour:
1159 n := int(d.Minutes())
1160 return fmt.Sprintf("%d %s назад", n, pluralRu(n, "минуту", "минуты", "минут"))
1161 case d < 24*time.Hour:
1162 n := int(d.Hours())
1163 return fmt.Sprintf("%d %s назад", n, pluralRu(n, "час", "часа", "часов"))
1164 case d < 7*24*time.Hour:
1165 n := int(d.Hours() / 24)
1166 return fmt.Sprintf("%d %s назад", n, pluralRu(n, "день", "дня", "дней"))
1167 case d < 35*24*time.Hour:
1168 n := int(d.Hours() / 24 / 7)
1169 return fmt.Sprintf("%d %s назад", n, pluralRu(n, "неделю", "недели", "недель"))
1170 case d < 365*24*time.Hour:
1171 n := int(d.Hours() / 24 / 30)
1172 return fmt.Sprintf("%d %s назад", n, pluralRu(n, "месяц", "месяца", "месяцев"))
1173 default:
1174 n := int(d.Hours() / 24 / 365)
1175 return fmt.Sprintf("%d %s назад", n, pluralRu(n, "год", "года", "лет"))
1176 }
1177}
1178
1179// pluralRu подбирает форму множественного числа для русского языка.
1180func pluralRu(n int, one, few, many string) string {
1181 n10 := n % 10
1182 n100 := n % 100
1183 switch {
1184 case n10 == 1 && n100 != 11:
1185 return one
1186 case n10 >= 2 && n10 <= 4 && (n100 < 10 || n100 >= 20):
1187 return few
1188 default:
1189 return many
1190 }
1191}
1192
1193func formatSize(b int64) string {
1194 const unit = 1024
1195 if b < unit {
1196 return strconv.FormatInt(b, 10) + " B"
1197 }
1198 div, exp := int64(unit), 0
1199 for n := b / unit; n >= unit; n /= unit {
1200 div *= unit
1201 exp++
1202 }
1203 suffixes := "KMGTPE"
1204 if exp >= len(suffixes) {
1205 exp = len(suffixes) - 1
1206 }
1207 suffix := suffixes[exp : exp+1]
1208 return strconv.FormatFloat(float64(b)/float64(div), 'f', 1, 64) + " " + suffix + "B"
1209}
1210
1211var (
1212 validRepoName = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
1213 validUsername = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
1214 validReleaseTag = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`)
1215)
1216
1217// maxFormBody ограничивает тело обычных POST-форм (не git-запросов).
1218const maxFormBody = 1 << 20 // 1 MiB
1219
1220// isGitPath сообщает, относится ли путь к Smart HTTP git-протоколу.
1221func isGitPath(p string) bool {
1222 return strings.HasSuffix(p, "/info/refs") ||
1223 strings.HasSuffix(p, "/git-upload-pack") ||
1224 strings.HasSuffix(p, "/git-receive-pack")
1225}