View raw

1 (** Request handlers: the seam between Git data and rendered pages. 2 3 Each handler opens exactly one repository context, reads what its page 4 needs, renders it, and closes the context — so the Git store, resolved 5 metadata and default-branch policy are shared by every operation in a 6 request instead of being reopened per query. 7 8 Errors keep the category {!module:Resolvers} gave them until they reach 9 {!error_response}, which is the single place a category becomes an HTTP 10 status: malformed input is [400], a missing repository or object is [404], 11 and storage failures are [500]. Handlers therefore never choose a status 12 themselves. 13 14 This is also the only layer allowed to touch both configuration and the 15 filesystem; views receive plain values. *) 16 17 (** Map a resolver failure to the status and wording shown to the reader. 18 Internal failures are deliberately vague, since their detail is for the 19 server log rather than the visitor. *) 20 let error_response error = 21 let status, title, message = 22 match error with 23 | Resolvers.Bad_request raw -> (`Bad_Request, "Bad request", raw) 24 | Resolvers.Not_found raw -> (`Not_Found, "Not found", raw) 25 | Resolvers.Store_error _ -> 26 ( `Internal_Server_Error, 27 "Internal server error", 28 "An unexpected error occurred while reading repository data." ) 29 | Resolvers.Internal _ -> 30 ( `Internal_Server_Error, 31 "Internal server error", 32 "An unexpected error occurred." ) 33 in 34 Views.error_page ~status ~title message 35 36 (* The configured title wins; otherwise derive one from the user name. Named for 37 the resolution it performs, to distinguish it from the [root_title] fields it 38 reads and writes. *) 39 let resolve_root_title config = 40 if config.Config.root_title = "" then 41 if config.Config.user_name = "" then "Repositories" 42 else "Repositories for " ^ config.Config.user_name 43 else config.Config.root_title 44 45 let site config = 46 Layout.site ~user_name:config.Config.user_name 47 ~root_title:(resolve_root_title config) 48 ~nav_logo:config.Config.nav_logo 49 50 let collect_repo_paths ?(prefix = "") nodes = 51 let rec walk current_prefix acc = function 52 | Resolvers.Repo { repo_name; _ } -> 53 let full = 54 if current_prefix = "" then repo_name 55 else current_prefix ^ "/" ^ repo_name 56 in 57 full :: acc 58 | Resolvers.Directory (dir_name, children) -> 59 let p = 60 if current_prefix = "" then dir_name 61 else current_prefix ^ "/" ^ dir_name 62 in 63 List.fold_left (walk p) acc children 64 in 65 List.fold_left (walk prefix) [] nodes |> List.rev 66 67 (* Head commit dates for the repository list pages, keyed by (path, head 68 hash). Any new commit changes the key, so an entry can never go stale. *) 69 let date_cache : (int64 * Git.User.tz_offset option) option Cache.t = 70 Cache.create ~capacity:1024 71 72 let fetch_repo_dates config repo_paths = 73 let open Lwt.Syntax in 74 let fetch path = 75 Lwt.bind (Resolvers.open_repository config path) @@ function 76 | Error _ -> Lwt.return None 77 | Ok repository -> 78 let* date = Resolvers.head_commit_date repository in 79 let* () = Resolvers.close_repository repository in 80 Lwt.return date 81 in 82 Lwt_list.map_p 83 (fun path -> 84 match Resolvers.head_hash_hint config path with 85 | None -> 86 let* date = fetch path in 87 Lwt.return (path, date) 88 | Some head -> ( 89 let key = path ^ "\x00" ^ head in 90 match Cache.find date_cache key with 91 | Some date -> Lwt.return (path, date) 92 | None -> 93 let* date = fetch path in 94 Cache.add date_cache key date; 95 Lwt.return (path, date))) 96 repo_paths 97 98 let root config _request = 99 match Resolvers.scan_project_root config with 100 | Ok nodes -> 101 let open Lwt.Syntax in 102 let repo_paths = collect_repo_paths nodes in 103 let* dates = fetch_repo_dates config repo_paths in 104 let node_name = function 105 | Resolvers.Repo { repo_name; _ } -> repo_name 106 | Resolvers.Directory (dir_name, _) -> dir_name ^ "/" 107 in 108 let strip_dot_git name = 109 if String.ends_with ~suffix:".git" name then 110 String.sub name 0 (String.length name - 4) 111 else name 112 in 113 let name_matches config_entry node_name = 114 (* Directories end with '/' — match exactly *) 115 if String.ends_with ~suffix:"/" node_name then node_name = config_entry 116 else 117 (* Repos: match with or without .git suffix *) 118 strip_dot_git node_name = strip_dot_git config_entry 119 in 120 let is_in_list config_list node = 121 let name = node_name node in 122 List.exists (fun entry -> name_matches entry name) config_list 123 in 124 let is_favorite node = 125 is_in_list config.Config.favorite_repositories node 126 in 127 let is_archived node = 128 is_in_list config.Config.archived_repositories node 129 in 130 let favorites, rest = List.partition is_favorite nodes in 131 let archived, regular = List.partition is_archived rest in 132 (* Preserve the order specified in the config for favorites *) 133 let sorted_favorites = 134 List.filter_map 135 (fun entry -> 136 List.find_opt (fun n -> name_matches entry (node_name n)) favorites) 137 config.Config.favorite_repositories 138 in 139 let readme = Resolvers.read_root_readme config in 140 Views.root (site config) ~dates ~favorites:sorted_favorites ~archived 141 ?readme regular 142 | Error error -> error_response error 143 144 module Repo = struct 145 let ( let* ) result continue = 146 Lwt.bind result @@ function 147 | Ok value -> continue value 148 | Error error -> error_response error 149 150 let view_context config repository = 151 Views.Repo.context ~site:(site config) 152 ~repo:(Resolvers.repository_name repository) 153 ~description:(Resolvers.repository_description repository) 154 155 let with_repository config name continue = 156 Lwt.bind (Resolvers.open_repository config name) @@ function 157 | Error error -> error_response error 158 | Ok repository -> 159 let context = view_context config repository in 160 Lwt.finalize 161 (fun () -> continue repository context) 162 (fun () -> Resolvers.close_repository repository) 163 164 let summary _config repository context = 165 let* readme = Resolvers.Repo.readme repository in 166 Views.Repo.summary context ?readme () 167 168 let commit_matches ?filter_type ?author ?committer 169 (commit : Resolvers.Commit.t) = 170 let type_matches = 171 match filter_type with 172 | None -> true 173 | Some expected -> 174 let summary = 175 match commit.message with 176 | None -> "" 177 | Some message -> ( 178 match String.split_on_char '\n' message with 179 | [] -> "" 180 | summary :: _ -> summary) 181 in 182 let commit_type, _ = Commit_message.parse_conventional summary in 183 commit_type = Some expected 184 in 185 let author_matches = 186 match author with 187 | None -> true 188 | Some email -> String.equal commit.author.email email 189 in 190 let committer_matches = 191 match committer with 192 | None -> true 193 | Some email -> String.equal commit.committer.email email 194 in 195 type_matches && author_matches && committer_matches 196 197 let commits config request repository context = 198 let page_size = config.Config.commits_max_displayed in 199 let filter_type = Dream.query request "type" in 200 let author = Dream.query request "author" in 201 let committer = Dream.query request "committer" in 202 (* The query parameter keeps its short name; the binding says what it is. *) 203 let page_number = 204 let ( >>= ) = Option.bind in 205 Dream.query request "page" >>= int_of_string_opt 206 >>= (fun p -> if p > 0 then Some p else None) 207 |> Option.value ~default:1 208 in 209 let offset = (page_number - 1) * page_size in 210 (* Fetch one beyond orphan threshold to detect whether more exist *) 211 let fetch_count = offset + page_size + 11 in 212 let predicate = commit_matches ?filter_type ?author ?committer in 213 let* all_commits, truncated = 214 Resolvers.Commit.recent_matching repository fetch_count predicate 215 in 216 let total = List.length all_commits in 217 let after_offset = 218 if offset >= total then [] else List_ext.drop offset all_commits 219 in 220 let remaining = List.length after_offset in 221 (* Avoid a final page with fewer than 10 items — absorb them into this 222 page instead, so users don't paginate for a near-empty last page. *) 223 let effective_size = 224 if remaining > page_size && remaining <= page_size + 10 then remaining 225 else page_size 226 in 227 let page_commits = List_ext.take effective_size after_offset in 228 let has_next = remaining > effective_size in 229 let has_prev = page_number > 1 in 230 Views.Repo.commits ?filter_type ?author ?committer ~truncated ~page_number 231 ~has_prev ~has_next context page_commits 232 233 let commits_branch config repository context branch = 234 let page_size = config.Config.commits_max_displayed in 235 let* reference = Resolvers.Reference.of_id repository branch in 236 let fetch_count = page_size + 11 in 237 let* commits = 238 Resolvers.Commit.recent_from repository reference.hash fetch_count 239 in 240 let remaining = List.length commits in 241 let effective_size = 242 if remaining > page_size && remaining <= page_size + 10 then remaining 243 else page_size 244 in 245 let page_commits = List_ext.take effective_size commits in 246 let has_next = remaining > effective_size in 247 Views.Repo.commits ~page_number:1 ~has_prev:false ~has_next context 248 page_commits 249 250 let commit_id repository context id = 251 let* commit = Resolvers.Commit.of_id repository id in 252 let* diff = Resolvers.Diff.of_commit repository commit in 253 Views.Repo.commit context commit diff 254 255 let files_at_head repository context = 256 let* tree = Resolvers.Tree.head repository in 257 let* nodes = Resolvers.Tree.expand repository tree in 258 Views.Repo.files context [] nodes 259 260 let file_id repository context id = 261 let* trail = Resolvers.Tree.find_path repository id in 262 let* object_ = Resolvers.blob_or_tree repository id in 263 match object_ with 264 | `Tree tree -> 265 let* nodes = Resolvers.Tree.expand repository tree in 266 Views.Repo.files context trail nodes 267 | `Blob blob -> Views.Repo.file context trail blob 268 269 let file_at repository context path = 270 let* object_, trail = Resolvers.object_at_path repository path in 271 match object_ with 272 | `Tree tree -> 273 let* nodes = Resolvers.Tree.expand repository tree in 274 Views.Repo.files context trail nodes 275 | `Blob blob -> Views.Repo.file context trail blob 276 277 let mime_of_filename filename = 278 match Filename.extension filename |> String.lowercase_ascii with 279 | ".png" -> "image/png" 280 | ".jpg" | ".jpeg" -> "image/jpeg" 281 | ".gif" -> "image/gif" 282 | ".svg" -> "image/svg+xml" 283 | ".webp" -> "image/webp" 284 | ".ico" -> "image/x-icon" 285 | ".bmp" -> "image/bmp" 286 | ".avif" -> "image/avif" 287 | _ -> "text/plain; charset=utf-8" 288 289 (* Raw blobs are repository content served from ogit's own origin. The 290 sandbox policy stops any active content — an SVG carrying a script is the 291 canonical case — from running with the site's authority, and [nosniff] 292 stops browsers from promoting text/plain to something executable. *) 293 let raw_headers content_type = 294 [ 295 ("Content-Type", content_type); 296 ("Content-Security-Policy", "sandbox"); 297 ("X-Content-Type-Options", "nosniff"); 298 ] 299 300 let raw_file repository _context id = 301 let* trail = Resolvers.Tree.find_path repository id in 302 let* object_ = Resolvers.blob_or_tree repository id in 303 match object_ with 304 | `Blob blob -> 305 let content_type = 306 match List.rev trail with 307 | (name, _) :: _ -> mime_of_filename name 308 | [] -> "text/plain; charset=utf-8" 309 in 310 Lwt.return 311 (Dream.response ~headers:(raw_headers content_type) blob.content) 312 | `Tree _ -> 313 error_response (Resolvers.Bad_request "object is a tree, not a blob") 314 315 let raw_at repository _context path = 316 let* object_, _trail = Resolvers.object_at_path repository path in 317 match object_ with 318 | `Blob blob -> 319 let content_type = 320 match List.rev (String.split_on_char '/' path) with 321 | name :: _ -> mime_of_filename name 322 | [] -> "text/plain; charset=utf-8" 323 in 324 Lwt.return 325 (Dream.response ~headers:(raw_headers content_type) blob.content) 326 | `Tree _ -> 327 error_response (Resolvers.Bad_request "path names a tree, not a file") 328 end 329 330 let project_dir config subdir = 331 match Resolvers.scan_subdirectory config subdir with 332 | Ok nodes -> 333 let open Lwt.Syntax in 334 let repo_paths = collect_repo_paths ~prefix:subdir nodes in 335 let* dates = fetch_repo_dates config repo_paths in 336 let readme = Resolvers.read_subdir_readme config subdir in 337 Views.root (site config) ~dates ~prefix:subdir ?readme nodes 338 | Error error -> error_response error 339 340 (* Rendered pages are memoized per repository head: the key embeds the hash 341 HEAD names, so any new commit changes the key and an entry can never serve 342 a moved branch's old content. What CAN go stale until eviction is metadata 343 outside the object store — the description file — which is accepted. 344 Requests whose head cannot be resolved cheaply are served uncached. *) 345 let page_cache : (Dream.status * (string * string) list * string) Cache.t = 346 Cache.create ~capacity:256 347 348 (* Raw blobs pass through here too; the size cap keeps a handful of large 349 files from occupying the whole cache. *) 350 let max_cacheable_body_bytes = 512 * 1024 351 352 let respond_cached config name request serve = 353 match Resolvers.head_hash_hint config name with 354 | None -> serve () 355 | Some head -> ( 356 let key = String.concat "\x00" [ name; head; Dream.target request ] in 357 match Cache.find page_cache key with 358 | Some (status, headers, body) -> 359 Lwt.return (Dream.response ~status ~headers body) 360 | None -> 361 let open Lwt.Syntax in 362 let* response = serve () in 363 let* body = Dream.body response in 364 let status = Dream.status response in 365 let headers = Dream.all_headers response in 366 if 367 Dream.status_to_int status = 200 368 && String.length body <= max_cacheable_body_bytes 369 then Cache.add page_cache key (status, headers, body); 370 Lwt.return (Dream.response ~status ~headers body)) 371 372 (* The repository a route is scoped to, or [None] for the root page, which 373 depends on every repository and is not cached. *) 374 let route_repo = function 375 | Routes.Root -> None 376 | Routes.Project_dir name 377 | Routes.Repo name 378 | Routes.Commits name 379 | Routes.Commits_branch (name, _) 380 | Routes.Commit (name, _) 381 | Routes.Files name 382 | Routes.File (name, _) 383 | Routes.File_at (name, _) 384 | Routes.Raw_file (name, _) 385 | Routes.Raw_at (name, _) -> 386 Some name 387 388 let routes config = 389 let repo_dispatcher request = 390 let path = Dream.target request in 391 (* Strip leading slash and query string *) 392 let path = 393 if String.starts_with ~prefix:"/" path then 394 String.sub path 1 (String.length path - 1) 395 else path 396 in 397 let path = 398 match String.index_opt path '?' with 399 | None -> path 400 | Some i -> String.sub path 0 i 401 in 402 (* A path without a reserved segment names either a repository (serve its 403 summary) or a directory of repositories (serve the listing). The 404 filesystem decides: a name that opens as a repository is one. *) 405 let summary_or_directory name = 406 Lwt.bind (Resolvers.open_repository config name) (function 407 | Error (Resolvers.Not_found _) -> project_dir config name 408 | Error error -> error_response error 409 | Ok repository -> 410 let context = Repo.view_context config repository in 411 Lwt.finalize 412 (fun () -> Repo.summary config repository context) 413 (fun () -> Resolvers.close_repository repository)) 414 in 415 match Routes.dispatch path with 416 | None -> error_response (Not_found ("not found: " ^ Dream.target request)) 417 | Some route -> ( 418 let serve () = 419 match route with 420 | Routes.Root -> root config request 421 | Routes.Project_dir name | Routes.Repo name -> 422 summary_or_directory name 423 | Routes.Commits name -> 424 Repo.with_repository config name (fun repository context -> 425 Repo.commits config request repository context) 426 | Routes.Commits_branch (name, branch) -> 427 Repo.with_repository config name (fun repository context -> 428 Repo.commits_branch config repository context branch) 429 | Routes.Commit (name, hash) -> 430 Repo.with_repository config name (fun repository context -> 431 Repo.commit_id repository context hash) 432 | Routes.Files name -> 433 Repo.with_repository config name Repo.files_at_head 434 | Routes.File (name, hash) -> 435 Repo.with_repository config name (fun repository context -> 436 Repo.file_id repository context hash) 437 | Routes.File_at (name, path) -> 438 Repo.with_repository config name (fun repository context -> 439 Repo.file_at repository context path) 440 | Routes.Raw_file (name, hash) -> 441 Repo.with_repository config name (fun repository context -> 442 Repo.raw_file repository context hash) 443 | Routes.Raw_at (name, path) -> 444 Repo.with_repository config name (fun repository context -> 445 Repo.raw_at repository context path) 446 in 447 match route_repo route with 448 | None -> serve () 449 | Some name -> respond_cached config name request serve) 450 in 451 [ 452 Dream.get "/" (root config); 453 Dream.get "/static/**" Static_handler.handler; 454 Dream.get "/**" repo_dispatcher; 455 ] 456