internal/handlers/handlers.go
32.1 KB
| 1 | package handlers |
| 2 | |
| 3 | import ( |
| 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"}}. |
| 26 | type Templates struct { |
| 27 | pages map[string]*template.Template |
| 28 | } |
| 29 | |
| 30 | var 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 по расширению файла. |
| 43 | func 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 кодирует путь по сегментам, сохраняя разделители "/". |
| 94 | func 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 | |
| 102 | func NewTemplates() *Templates { |
| 103 | return &Templates{pages: make(map[string]*template.Template)} |
| 104 | } |
| 105 | |
| 106 | // LoadDir читает шаблоны из каталога на диске. |
| 107 | func (t *Templates) LoadDir(dir string) error { |
| 108 | return t.loadFrom(os.DirFS(dir), ".") |
| 109 | } |
| 110 | |
| 111 | // LoadFS читает шаблоны из переданной файловой системы (например, embed.FS). |
| 112 | func (t *Templates) LoadFS(fsys fs.FS, dir string) error { |
| 113 | return t.loadFrom(fsys, dir) |
| 114 | } |
| 115 | |
| 116 | func (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-шаблон с данными. |
| 154 | func (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 | |
| 168 | type App struct { |
| 169 | Tmpl *Templates |
| 170 | GitSvc *services.GitService |
| 171 | BuildSvc *services.BuildService |
| 172 | StaticFS http.Handler |
| 173 | } |
| 174 | |
| 175 | func 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. |
| 180 | func (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 и отправляем нужному хендлеру. |
| 186 | func (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-токен. |
| 266 | func (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. |
| 302 | func (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 проверяет доступ к репозиторию: приватный виден только владельцу. |
| 317 | func canViewRepo(repo *models.Repo, userID int) bool { |
| 318 | return !repo.IsPrivate || repo.OwnerID == userID |
| 319 | } |
| 320 | |
| 321 | func (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 | |
| 346 | func (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 | |
| 380 | func (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 | |
| 447 | func (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 | |
| 456 | func (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 | |
| 467 | func (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 | |
| 479 | func (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 | |
| 548 | func (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 показывает список релизов репозитория. |
| 608 | func (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 — сохранение). |
| 631 | func (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 удаляет релиз (только владелец репозитория). |
| 716 | func (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 | |
| 753 | func (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 | |
| 822 | func (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 | |
| 872 | func (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 | |
| 903 | func (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 | |
| 983 | func (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 при несовпадении. |
| 1044 | func 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 нормализует путь внутри репозитория и не даёт выйти за его пределы. |
| 1056 | func 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 | |
| 1069 | type Crumb struct { |
| 1070 | Name string |
| 1071 | Path string |
| 1072 | } |
| 1073 | |
| 1074 | func 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 | |
| 1094 | func 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 превращает текст файла в таблицу с номерами строк. |
| 1103 | func 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 | |
| 1125 | func 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 | |
| 1138 | func 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). |
| 1147 | func 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 подбирает форму множественного числа для русского языка. |
| 1180 | func 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 | |
| 1193 | func 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 | |
| 1211 | var ( |
| 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-запросов). |
| 1218 | const maxFormBody = 1 << 20 // 1 MiB |
| 1219 | |
| 1220 | // isGitPath сообщает, относится ли путь к Smart HTTP git-протоколу. |
| 1221 | func 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 | } |