[OCaml] Mobile-friendly clone of cgit.
1
(** URL paths, in both directions.
2
3
One {!t} value describes a page, and the same value both generates a link
4
({!path_of}) and is recovered from an incoming request ({!dispatch}).
5
Keeping the two directions in one module is what stops generated links and
6
served routes from drifting apart. *)
7
8
type t =
9
| Root
10
| Project_dir of string
11
| Repo of string
12
| Commits of string
13
| Commits_branch of string * string
14
| Commit of string * string
15
| Files of string
16
| File of string * string
17
| File_at of string * string
18
| Raw_file of string * string
19
| Raw_at of string * string
20
21
(* Path arguments in [File_at] and [Raw_at] carry repository file names, which
22
may contain characters that are not safe in a URL. [path_of] percent-encodes
23
each segment and [dispatch] decodes them, so the round trip preserves any
24
name. Repository names and object ids are emitted as-is: the former are
25
constrained by [Resolvers.is_valid_repo_name], the latter are hexadecimal. *)
26
27
let encode_segment segment =
28
let buffer = Buffer.create (String.length segment) in
29
String.iter
30
(fun char ->
31
match char with
32
| 'A' .. 'Z' | 'a' .. 'z' | '0' .. '9' | '-' | '.' | '_' | '~' ->
33
Buffer.add_char buffer char
34
| _ ->
35
Buffer.add_string buffer (Printf.sprintf "%%%02X" (Char.code char)))
36
segment;
37
Buffer.contents buffer
38
39
let encode_path path =
40
String.split_on_char '/' path |> List.map encode_segment |> String.concat "/"
41
42
let hex_digit_value = function
43
| '0' .. '9' as digit -> Some (Char.code digit - Char.code '0')
44
| 'a' .. 'f' as digit -> Some (Char.code digit - Char.code 'a' + 10)
45
| 'A' .. 'F' as digit -> Some (Char.code digit - Char.code 'A' + 10)
46
| _ -> None
47
48
(* A '%' that is not followed by two hexadecimal digits is kept literally
49
rather than rejected: the segment then simply names no existing file. *)
50
let decode_segment segment =
51
let length = String.length segment in
52
let buffer = Buffer.create length in
53
let rec go index =
54
if index >= length then ()
55
else if segment.[index] = '%' && index + 2 < length then (
56
match
57
(hex_digit_value segment.[index + 1], hex_digit_value segment.[index + 2])
58
with
59
| Some high, Some low ->
60
Buffer.add_char buffer (Char.chr ((high * 16) + low));
61
go (index + 3)
62
| _ ->
63
Buffer.add_char buffer '%';
64
go (index + 1))
65
else (
66
Buffer.add_char buffer segment.[index];
67
go (index + 1))
68
in
69
go 0;
70
Buffer.contents buffer
71
72
let decode_path segments =
73
List.map decode_segment segments |> String.concat "/"
74
75
(* Generate URL paths for routes *)
76
let path_of = function
77
| Root -> "/"
78
| Project_dir dir -> "/" ^ dir ^ "/"
79
| Repo repo -> "/" ^ repo ^ "/summary/"
80
| Commits repo -> "/" ^ repo ^ "/commits/"
81
| Commits_branch (repo, branch) -> "/" ^ repo ^ "/commits/" ^ branch
82
| Commit (repo, hash) -> "/" ^ repo ^ "/commit/" ^ hash
83
| Files repo -> "/" ^ repo ^ "/files/"
84
| File (repo, hash) -> "/" ^ repo ^ "/file/" ^ hash
85
| File_at (repo, path) -> "/" ^ repo ^ "/file/" ^ encode_path path
86
| Raw_file (repo, hash) -> "/" ^ repo ^ "/raw/" ^ hash
87
| Raw_at (repo, path) -> "/" ^ repo ^ "/raw/" ^ encode_path path
88
89
let known_actions = [ "summary"; "commits"; "commit"; "files"; "file"; "raw" ]
90
91
(* The store hashes objects with SHA-1, whose hexadecimal form is 40
92
characters. A single segment of that shape is an object id; anything else
93
under file/ or raw/ is a path. *)
94
let is_hex_hash candidate =
95
String.length candidate = 40
96
&& String.for_all
97
(function '0' .. '9' | 'a' .. 'f' | 'A' .. 'F' -> true | _ -> false)
98
candidate
99
100
(* The split point is the first segment naming a known action; everything
101
before it is the repository or directory path. Anything after the action
102
that the route shapes above do not account for is rejected rather than
103
ignored, so every accepted path is one [path_of] can regenerate. *)
104
let dispatch path =
105
let segments =
106
String.split_on_char '/' path |> List.filter (fun s -> s <> "")
107
in
108
let rec find_split repo_acc = function
109
| [] -> (
110
(* No action segment: the root, or a repository/directory path whose
111
page is the implicit summary. Only the filesystem can tell a
112
repository from a directory of repositories, so dispatch reports
113
[Project_dir] and the handler resolves which one it is. *)
114
match String.concat "/" (List.rev repo_acc) with
115
| "" -> Some Root
116
| repo -> Some (Project_dir repo))
117
| seg :: rest when List.mem seg known_actions -> (
118
let repo = String.concat "/" (List.rev repo_acc) in
119
if repo = "" then None
120
else
121
match (seg, rest) with
122
| "summary", [] -> Some (Repo repo)
123
| "commits", [] -> Some (Commits repo)
124
| "commits", [ branch ] -> Some (Commits_branch (repo, branch))
125
| "commit", [ hash ] -> Some (Commit (repo, hash))
126
| "files", [] -> Some (Files repo)
127
| "file", [ hash ] when is_hex_hash hash -> Some (File (repo, hash))
128
| "file", (_ :: _ as path) -> Some (File_at (repo, decode_path path))
129
| "raw", [ hash ] when is_hex_hash hash ->
130
Some (Raw_file (repo, hash))
131
| "raw", (_ :: _ as path) -> Some (Raw_at (repo, decode_path path))
132
| _ -> None)
133
| seg :: rest -> find_split (seg :: repo_acc) rest
134
in
135
find_split [] segments
136