| 1 | module main |
| 2 | |
| 3 | import veb |
| 4 | import api |
| 5 | import crypto.sha1 |
| 6 | import os |
| 7 | import time |
| 8 | import highlight |
| 9 | import validation |
| 10 | import git |
| 11 | import config |
| 12 | |
| 13 | const top_files_limit = 50 |
| 14 | |
| 15 | @['/:username/repos'] |
| 16 | pub fn (mut app App) user_repos(username string) veb.Result { |
| 17 | exists, user := app.check_username(username) |
| 18 | |
| 19 | if !exists { |
| 20 | return ctx.not_found() |
| 21 | } |
| 22 | |
| 23 | mut repos := app.find_user_public_repos(user.id) |
| 24 | |
| 25 | if user.id == ctx.user.id { |
| 26 | repos = app.find_user_repos(user.id) |
| 27 | } |
| 28 | |
| 29 | for mut repo in repos { |
| 30 | repo.lang_stats = app.find_repo_lang_stats(repo.id) |
| 31 | repo.latest_commit_at = app.find_repo_last_commit_time(repo.id) |
| 32 | repo.activity_buckets = app.get_repo_activity_buckets(repo.id) |
| 33 | } |
| 34 | |
| 35 | return $veb.html('templates/user/repos.html') |
| 36 | } |
| 37 | |
| 38 | @['/:username/stars'] |
| 39 | pub fn (mut app App) user_stars(username string) veb.Result { |
| 40 | exists, user := app.check_username(username) |
| 41 | |
| 42 | if !exists { |
| 43 | return ctx.not_found() |
| 44 | } |
| 45 | |
| 46 | repos := app.find_user_starred_repos(ctx.user.id) |
| 47 | |
| 48 | return $veb.html('templates/user/stars.html') |
| 49 | } |
| 50 | |
| 51 | @['/:username/:repo_name/settings'] |
| 52 | pub fn (mut app App) repo_settings(username string, repo_name string) veb.Result { |
| 53 | repo := app.find_repo_by_name_and_username(repo_name, username) or { |
| 54 | return ctx.redirect_to_repository(username, repo_name) |
| 55 | } |
| 56 | is_owner := app.check_repo_owner(ctx.user.username, repo_name) |
| 57 | |
| 58 | if !is_owner { |
| 59 | return ctx.redirect_to_repository(username, repo_name) |
| 60 | } |
| 61 | |
| 62 | return $veb.html('templates/repo/settings.html') |
| 63 | } |
| 64 | |
| 65 | @['/:username/:repo_name/settings'; post] |
| 66 | pub fn (mut app App) handle_update_repo_settings(username string, repo_name string, webhook_secret string) veb.Result { |
| 67 | repo := app.find_repo_by_name_and_username(repo_name, username) or { |
| 68 | return ctx.redirect_to_repository(username, repo_name) |
| 69 | } |
| 70 | is_owner := app.check_repo_owner(ctx.user.username, repo_name) |
| 71 | |
| 72 | if !is_owner { |
| 73 | return ctx.redirect_to_repository(username, repo_name) |
| 74 | } |
| 75 | |
| 76 | if webhook_secret != '' && webhook_secret != repo.webhook_secret { |
| 77 | webhook := sha1.hexhash(webhook_secret) |
| 78 | app.set_repo_webhook_secret(repo.id, webhook) or { app.info(err.str()) } |
| 79 | } |
| 80 | |
| 81 | return ctx.redirect_to_repository(username, repo_name) |
| 82 | } |
| 83 | |
| 84 | @['/:username/:repo_name/settings/features'; post] |
| 85 | pub fn (mut app App) handle_update_repo_features(username string, repo_name string) veb.Result { |
| 86 | repo := app.find_repo_by_name_and_username(repo_name, username) or { |
| 87 | return ctx.redirect_to_repository(username, repo_name) |
| 88 | } |
| 89 | is_owner := app.check_repo_owner(ctx.user.username, repo_name) |
| 90 | |
| 91 | if !is_owner { |
| 92 | return ctx.redirect_to_repository(username, repo_name) |
| 93 | } |
| 94 | |
| 95 | disable_discussions := 'discussions_enabled' !in ctx.form |
| 96 | disable_projects := 'projects_enabled' !in ctx.form |
| 97 | disable_milestones := 'milestones_enabled' !in ctx.form |
| 98 | disable_wiki := 'wiki_enabled' !in ctx.form |
| 99 | |
| 100 | app.update_repo_features(repo.id, disable_discussions, disable_projects, disable_milestones, |
| 101 | disable_wiki) or { app.info(err.str()) } |
| 102 | |
| 103 | return ctx.redirect('/${username}/${repo_name}/settings') |
| 104 | } |
| 105 | |
| 106 | @['/:user/:repo_name/delete'; post] |
| 107 | pub fn (mut app App) handle_repo_delete(username string, repo_name string) veb.Result { |
| 108 | repo := app.find_repo_by_name_and_username(repo_name, username) or { |
| 109 | return ctx.redirect_to_repository(username, repo_name) |
| 110 | } |
| 111 | is_owner := app.check_repo_owner(ctx.user.username, repo_name) |
| 112 | |
| 113 | if !is_owner { |
| 114 | return ctx.redirect_to_repository(username, repo_name) |
| 115 | } |
| 116 | |
| 117 | if ctx.form['verify'] == '${username}/${repo_name}' { |
| 118 | spawn app.delete_repository(repo.id, repo.git_dir, repo.name) |
| 119 | } else { |
| 120 | ctx.error('Verification failed') |
| 121 | return app.repo_settings(mut ctx, username, repo_name) |
| 122 | } |
| 123 | |
| 124 | return ctx.redirect_to_index() |
| 125 | } |
| 126 | |
| 127 | @['/:username/:repo_name/move'; post] |
| 128 | pub fn (mut app App) handle_repo_move(username string, repo_name string, dest string, verify string) veb.Result { |
| 129 | repo := app.find_repo_by_name_and_username(repo_name, username) or { |
| 130 | return ctx.redirect_to_index() |
| 131 | } |
| 132 | is_owner := app.check_repo_owner(ctx.user.username, repo_name) |
| 133 | |
| 134 | if !is_owner { |
| 135 | return ctx.redirect_to_repository(username, repo_name) |
| 136 | } |
| 137 | |
| 138 | if dest != '' && verify == '${username}/${repo_name}' { |
| 139 | dest_user := app.get_user_by_username(dest) or { |
| 140 | ctx.error('Unknown user ${dest}') |
| 141 | return app.repo_settings(mut ctx, username, repo_name) |
| 142 | } |
| 143 | |
| 144 | if app.user_has_repo(dest_user.id, repo.name) { |
| 145 | ctx.error('User already owns repo ${repo.name}') |
| 146 | return app.repo_settings(mut ctx, username, repo_name) |
| 147 | } |
| 148 | |
| 149 | if app.get_count_user_repos(dest_user.id) >= max_user_repos { |
| 150 | ctx.error('User already reached the repo limit') |
| 151 | return app.repo_settings(mut ctx, username, repo_name) |
| 152 | } |
| 153 | |
| 154 | app.move_repo_to_user(repo.id, dest_user.id, dest_user.username) or { |
| 155 | ctx.error('There was an error while moving the repo') |
| 156 | return app.repo_settings(mut ctx, username, repo_name) |
| 157 | } |
| 158 | |
| 159 | return ctx.redirect('/${dest_user.username}/${repo.name}') |
| 160 | } else { |
| 161 | ctx.error('Verification failed') |
| 162 | |
| 163 | return app.repo_settings(mut ctx, username, repo_name) |
| 164 | } |
| 165 | |
| 166 | return ctx.redirect_to_index() |
| 167 | } |
| 168 | |
| 169 | @['/:username/:repo_name'] |
| 170 | pub fn (mut app App) handle_tree(mut ctx Context, username string, repo_name string) veb.Result { |
| 171 | match repo_name { |
| 172 | 'repos' { |
| 173 | return app.user_repos(mut ctx, username) |
| 174 | } |
| 175 | 'issues' { |
| 176 | return app.handle_get_user_issues(mut ctx, username) |
| 177 | } |
| 178 | 'pulls' { |
| 179 | return app.handle_get_user_pulls(mut ctx, username) |
| 180 | } |
| 181 | 'settings' { |
| 182 | return app.user_settings(mut ctx, username) |
| 183 | } |
| 184 | else {} |
| 185 | } |
| 186 | |
| 187 | repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } |
| 188 | |
| 189 | return app.tree(mut ctx, username, repo_name, repo.primary_branch, '') |
| 190 | } |
| 191 | |
| 192 | @['/:username/:repo_name/tree/:branch_name'] |
| 193 | pub fn (mut app App) handle_branch_tree(mut ctx Context, username string, repo_name string, branch_name string) veb.Result { |
| 194 | app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } |
| 195 | |
| 196 | return app.tree(mut ctx, username, repo_name, branch_name, '') |
| 197 | } |
| 198 | |
| 199 | @['/:username/:repo_name/update'] |
| 200 | pub fn (mut app App) handle_repo_update(mut ctx Context, username string, repo_name string) veb.Result { |
| 201 | mut repo := app.find_repo_by_name_and_username(repo_name, username) or { |
| 202 | return ctx.not_found() |
| 203 | } |
| 204 | |
| 205 | if ctx.user.is_admin { |
| 206 | app.update_repo_from_remote(mut repo) or { app.info(err.str()) } |
| 207 | app.slow_fetch_files_info(mut repo, 'master', '.') or { app.info(err.str()) } |
| 208 | } |
| 209 | |
| 210 | return ctx.redirect_to_repository(username, repo_name) |
| 211 | } |
| 212 | |
| 213 | @['/new'] |
| 214 | pub fn (mut app App) new() veb.Result { |
| 215 | if !ctx.logged_in { |
| 216 | return ctx.redirect_to_login() |
| 217 | } |
| 218 | orgs := app.find_orgs_for_user(ctx.user.id) |
| 219 | selected_owner := ctx.query['owner'] or { ctx.user.username } |
| 220 | return $veb.html() |
| 221 | } |
| 222 | |
| 223 | @['/new'; post] |
| 224 | pub fn (mut app App) handle_new_repo(mut ctx Context, name string, clone_url string, description string, no_redirect string) veb.Result { |
| 225 | println('NEW POST') |
| 226 | mut valid_clone_url := clone_url |
| 227 | is_clone_url_empty := validation.is_string_empty(clone_url) |
| 228 | is_public := ctx.form['repo_visibility'] == 'public' |
| 229 | if !ctx.logged_in { |
| 230 | return ctx.redirect_to_login() |
| 231 | } |
| 232 | owner := ctx.form['owner'] or { ctx.user.username } |
| 233 | mut owner_name := ctx.user.username |
| 234 | mut owner_org_id := 0 |
| 235 | if owner != ctx.user.username { |
| 236 | org := app.get_org_by_name(owner) or { |
| 237 | ctx.error('Unknown owner "${owner}"') |
| 238 | return app.new(mut ctx) |
| 239 | } |
| 240 | if !app.is_org_member(org.id, ctx.user.id) { |
| 241 | ctx.error('You are not a member of "${owner}"') |
| 242 | return app.new(mut ctx) |
| 243 | } |
| 244 | owner_name = org.name |
| 245 | owner_org_id = org.id |
| 246 | } |
| 247 | if owner_org_id == 0 && !ctx.is_admin() && app.get_count_user_repos(ctx.user.id) >= max_user_repos { |
| 248 | ctx.error('You have reached the limit for the number of repositories') |
| 249 | return app.new(mut ctx) |
| 250 | } |
| 251 | if name.len > max_repo_name_len { |
| 252 | ctx.error('The repository name is too long (should be fewer than ${max_repo_name_len} characters)') |
| 253 | return app.new(mut ctx) |
| 254 | } |
| 255 | eprintln(1) |
| 256 | if _ := app.find_repo_by_name_and_username(name, owner_name) { |
| 257 | ctx.error('A repository with the name "${name}" already exists') |
| 258 | return app.new(mut ctx) |
| 259 | } |
| 260 | eprintln(2) |
| 261 | if name.contains(' ') { |
| 262 | ctx.error('Repository name cannot contain spaces') |
| 263 | return app.new(mut ctx) |
| 264 | } |
| 265 | eprintln(3) |
| 266 | is_repo_name_valid := validation.is_repository_name_valid(name) |
| 267 | if !is_repo_name_valid { |
| 268 | ctx.error('The repository name is not valid') |
| 269 | return app.new(mut ctx) |
| 270 | } |
| 271 | eprintln(4) |
| 272 | has_clone_url_https_prefix := clone_url.starts_with('https://') |
| 273 | if !is_clone_url_empty { |
| 274 | if !has_clone_url_https_prefix { |
| 275 | valid_clone_url = 'https://' + clone_url |
| 276 | } |
| 277 | println('checking') |
| 278 | is_git_repo := git.check_git_repo_url(valid_clone_url) |
| 279 | println('done') |
| 280 | if !is_git_repo { |
| 281 | ctx.error('The repository URL does not contain any git repository or the server does not respond') |
| 282 | return app.new(mut ctx) |
| 283 | } |
| 284 | } |
| 285 | println('OK') |
| 286 | owner_dir := os.join_path(app.config.repo_storage_path, owner_name) |
| 287 | if !os.exists(owner_dir) { |
| 288 | os.mkdir(owner_dir) or { app.info('failed to create owner dir ${owner_dir}: ${err}') } |
| 289 | } |
| 290 | repo_path := os.join_path(owner_dir, name) |
| 291 | id := app.get_max_repo_id() + 1 |
| 292 | mut new_repo := &Repo{ |
| 293 | name: name |
| 294 | id: id |
| 295 | description: description |
| 296 | git_dir: repo_path |
| 297 | user_id: ctx.user.id |
| 298 | primary_branch: 'master' |
| 299 | user_name: owner_name |
| 300 | clone_url: valid_clone_url |
| 301 | is_public: is_public |
| 302 | } |
| 303 | import_issues := ctx.form['import_issues'] == '1' |
| 304 | if is_clone_url_empty { |
| 305 | os.mkdir(new_repo.git_dir) or { panic(err) } |
| 306 | new_repo.git('init --bare') |
| 307 | } else { |
| 308 | new_repo.status = .cloning |
| 309 | } |
| 310 | // Insert the repo row BEFORE spawning the clone thread, so that the |
| 311 | // background `set_repo_status(.done)` UPDATE has a row to match. |
| 312 | app.add_repo(new_repo) or { |
| 313 | ctx.error('There was an error while adding the repo ${err}') |
| 314 | return app.new(mut ctx) |
| 315 | } |
| 316 | if !is_clone_url_empty { |
| 317 | app.debug('cloning') |
| 318 | clone_job_repo := *new_repo |
| 319 | spawn clone_repo(clone_job_repo, app.config, import_issues, ctx.user.id) |
| 320 | } |
| 321 | new_repo2 := app.find_repo_by_name_and_username(new_repo.name, owner_name) or { |
| 322 | app.info('Repo was not inserted') |
| 323 | return ctx.redirect('/new') |
| 324 | } |
| 325 | repo_id := new_repo2.id |
| 326 | // $dbg; |
| 327 | // primary_branch := git.get_repository_primary_branch(repo_path) |
| 328 | primary_branch := new_repo2.primary_branch |
| 329 | // app.debug("new_repo2: ${new_repo2}") |
| 330 | |
| 331 | app.update_repo_primary_branch(repo_id, primary_branch) or { |
| 332 | ctx.error('There was an error while adding the repo') |
| 333 | return app.new(mut ctx) |
| 334 | } |
| 335 | app.find_repo_by_id(repo_id) or { return app.new(mut ctx) } |
| 336 | // Update only cloned repositories |
| 337 | /* |
| 338 | if !is_clone_url_empty { |
| 339 | app.update_repo_from_fs(mut new_repo, true) or { |
| 340 | ctx.error('There was an error while cloning the repo') |
| 341 | return app.new(mut ctx) |
| 342 | } |
| 343 | } |
| 344 | */ |
| 345 | if no_redirect == '1' { |
| 346 | return ctx.text('ok') |
| 347 | } |
| 348 | has_first_repo_activity := app.has_activity(ctx.user.id, 'first_repo') |
| 349 | if !has_first_repo_activity { |
| 350 | app.add_activity(ctx.user.id, 'first_repo') or { app.info(err.str()) } |
| 351 | } |
| 352 | return ctx.redirect('/${owner_name}/${new_repo.name}') |
| 353 | } |
| 354 | |
| 355 | fn bg_fetch_files_info(repo_ Repo, branch string, path string, conf config.Config) { |
| 356 | mut repo := repo_ |
| 357 | mut app := &App{ |
| 358 | db: connect_db(conf) or { |
| 359 | eprintln('cannot open ${db_backend_name()} db connection for bg_fetch thread: ${err}') |
| 360 | return |
| 361 | } |
| 362 | config: conf |
| 363 | } |
| 364 | app.load_settings() |
| 365 | app.slow_fetch_files_info(mut repo, branch, path) or { |
| 366 | eprintln('bg_fetch_files_info error: ${err}') |
| 367 | } |
| 368 | if app.settings.tree_folder_size_enabled() { |
| 369 | app.slow_fetch_folder_sizes(mut repo, branch, path) or { |
| 370 | eprintln('bg_fetch_folder_sizes error: ${err}') |
| 371 | } |
| 372 | } |
| 373 | app.db.close() or {} |
| 374 | } |
| 375 | |
| 376 | fn clone_repo(new_repo Repo, conf config.Config, import_issues bool, owner_user_id int) { |
| 377 | mut cloned_repo := new_repo |
| 378 | cloned_repo.clone() |
| 379 | // Use a dedicated DB connection for the clone thread to avoid |
| 380 | // sharing a connection across threads. |
| 381 | mut app := &App{ |
| 382 | db: connect_db(conf) or { |
| 383 | eprintln('cannot open ${db_backend_name()} db connection for clone thread: ${err}') |
| 384 | return |
| 385 | } |
| 386 | config: conf |
| 387 | } |
| 388 | // Mark repo as done immediately so the user can browse it. |
| 389 | // The tree page will fetch files from git on demand. |
| 390 | app.set_repo_status(cloned_repo.id, .done) or { eprintln('cannot set repo status ${err}') } |
| 391 | eprintln('clone done, repo available — indexing in background') |
| 392 | // For GitHub clones, also pull the repo description and contributors list |
| 393 | // (the issue import is gated on a separate user opt-in). |
| 394 | if cloned_repo.clone_url.contains('github.com') { |
| 395 | spawn bg_import_github_repo_info(cloned_repo.id, cloned_repo.clone_url, |
| 396 | cloned_repo.description, conf) |
| 397 | if import_issues { |
| 398 | spawn bg_import_github_issues(cloned_repo.id, cloned_repo.clone_url, owner_user_id, |
| 399 | conf) |
| 400 | } |
| 401 | } |
| 402 | // Index branches, commits, and language stats in the background. |
| 403 | app.update_repo_from_fs(mut cloned_repo, true) or { |
| 404 | eprintln('cannot update repo from fs ${err}') |
| 405 | } |
| 406 | eprintln('background indexing complete') |
| 407 | app.db.close() or {} |
| 408 | } |
| 409 | |
| 410 | fn bg_import_github_repo_info(repo_id int, clone_url string, existing_description string, conf config.Config) { |
| 411 | eprintln('[github-info] spawned thread for repo_id=${repo_id}') |
| 412 | mut app := &App{ |
| 413 | db: connect_db(conf) or { |
| 414 | eprintln('[github-info] cannot open db connection: ${err}') |
| 415 | return |
| 416 | } |
| 417 | config: conf |
| 418 | } |
| 419 | defer { |
| 420 | app.db.close() or {} |
| 421 | } |
| 422 | if existing_description.trim_space() == '' { |
| 423 | description := fetch_github_repo_description(clone_url) |
| 424 | if description != '' { |
| 425 | app.set_repo_description(repo_id, description) or { |
| 426 | eprintln('[github-info] cannot save description: ${err}') |
| 427 | } |
| 428 | } |
| 429 | } |
| 430 | app.import_github_contributors(repo_id, clone_url) or { |
| 431 | eprintln('[github-contrib] FAILED: ${err}') |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | fn bg_import_github_issues(repo_id int, clone_url string, owner_user_id int, conf config.Config) { |
| 436 | eprintln('[github-import] spawned thread for repo_id=${repo_id}') |
| 437 | mut app := &App{ |
| 438 | db: connect_db(conf) or { |
| 439 | eprintln('[github-import] cannot open db connection for import thread: ${err}') |
| 440 | return |
| 441 | } |
| 442 | config: conf |
| 443 | } |
| 444 | app.import_github_issues(repo_id, clone_url, owner_user_id) or { |
| 445 | eprintln('[github-import] FAILED: ${err}') |
| 446 | } |
| 447 | app.db.close() or {} |
| 448 | } |
| 449 | |
| 450 | pub fn (mut app App) kekw(mut ctx Context) veb.Result { |
| 451 | clone_url := '' |
| 452 | clone_progress := '' |
| 453 | return $veb.html('templates/cloning_in_process.html') |
| 454 | } |
| 455 | |
| 456 | // read_clone_progress parses a git `--progress` log file and returns |
| 457 | // the latest output as a single newline-separated string, ready to be |
| 458 | // shown inside a <pre> block. Git emits live progress with `\r` and |
| 459 | // stage transitions with `\n`; we collapse repeated progress lines for |
| 460 | // the same phase ("Counting objects", "Receiving objects", …) so only |
| 461 | // the most recent value for each phase remains. |
| 462 | fn read_clone_progress(progress_path string) string { |
| 463 | raw := os.read_file(progress_path) or { return '' } |
| 464 | if raw.len == 0 { |
| 465 | return '' |
| 466 | } |
| 467 | lines := raw.replace('\r', '\n').split('\n') |
| 468 | mut stages := []string{} |
| 469 | mut phase_index := map[string]int{} |
| 470 | for raw_line in lines { |
| 471 | line := raw_line.trim_space() |
| 472 | if line == '' { |
| 473 | continue |
| 474 | } |
| 475 | mut body := line |
| 476 | if body.starts_with('remote: ') { |
| 477 | body = body[8..] |
| 478 | } |
| 479 | colon := body.index(':') or { -1 } |
| 480 | key := if colon == -1 { body } else { body[..colon].trim_space() } |
| 481 | if key in phase_index { |
| 482 | stages[phase_index[key]] = line |
| 483 | } else { |
| 484 | phase_index[key] = stages.len |
| 485 | stages << line |
| 486 | } |
| 487 | } |
| 488 | return stages.join('\n') |
| 489 | } |
| 490 | |
| 491 | @['/:username/:repo_name/tree/:branch_name/:path...'] |
| 492 | pub fn (mut app App) tree(mut ctx Context, username string, repo_name string, branch_name string, path string) veb.Result { |
| 493 | tree_t0 := time.ticks() |
| 494 | mut tree_t := tree_t0 |
| 495 | mut repo := app.find_repo_by_name_and_username(repo_name, username) or { |
| 496 | eprintln('tree() repo ${repo_name} not found') |
| 497 | return ctx.not_found() |
| 498 | } |
| 499 | eprintln('[tree] find_repo: ${time.ticks() - tree_t}ms') |
| 500 | tree_t = time.ticks() |
| 501 | mut clone_url := '' |
| 502 | mut clone_progress := '' |
| 503 | if repo.status == .cloning { |
| 504 | clone_url = repo.clone_url |
| 505 | clone_progress = read_clone_progress(repo.clone_progress_path()) |
| 506 | return $veb.html('templates/cloning_in_process.html') |
| 507 | } |
| 508 | |
| 509 | _, user := app.check_username(username) |
| 510 | eprintln('[tree] check_username: ${time.ticks() - tree_t}ms') |
| 511 | tree_t = time.ticks() |
| 512 | if !repo.is_public { |
| 513 | if user.id != ctx.user.id { |
| 514 | return ctx.not_found() |
| 515 | } |
| 516 | } |
| 517 | |
| 518 | repo_id := repo.id |
| 519 | |
| 520 | // XTODO |
| 521 | // app.fetch_tags(repo) or { app.info(err.str()) } |
| 522 | |
| 523 | ctx.current_path = path |
| 524 | if path.contains('favicon.svg') { |
| 525 | return ctx.not_found() |
| 526 | } |
| 527 | |
| 528 | ctx.path_split = [repo_name] |
| 529 | if path != '' { |
| 530 | ctx.path_split << path.split('/') |
| 531 | } |
| 532 | |
| 533 | ctx.is_tree = true |
| 534 | ctx.branch = branch_name |
| 535 | |
| 536 | app.increment_repo_views(repo.id) or { app.info(err.str()) } |
| 537 | eprintln('[tree] increment_repo_views: ${time.ticks() - tree_t}ms') |
| 538 | tree_t = time.ticks() |
| 539 | |
| 540 | mut up := '/' |
| 541 | can_up := path != '' |
| 542 | if can_up { |
| 543 | if !path.contains('/') { |
| 544 | up = '../..' |
| 545 | } else { |
| 546 | up = ctx.req.url.all_before_last('/') |
| 547 | } |
| 548 | } |
| 549 | |
| 550 | tree_mode := if 'mode' in ctx.query { ctx.query['mode'] } else { 'tree' } |
| 551 | is_top_files_mode := tree_mode == 'top-files' |
| 552 | top_files := if is_top_files_mode { |
| 553 | repo.top_files(branch_name, top_files_limit) |
| 554 | } else { |
| 555 | []File{} |
| 556 | } |
| 557 | if is_top_files_mode { |
| 558 | eprintln('[tree] top_files: ${time.ticks() - tree_t}ms') |
| 559 | tree_t = time.ticks() |
| 560 | } |
| 561 | tree_url := if path == '' { |
| 562 | '/${username}/${repo_name}/tree/${branch_name}' |
| 563 | } else { |
| 564 | '/${username}/${repo_name}/tree/${branch_name}/${path}' |
| 565 | } |
| 566 | top_files_url := '/${username}/${repo_name}/tree/${branch_name}?mode=top-files' |
| 567 | |
| 568 | mut items := app.find_repository_items(repo_id, branch_name, ctx.current_path) |
| 569 | eprintln('[tree] find_repository_items (${items.len} items): ${time.ticks() - tree_t}ms') |
| 570 | tree_t = time.ticks() |
| 571 | branch := app.find_repo_branch_by_name(repo.id, branch_name) |
| 572 | eprintln('[tree] find_repo_branch_by_name: ${time.ticks() - tree_t}ms') |
| 573 | tree_t = time.ticks() |
| 574 | |
| 575 | show_folder_size := app.settings.tree_folder_size_enabled() |
| 576 | |
| 577 | if !is_top_files_mode { |
| 578 | if items.len == 0 { |
| 579 | // No files in the db, fetch them from git and cache in db |
| 580 | items = app.cache_repository_items(mut repo, branch_name, ctx.current_path) or { |
| 581 | app.info(err.str()) |
| 582 | []File{} |
| 583 | } |
| 584 | eprintln('[tree] cache_repository_items: ${time.ticks() - tree_t}ms') |
| 585 | tree_t = time.ticks() |
| 586 | // Fetch commit info in background — don't block the page |
| 587 | spawn bg_fetch_files_info(repo, branch_name, ctx.current_path, app.config) |
| 588 | } else if items.any(it.last_msg == '') { |
| 589 | // Some files still need commit info — fetch in background |
| 590 | spawn bg_fetch_files_info(repo, branch_name, ctx.current_path, app.config) |
| 591 | } else if show_folder_size && items.any(it.is_dir && !it.is_size_calculated) { |
| 592 | // Some folders still need size info, fetch in background |
| 593 | spawn bg_fetch_files_info(repo, branch_name, ctx.current_path, app.config) |
| 594 | } |
| 595 | } |
| 596 | |
| 597 | // Fetch last commit message for this directory, printed at the top of the tree |
| 598 | mut last_commit := Commit{} |
| 599 | mut dir := File{} |
| 600 | if can_up { |
| 601 | mut p := path |
| 602 | if p.ends_with('/') { |
| 603 | p = p[0..path.len - 1] |
| 604 | } |
| 605 | if !p.contains('/') { |
| 606 | p = '/${p}' |
| 607 | } |
| 608 | dir = app.find_repo_file_by_path(repo.id, branch_name, p) or { File{} } |
| 609 | if dir.id != 0 { |
| 610 | last_commit = app.find_repo_commit_by_hash(repo.id, dir.last_hash) |
| 611 | } |
| 612 | } else { |
| 613 | last_commit = app.find_repo_last_commit(repo.id, branch.id) |
| 614 | } |
| 615 | eprintln('[tree] last_commit lookup: ${time.ticks() - tree_t}ms') |
| 616 | tree_t = time.ticks() |
| 617 | |
| 618 | mut next_dir_idx := 0 |
| 619 | for scan_idx in 0 .. items.len { |
| 620 | if items[scan_idx].is_dir { |
| 621 | if scan_idx != next_dir_idx { |
| 622 | moving_dir := items[scan_idx] |
| 623 | mut move_idx := scan_idx |
| 624 | for move_idx > next_dir_idx { |
| 625 | items[move_idx] = items[move_idx - 1] |
| 626 | move_idx-- |
| 627 | } |
| 628 | items[next_dir_idx] = moving_dir |
| 629 | } |
| 630 | next_dir_idx++ |
| 631 | } |
| 632 | } |
| 633 | |
| 634 | commits_count := app.get_repo_commit_count(repo.id, branch.id) |
| 635 | has_commits := commits_count > 0 |
| 636 | eprintln('[tree] get_repo_commit_count: ${time.ticks() - tree_t}ms') |
| 637 | tree_t = time.ticks() |
| 638 | |
| 639 | // Get readme after updating repository |
| 640 | readme_file := find_readme_file(items) or { File{} } |
| 641 | readme := render_readme(repo, branch_name, path, readme_file) |
| 642 | eprintln('[tree] render_readme: ${time.ticks() - tree_t}ms') |
| 643 | tree_t = time.ticks() |
| 644 | |
| 645 | license_file := find_license_file(items) or { File{} } |
| 646 | mut license_file_path := '' |
| 647 | |
| 648 | if license_file.id != 0 { |
| 649 | license_file_path = '/${username}/${repo_name}/blob/${branch_name}/${license_file.name}' |
| 650 | } |
| 651 | |
| 652 | watcher_count := app.get_count_repo_watchers(repo_id) |
| 653 | is_repo_starred := app.check_repo_starred(repo_id, ctx.user.id) |
| 654 | is_repo_watcher := app.check_repo_watcher_status(repo_id, ctx.user.id) |
| 655 | is_top_directory := ctx.current_path == '' |
| 656 | eprintln('[tree] watcher/star/watcher_status: ${time.ticks() - tree_t}ms') |
| 657 | tree_t = time.ticks() |
| 658 | |
| 659 | // CI status for last commit |
| 660 | ci_status := app.find_ci_status_for_commit(repo_id, last_commit.hash) or { |
| 661 | app.find_ci_status_for_branch(repo_id, branch_name) or { CiStatus{} } |
| 662 | } |
| 663 | has_ci := ci_status.id != 0 |
| 664 | eprintln('[tree] ci_status: ${time.ticks() - tree_t}ms') |
| 665 | tree_t = time.ticks() |
| 666 | |
| 667 | mut sidebar_contributors := []User{} |
| 668 | mut sidebar_releases := []Release{} |
| 669 | if is_top_directory { |
| 670 | all_contributors := app.find_repo_registered_contributor(repo_id) |
| 671 | sidebar_contributors = if all_contributors.len > 12 { |
| 672 | all_contributors[..12] |
| 673 | } else { |
| 674 | all_contributors |
| 675 | } |
| 676 | |
| 677 | rels := app.find_repo_releases_as_page(repo_id, 0) |
| 678 | tags := app.get_all_repo_tags(repo_id) |
| 679 | for rel in rels { |
| 680 | mut r := rel |
| 681 | for tag in tags { |
| 682 | if tag.id == rel.tag_id { |
| 683 | r.tag_name = tag.name |
| 684 | r.tag_hash = tag.hash |
| 685 | r.date = time.unix(tag.created_at) |
| 686 | break |
| 687 | } |
| 688 | } |
| 689 | sidebar_releases << r |
| 690 | if sidebar_releases.len >= 3 { |
| 691 | break |
| 692 | } |
| 693 | } |
| 694 | eprintln('[tree] sidebar contributors/releases: ${time.ticks() - tree_t}ms') |
| 695 | tree_t = time.ticks() |
| 696 | } |
| 697 | |
| 698 | eprintln('[tree] pre-render TOTAL ${username}/${repo_name}: ${time.ticks() - tree_t0}ms') |
| 699 | return $veb.html() |
| 700 | } |
| 701 | |
| 702 | fn render_readme(repo Repo, branch_name string, path string, readme_file File) veb.RawHtml { |
| 703 | if readme_file.id == 0 { |
| 704 | return veb.RawHtml('') |
| 705 | } |
| 706 | |
| 707 | readme_path := '${path}/${readme_file.name}' |
| 708 | readme_content := repo.read_file(branch_name, readme_path) |
| 709 | highlighted_readme, _, _ := highlight.highlight_text(readme_content, readme_path, false) |
| 710 | |
| 711 | return veb.RawHtml(highlighted_readme) |
| 712 | } |
| 713 | |
| 714 | @['/api/v1/repos/:repo_id/star'; 'post'] |
| 715 | pub fn (mut app App) handle_api_repo_star(mut ctx Context, repo_id_str string) veb.Result { |
| 716 | repo_id := repo_id_str.int() |
| 717 | |
| 718 | has_access := app.has_user_repo_read_access(ctx, ctx.user.id, repo_id) |
| 719 | |
| 720 | if !has_access { |
| 721 | return ctx.json_error('Not found') |
| 722 | } |
| 723 | |
| 724 | user_id := ctx.user.id |
| 725 | app.toggle_repo_star(repo_id, user_id) or { |
| 726 | return ctx.json_error('There was an error while starring the repo') |
| 727 | } |
| 728 | is_repo_starred := app.check_repo_starred(repo_id, user_id) |
| 729 | |
| 730 | return ctx.json(api.ApiSuccessResponse[bool]{ |
| 731 | success: true |
| 732 | result: is_repo_starred |
| 733 | }) |
| 734 | } |
| 735 | |
| 736 | @['/api/v1/repos/:repo_id/watch'; 'post'] |
| 737 | pub fn (mut app App) handle_api_repo_watch(mut ctx Context, repo_id_str string) veb.Result { |
| 738 | repo_id := repo_id_str.int() |
| 739 | |
| 740 | has_access := app.has_user_repo_read_access(ctx, ctx.user.id, repo_id) |
| 741 | |
| 742 | if !has_access { |
| 743 | return ctx.json_error('Not found') |
| 744 | } |
| 745 | |
| 746 | user_id := ctx.user.id |
| 747 | app.toggle_repo_watcher_status(repo_id, user_id) or { |
| 748 | return ctx.json_error('There was an error while toggling to watch') |
| 749 | } |
| 750 | is_watching := app.check_repo_watcher_status(repo_id, user_id) |
| 751 | |
| 752 | return ctx.json(api.ApiSuccessResponse[bool]{ |
| 753 | success: true |
| 754 | result: is_watching |
| 755 | }) |
| 756 | } |
| 757 | |
| 758 | // API: get file listing with commit info for a directory (used by JS polling) |
| 759 | // Path uses /tree/files to avoid colliding with /api/v1/repos/:username/:repo_name. |
| 760 | @['/api/v1/repos/:repo_id_str/tree/files'] |
| 761 | pub fn (mut app App) handle_api_repo_files(mut ctx Context, repo_id_str string) veb.Result { |
| 762 | repo_id := repo_id_str.int() |
| 763 | repo := app.find_repo_by_id(repo_id) or { return ctx.json_error('Not found') } |
| 764 | |
| 765 | if !repo.is_public && repo.user_id != ctx.user.id { |
| 766 | return ctx.json_error('Not found') |
| 767 | } |
| 768 | |
| 769 | branch := if 'branch' in ctx.query { ctx.query['branch'] } else { '' } |
| 770 | path := if 'path' in ctx.query { ctx.query['path'] } else { '' } |
| 771 | |
| 772 | if branch == '' { |
| 773 | return ctx.json_error('branch is required') |
| 774 | } |
| 775 | |
| 776 | items := app.find_repository_items(repo_id, branch, path) |
| 777 | mut result := []FileInfo{} |
| 778 | for item in items { |
| 779 | result << FileInfo{ |
| 780 | name: item.name |
| 781 | last_msg: item.last_msg |
| 782 | last_hash: item.last_hash |
| 783 | last_time: item.pretty_last_time() |
| 784 | size: item.pretty_tree_size() |
| 785 | } |
| 786 | } |
| 787 | |
| 788 | return ctx.json(api.ApiSuccessResponse[[]FileInfo]{ |
| 789 | success: true |
| 790 | result: result |
| 791 | }) |
| 792 | } |
| 793 | |
| 794 | @['/:username/:repo_name/contributors'] |
| 795 | pub fn (mut app App) contributors(mut ctx Context, username string, repo_name string) veb.Result { |
| 796 | repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } |
| 797 | |
| 798 | contributors := app.find_repo_registered_contributor(repo.id) |
| 799 | |
| 800 | return $veb.html() |
| 801 | } |
| 802 | |
| 803 | @['/:username/:repo_name/blob/:branch_name/:path...'] |
| 804 | pub fn (mut app App) blob(mut ctx Context, username string, repo_name string, branch_name string, path string) veb.Result { |
| 805 | repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() } |
| 806 | |
| 807 | mut path_parts := path.split('/') |
| 808 | path_parts.pop() |
| 809 | |
| 810 | ctx.current_path = path |
| 811 | ctx.path_split = [repo_name] |
| 812 | ctx.path_split << path_parts |
| 813 | |
| 814 | if !app.contains_repo_branch(repo.id, branch_name) && branch_name != repo.primary_branch { |
| 815 | app.info('Branch ${branch_name} not found') |
| 816 | return ctx.not_found() |
| 817 | } |
| 818 | |
| 819 | raw_url := '/${username}/${repo_name}/raw/${branch_name}/${path}' |
| 820 | file := app.find_repo_file_by_path(repo.id, branch_name, path) or { |
| 821 | repo.lookup_file_via_git(branch_name, path) or { return ctx.not_found() } |
| 822 | } |
| 823 | is_markdown := file.name.to_lower().ends_with('.md') |
| 824 | plain_text := repo.read_file(branch_name, path) |
| 825 | highlighted_source, _, _ := highlight.highlight_text(plain_text, file.name, false) |
| 826 | source := veb.RawHtml(highlighted_source) |
| 827 | loc, sloc := calculate_lines_of_code(plain_text) |
| 828 | |
| 829 | return $veb.html() |
| 830 | } |
| 831 | |
| 832 | @['/:user/:repository/raw/:branch_name/:path...'] |
| 833 | pub fn (mut app App) handle_raw(mut ctx Context, username string, repo_name string, branch_name string, path string) veb.Result { |
| 834 | user := app.get_user_by_username(username) or { return ctx.not_found() } |
| 835 | repo := app.find_repo_by_name_and_user_id(repo_name, user.id) or { return ctx.not_found() } |
| 836 | |
| 837 | // TODO: throw error when git returns non-zero status |
| 838 | file_source := repo.git('--no-pager show ${branch_name}:${path}') |
| 839 | |
| 840 | return ctx.ok(file_source) |
| 841 | } |
| 842 | |