max / makenotwork
7 files changed,
+1044 insertions,
-12 deletions
| @@ -9,6 +9,332 @@ | |||
| 9 | 9 | "version": "0.11.12" | |
| 10 | 10 | }, | |
| 11 | 11 | "paths": { | |
| 12 | + | "/api/git/{owner}/{repo}/notes": { | |
| 13 | + | "get": { | |
| 14 | + | "tags": [ | |
| 15 | + | "Git Notes" | |
| 16 | + | ], | |
| 17 | + | "summary": "`GET /api/git/{owner}/{repo}/notes`: the namespaces this repository carries.", | |
| 18 | + | "operationId": "list_namespaces", | |
| 19 | + | "parameters": [ | |
| 20 | + | { | |
| 21 | + | "name": "owner", | |
| 22 | + | "in": "path", | |
| 23 | + | "description": "Repository owner's username", | |
| 24 | + | "required": true, | |
| 25 | + | "schema": { | |
| 26 | + | "type": "string" | |
| 27 | + | } | |
| 28 | + | }, | |
| 29 | + | { | |
| 30 | + | "name": "repo", | |
| 31 | + | "in": "path", | |
| 32 | + | "description": "Repository name", | |
| 33 | + | "required": true, | |
| 34 | + | "schema": { | |
| 35 | + | "type": "string" | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ], | |
| 39 | + | "responses": { | |
| 40 | + | "200": { | |
| 41 | + | "description": "Namespaces, with a note count each", | |
| 42 | + | "content": { | |
| 43 | + | "application/json": { | |
| 44 | + | "schema": { | |
| 45 | + | "$ref": "#/components/schemas/NamespacesResponse" | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | }, | |
| 50 | + | "404": { | |
| 51 | + | "description": "No such repository, or not visible to the caller" | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | }, | |
| 56 | + | "/api/git/{owner}/{repo}/notes/search": { | |
| 57 | + | "get": { | |
| 58 | + | "tags": [ | |
| 59 | + | "Git Notes" | |
| 60 | + | ], | |
| 61 | + | "summary": "`GET /api/git/{owner}/{repo}/notes/search`: full-text search over the index.", | |
| 62 | + | "description": "`search` is a static segment and an object id is 40 or 64 hex characters, so\nit can never be shadowed by, or shadow, a real target on `/notes/{target}`.", | |
| 63 | + | "operationId": "search_notes", | |
| 64 | + | "parameters": [ | |
| 65 | + | { | |
| 66 | + | "name": "owner", | |
| 67 | + | "in": "path", | |
| 68 | + | "description": "Repository owner's username", | |
| 69 | + | "required": true, | |
| 70 | + | "schema": { | |
| 71 | + | "type": "string" | |
| 72 | + | } | |
| 73 | + | }, | |
| 74 | + | { | |
| 75 | + | "name": "repo", | |
| 76 | + | "in": "path", | |
| 77 | + | "description": "Repository name", | |
| 78 | + | "required": true, | |
| 79 | + | "schema": { | |
| 80 | + | "type": "string" | |
| 81 | + | } | |
| 82 | + | }, | |
| 83 | + | { | |
| 84 | + | "name": "q", | |
| 85 | + | "in": "query", | |
| 86 | + | "description": "Query: bare words, quoted phrases, `or`, `-excluded`", | |
| 87 | + | "required": true, | |
| 88 | + | "schema": { | |
| 89 | + | "type": "string" | |
| 90 | + | } | |
| 91 | + | }, | |
| 92 | + | { | |
| 93 | + | "name": "namespace", | |
| 94 | + | "in": "query", | |
| 95 | + | "description": "Restrict to one namespace", | |
| 96 | + | "required": false, | |
| 97 | + | "schema": { | |
| 98 | + | "type": "string" | |
| 99 | + | } | |
| 100 | + | }, | |
| 101 | + | { | |
| 102 | + | "name": "commits_only", | |
| 103 | + | "in": "query", | |
| 104 | + | "description": "Drop notes on blobs and trees", | |
| 105 | + | "required": false, | |
| 106 | + | "schema": { | |
| 107 | + | "type": "boolean" | |
| 108 | + | } | |
| 109 | + | }, | |
| 110 | + | { | |
| 111 | + | "name": "limit", | |
| 112 | + | "in": "query", | |
| 113 | + | "description": "Maximum hits, default 50, capped at 200", | |
| 114 | + | "required": false, | |
| 115 | + | "schema": { | |
| 116 | + | "type": "integer", | |
| 117 | + | "format": "int64" | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ], | |
| 121 | + | "responses": { | |
| 122 | + | "200": { | |
| 123 | + | "description": "Matching notes, and whether the index has seen this repository", | |
| 124 | + | "content": { | |
| 125 | + | "application/json": { | |
| 126 | + | "schema": { | |
| 127 | + | "$ref": "#/components/schemas/SearchResponse" | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | }, | |
| 132 | + | "404": { | |
| 133 | + | "description": "No such repository, or not visible to the caller" | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | }, | |
| 138 | + | "/api/git/{owner}/{repo}/notes/{target}": { | |
| 139 | + | "get": { | |
| 140 | + | "tags": [ | |
| 141 | + | "Git Notes" | |
| 142 | + | ], | |
| 143 | + | "summary": "`GET /api/git/{owner}/{repo}/notes/{target}`: one note.", | |
| 144 | + | "operationId": "get_note", | |
| 145 | + | "parameters": [ | |
| 146 | + | { | |
| 147 | + | "name": "owner", | |
| 148 | + | "in": "path", | |
| 149 | + | "description": "Repository owner's username", | |
| 150 | + | "required": true, | |
| 151 | + | "schema": { | |
| 152 | + | "type": "string" | |
| 153 | + | } | |
| 154 | + | }, | |
| 155 | + | { | |
| 156 | + | "name": "repo", | |
| 157 | + | "in": "path", | |
| 158 | + | "description": "Repository name", | |
| 159 | + | "required": true, | |
| 160 | + | "schema": { | |
| 161 | + | "type": "string" | |
| 162 | + | } | |
| 163 | + | }, | |
| 164 | + | { | |
| 165 | + | "name": "target", | |
| 166 | + | "in": "path", | |
| 167 | + | "description": "Full object id of the annotated object", | |
| 168 | + | "required": true, | |
| 169 | + | "schema": { | |
| 170 | + | "type": "string" | |
| 171 | + | } | |
| 172 | + | }, | |
| 173 | + | { | |
| 174 | + | "name": "namespace", | |
| 175 | + | "in": "query", | |
| 176 | + | "description": "Notes namespace, default `commits`", | |
| 177 | + | "required": false, | |
| 178 | + | "schema": { | |
| 179 | + | "type": "string" | |
| 180 | + | } | |
| 181 | + | }, | |
| 182 | + | { | |
| 183 | + | "name": "attribution", | |
| 184 | + | "in": "query", | |
| 185 | + | "description": "Include who wrote the note; costs a bounded walk of the notes ref", | |
| 186 | + | "required": false, | |
| 187 | + | "schema": { | |
| 188 | + | "type": "boolean" | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ], | |
| 192 | + | "responses": { | |
| 193 | + | "200": { | |
| 194 | + | "description": "The note", | |
| 195 | + | "content": { | |
| 196 | + | "application/json": { | |
| 197 | + | "schema": { | |
| 198 | + | "$ref": "#/components/schemas/NoteResponse" | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | }, | |
| 203 | + | "404": { | |
| 204 | + | "description": "No such repository, namespace, or note" | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | }, | |
| 208 | + | "put": { | |
| 209 | + | "tags": [ | |
| 210 | + | "Git Notes" | |
| 211 | + | ], | |
| 212 | + | "summary": "`PUT /api/git/{owner}/{repo}/notes/{target}`: add or replace a note.", | |
| 213 | + | "operationId": "put_note", | |
| 214 | + | "parameters": [ | |
| 215 | + | { | |
| 216 | + | "name": "owner", | |
| 217 | + | "in": "path", | |
| 218 | + | "description": "Repository owner's username", | |
| 219 | + | "required": true, | |
| 220 | + | "schema": { | |
| 221 | + | "type": "string" | |
| 222 | + | } | |
| 223 | + | }, | |
| 224 | + | { | |
| 225 | + | "name": "repo", | |
| 226 | + | "in": "path", | |
| 227 | + | "description": "Repository name", | |
| 228 | + | "required": true, | |
| 229 | + | "schema": { | |
| 230 | + | "type": "string" | |
| 231 | + | } | |
| 232 | + | }, | |
| 233 | + | { | |
| 234 | + | "name": "target", | |
| 235 | + | "in": "path", | |
| 236 | + | "description": "Full object id of the commit to annotate", | |
| 237 | + | "required": true, | |
| 238 | + | "schema": { | |
| 239 | + | "type": "string" | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | ], | |
| 243 | + | "requestBody": { | |
| 244 | + | "content": { | |
| 245 | + | "application/json": { | |
| 246 | + | "schema": { | |
| 247 | + | "$ref": "#/components/schemas/PutNoteRequest" | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | }, | |
| 251 | + | "required": true | |
| 252 | + | }, | |
| 253 | + | "responses": { | |
| 254 | + | "200": { | |
| 255 | + | "description": "What the write did", | |
| 256 | + | "content": { | |
| 257 | + | "application/json": { | |
| 258 | + | "schema": { | |
| 259 | + | "$ref": "#/components/schemas/WriteResponse" | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | }, | |
| 264 | + | "401": { | |
| 265 | + | "description": "No credential; writes need a push-scoped personal access token" | |
| 266 | + | }, | |
| 267 | + | "403": { | |
| 268 | + | "description": "A session cookie, a read-only token, or an account that cannot push here" | |
| 269 | + | }, | |
| 270 | + | "404": { | |
| 271 | + | "description": "No such repository, or no such commit in it" | |
| 272 | + | }, | |
| 273 | + | "422": { | |
| 274 | + | "description": "Reserved namespace, empty or oversized content, or sustained write contention" | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | }, | |
| 278 | + | "delete": { | |
| 279 | + | "tags": [ | |
| 280 | + | "Git Notes" | |
| 281 | + | ], | |
| 282 | + | "summary": "`DELETE /api/git/{owner}/{repo}/notes/{target}`: remove a note.", | |
| 283 | + | "operationId": "delete_note", | |
| 284 | + | "parameters": [ | |
| 285 | + | { | |
| 286 | + | "name": "owner", | |
| 287 | + | "in": "path", | |
| 288 | + | "description": "Repository owner's username", | |
| 289 | + | "required": true, | |
| 290 | + | "schema": { | |
| 291 | + | "type": "string" | |
| 292 | + | } | |
| 293 | + | }, | |
| 294 | + | { | |
| 295 | + | "name": "repo", | |
| 296 | + | "in": "path", | |
| 297 | + | "description": "Repository name", | |
| 298 | + | "required": true, | |
| 299 | + | "schema": { | |
| 300 | + | "type": "string" | |
| 301 | + | } | |
| 302 | + | }, | |
| 303 | + | { | |
| 304 | + | "name": "target", | |
| 305 | + | "in": "path", | |
| 306 | + | "description": "Full object id of the annotated commit", | |
| 307 | + | "required": true, | |
| 308 | + | "schema": { | |
| 309 | + | "type": "string" | |
| 310 | + | } | |
| 311 | + | }, | |
| 312 | + | { | |
| 313 | + | "name": "namespace", | |
| 314 | + | "in": "query", | |
| 315 | + | "description": "Notes namespace, default `commits`", | |
| 316 | + | "required": false, | |
| 317 | + | "schema": { | |
| 318 | + | "type": "string" | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ], | |
| 322 | + | "responses": { | |
| 323 | + | "204": { | |
| 324 | + | "description": "The note is gone, whether or not it was there" | |
| 325 | + | }, | |
| 326 | + | "401": { | |
| 327 | + | "description": "No credential; writes need a push-scoped personal access token" | |
| 328 | + | }, | |
| 329 | + | "403": { | |
| 330 | + | "description": "A session cookie, a read-only token, or an account that cannot push here" | |
| 331 | + | }, | |
| 332 | + | "404": { | |
| 333 | + | "description": "No such repository, or no such commit in it" | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | }, | |
| 12 | 338 | "/api/v1/items/{item_id}/license.txt": { | |
| 13 | 339 | "get": { | |
| 14 | 340 | "tags": [ | |
| @@ -1729,6 +2055,117 @@ | |||
| 1729 | 2055 | } | |
| 1730 | 2056 | } | |
| 1731 | 2057 | }, | |
| 2058 | + | "NamespaceEntry": { | |
| 2059 | + | "type": "object", | |
| 2060 | + | "description": "One notes namespace as the repository holds it.", | |
| 2061 | + | "required": [ | |
| 2062 | + | "name", | |
| 2063 | + | "git_ref", | |
| 2064 | + | "tip", | |
| 2065 | + | "notes" | |
| 2066 | + | ], | |
| 2067 | + | "properties": { | |
| 2068 | + | "git_ref": { | |
| 2069 | + | "type": "string", | |
| 2070 | + | "description": "The ref it lives on, for a caller assembling a fetch refspec." | |
| 2071 | + | }, | |
| 2072 | + | "name": { | |
| 2073 | + | "type": "string", | |
| 2074 | + | "description": "Namespace as a person says it: `commits`, `review/security`." | |
| 2075 | + | }, | |
| 2076 | + | "notes": { | |
| 2077 | + | "type": "integer", | |
| 2078 | + | "format": "int64", | |
| 2079 | + | "description": "Notes in the namespace." | |
| 2080 | + | }, | |
| 2081 | + | "tip": { | |
| 2082 | + | "type": "string", | |
| 2083 | + | "description": "Object id the ref points at." | |
| 2084 | + | } | |
| 2085 | + | } | |
| 2086 | + | }, | |
| 2087 | + | "NamespacesResponse": { | |
| 2088 | + | "type": "object", | |
| 2089 | + | "required": [ | |
| 2090 | + | "data" | |
| 2091 | + | ], | |
| 2092 | + | "properties": { | |
| 2093 | + | "data": { | |
| 2094 | + | "type": "array", | |
| 2095 | + | "items": { | |
| 2096 | + | "$ref": "#/components/schemas/NamespaceEntry" | |
| 2097 | + | } | |
| 2098 | + | } | |
| 2099 | + | } | |
| 2100 | + | }, | |
| 2101 | + | "NoteAttribution": { | |
| 2102 | + | "type": "object", | |
| 2103 | + | "description": "Who wrote the note and when, from the notes ref's own history.", | |
| 2104 | + | "required": [ | |
| 2105 | + | "commit", | |
| 2106 | + | "name", | |
| 2107 | + | "email", | |
| 2108 | + | "at", | |
| 2109 | + | "exact" | |
| 2110 | + | ], | |
| 2111 | + | "properties": { | |
| 2112 | + | "at": { | |
| 2113 | + | "type": "string", | |
| 2114 | + | "format": "date-time" | |
| 2115 | + | }, | |
| 2116 | + | "commit": { | |
| 2117 | + | "type": "string", | |
| 2118 | + | "description": "The notes commit that set the note to what it says now." | |
| 2119 | + | }, | |
| 2120 | + | "email": { | |
| 2121 | + | "type": "string" | |
| 2122 | + | }, | |
| 2123 | + | "exact": { | |
| 2124 | + | "type": "boolean", | |
| 2125 | + | "description": "False when the bounded walk ran out before finding the change, so the\ncommit named is as far back as it looked rather than the one responsible." | |
| 2126 | + | }, | |
| 2127 | + | "name": { | |
| 2128 | + | "type": "string" | |
| 2129 | + | } | |
| 2130 | + | } | |
| 2131 | + | }, | |
| 2132 | + | "NoteResponse": { | |
| 2133 | + | "type": "object", | |
| 2134 | + | "required": [ | |
| 2135 | + | "namespace", | |
| 2136 | + | "target", | |
| 2137 | + | "blob", | |
| 2138 | + | "content" | |
| 2139 | + | ], | |
| 2140 | + | "properties": { | |
| 2141 | + | "attribution": { | |
| 2142 | + | "oneOf": [ | |
| 2143 | + | { | |
| 2144 | + | "type": "null" | |
| 2145 | + | }, | |
| 2146 | + | { | |
| 2147 | + | "$ref": "#/components/schemas/NoteAttribution", | |
| 2148 | + | "description": "Present only when the request asked for it." | |
| 2149 | + | } | |
| 2150 | + | ] | |
| 2151 | + | }, | |
| 2152 | + | "blob": { | |
| 2153 | + | "type": "string", | |
| 2154 | + | "description": "The blob holding the content." | |
| 2155 | + | }, | |
| 2156 | + | "content": { | |
| 2157 | + | "type": "string", | |
| 2158 | + | "description": "Note content. Note bodies are bytes, not text; anything that is not UTF-8\nis replaced rather than rejected, since a note git accepted has to be\nreadable here." | |
| 2159 | + | }, | |
| 2160 | + | "namespace": { | |
| 2161 | + | "type": "string" | |
| 2162 | + | }, | |
| 2163 | + | "target": { | |
| 2164 | + | "type": "string", | |
| 2165 | + | "description": "The annotated object. Need not be a commit: notes on blobs and trees are\nlegal and this returns them." | |
| 2166 | + | } | |
| 2167 | + | } | |
| 2168 | + | }, | |
| 1732 | 2169 | "PendingKeyInfo": { | |
| 1733 | 2170 | "type": "object", | |
| 1734 | 2171 | "required": [ | |
| @@ -1903,6 +2340,24 @@ | |||
| 1903 | 2340 | } | |
| 1904 | 2341 | } | |
| 1905 | 2342 | }, | |
| 2343 | + | "PutNoteRequest": { | |
| 2344 | + | "type": "object", | |
| 2345 | + | "required": [ | |
| 2346 | + | "content" | |
| 2347 | + | ], | |
| 2348 | + | "properties": { | |
| 2349 | + | "content": { | |
| 2350 | + | "type": "string", | |
| 2351 | + | "description": "The note body. Trailing whitespace is trimmed and a newline appended, the\nsame shape git's own notes carry." | |
| 2352 | + | }, | |
| 2353 | + | "namespace": { | |
| 2354 | + | "type": [ | |
| 2355 | + | "string", | |
| 2356 | + | "null" | |
| 2357 | + | ] | |
| 2358 | + | } | |
| 2359 | + | } | |
| 2360 | + | }, | |
| 1906 | 2361 | "RegisterDeviceRequest": { | |
| 1907 | 2362 | "type": "object", | |
| 1908 | 2363 | "required": [ | |
| @@ -2020,6 +2475,74 @@ | |||
| 2020 | 2475 | } | |
| 2021 | 2476 | } | |
| 2022 | 2477 | }, | |
| 2478 | + | "SearchHit": { | |
| 2479 | + | "type": "object", | |
| 2480 | + | "description": "One search hit, out of the index.", | |
| 2481 | + | "required": [ | |
| 2482 | + | "namespace", | |
| 2483 | + | "target", | |
| 2484 | + | "blob", | |
| 2485 | + | "content", | |
| 2486 | + | "target_is_commit", | |
| 2487 | + | "summary", | |
| 2488 | + | "updated_at", | |
| 2489 | + | "updated_by" | |
| 2490 | + | ], | |
| 2491 | + | "properties": { | |
| 2492 | + | "blob": { | |
| 2493 | + | "type": "string" | |
| 2494 | + | }, | |
| 2495 | + | "content": { | |
| 2496 | + | "type": "string" | |
| 2497 | + | }, | |
| 2498 | + | "namespace": { | |
| 2499 | + | "type": "string" | |
| 2500 | + | }, | |
| 2501 | + | "summary": { |
Lines truncated
| @@ -27,6 +27,12 @@ | |||
| 27 | 27 | crate::routes::api::license_keys::license_verify, | |
| 28 | 28 | crate::routes::api::license_keys::license_deactivate, | |
| 29 | 29 | crate::routes::api::license_keys::license_text, | |
| 30 | + | // Git Notes | |
| 31 | + | crate::routes::api::git_notes::list_namespaces, | |
| 32 | + | crate::routes::api::git_notes::get_note, | |
| 33 | + | crate::routes::api::git_notes::put_note, | |
| 34 | + | crate::routes::api::git_notes::delete_note, | |
| 35 | + | crate::routes::api::git_notes::search_notes, | |
| 30 | 36 | // SyncKit, Auth | |
| 31 | 37 | crate::routes::synckit::auth::sync_auth, | |
| 32 | 38 | crate::routes::synckit::auth::validate_app, | |
| @@ -74,6 +80,15 @@ | |||
| 74 | 80 | crate::routes::api::license_keys::LicenseVerifyRequest, | |
| 75 | 81 | crate::routes::api::license_keys::LicenseVerifyResponse, | |
| 76 | 82 | crate::routes::api::license_keys::LicenseDeactivateRequest, | |
| 83 | + | // Git Notes | |
| 84 | + | crate::routes::api::git_notes::NamespaceEntry, | |
| 85 | + | crate::routes::api::git_notes::NamespacesResponse, | |
| 86 | + | crate::routes::api::git_notes::NoteAttribution, | |
| 87 | + | crate::routes::api::git_notes::NoteResponse, | |
| 88 | + | crate::routes::api::git_notes::PutNoteRequest, | |
| 89 | + | crate::routes::api::git_notes::WriteResponse, | |
| 90 | + | crate::routes::api::git_notes::SearchHit, | |
| 91 | + | crate::routes::api::git_notes::SearchResponse, | |
| 77 | 92 | // SyncKit | |
| 78 | 93 | crate::routes::synckit::SyncAuthRequest, | |
| 79 | 94 | crate::routes::synckit::SyncAuthResponse, | |
| @@ -126,6 +141,7 @@ | |||
| 126 | 141 | tags( | |
| 127 | 142 | (name = "License Keys", description = "Public license key validation, activation, and deactivation. Stable API: response shapes are frozen."), | |
| 128 | 143 | (name = "SyncKit", description = "E2E encrypted cloud sync for indie apps. JWT auth via /api/v1/sync/auth, then Bearer token on all other endpoints."), | |
| 144 | + | (name = "Git Notes", description = "Read and write refs/notes/* on a repository. Reads answer from the repository and take a session or a personal access token; writes take a push-scoped personal access token as HTTP Basic auth, the same credential git push uses."), | |
| 129 | 145 | ), | |
| 130 | 146 | security( | |
| 131 | 147 | ("bearer" = []), |
| @@ -25,6 +25,7 @@ | |||
| 25 | 25 | mod domains; | |
| 26 | 26 | mod exports; | |
| 27 | 27 | mod follows; | |
| 28 | + | pub(crate) mod git_notes; | |
| 28 | 29 | pub(crate) mod git_tokens; | |
| 29 | 30 | mod guest_checkout; | |
| 30 | 31 | mod imports; | |
| @@ -61,7 +62,10 @@ | |||
| 61 | 62 | ||
| 62 | 63 | use crate::{ | |
| 63 | 64 | AppState, constants, | |
| 64 | - | csrf::{CsrfRouter, delete_csrf, post_csrf, post_csrf_skip, put_csrf}, | |
| 65 | + | csrf::{ | |
| 66 | + | CsrfRouter, delete_csrf, delete_csrf_skip, post_csrf, post_csrf_skip, put_csrf, | |
| 67 | + | put_csrf_skip, | |
| 68 | + | }, | |
| 65 | 69 | db::{self, BlogPostId, ItemId, ProjectId, ProjectType, UserId}, | |
| 66 | 70 | error::{ApiErrorMessage, AppError, Result}, | |
| 67 | 71 | }; | |
| @@ -69,6 +73,8 @@ | |||
| 69 | 73 | const LICENSE_BEARER_SKIP: &str = "license API: bearer license key, no session"; | |
| 70 | 74 | const GUEST_CHECKOUT_SKIP: &str = "guest checkout: pre-auth, no session"; | |
| 71 | 75 | const CSP_REPORT_SKIP: &str = "CSP report: browser-posted, no session"; | |
| 76 | + | const GIT_NOTES_SKIP: &str = | |
| 77 | + | "git notes API: push-scoped personal access token, session cookie rejected"; | |
| 72 | 78 | ||
| 73 | 79 | /// Fetch a project and verify the user owns it. Shared by all ownership checks | |
| 74 | 80 | /// that go through a project (items, blog posts, direct project access). | |
| @@ -539,6 +545,19 @@ | |||
| 539 | 545 | .route("/api/domains/{id}", delete_csrf(domains::remove_domain)) | |
| 540 | 546 | // Invite codes | |
| 541 | 547 | .route("/api/invites/create", post_csrf(users::create_invite)) | |
| 548 | + | // Git notes (write). CSRF-skipped because the caller is a script rather | |
| 549 | + | // than a page MNW rendered, so it holds no token to send; the handlers | |
| 550 | + | // require a push-scoped personal access token and reject a session | |
| 551 | + | // cookie, which is the same seal `receive-pack` uses for the same | |
| 552 | + | // reason. The `origin_gate` still wraps these. | |
| 553 | + | .route( | |
| 554 | + | "/api/git/{owner}/{repo}/notes/{target}", | |
| 555 | + | put_csrf_skip(GIT_NOTES_SKIP, git_notes::put_note), | |
| 556 | + | ) | |
| 557 | + | .route( | |
| 558 | + | "/api/git/{owner}/{repo}/notes/{target}", | |
| 559 | + | delete_csrf_skip(GIT_NOTES_SKIP, git_notes::delete_note), | |
| 560 | + | ) | |
| 542 | 561 | // Per-repository issue notifications (mailing-list step 6) | |
| 543 | 562 | .route( | |
| 544 | 563 | "/api/repos/notifications", | |
| @@ -678,6 +697,20 @@ | |||
| 678 | 697 | .route_get("/api/domains", get(domains::get_domain)) | |
| 679 | 698 | .route_get("/api/domains/caddy-ask", get(domains::caddy_ask)) | |
| 680 | 699 | .route_get("/api/restart-status", get(internal::restart_status)) | |
| 700 | + | // Git notes (read). `search` is a static segment and an object id is 40 | |
| 701 | + | // or 64 hex characters, so the two never shadow each other. | |
| 702 | + | .route_get( | |
| 703 | + | "/api/git/{owner}/{repo}/notes", | |
| 704 | + | get(git_notes::list_namespaces), | |
| 705 | + | ) | |
| 706 | + | .route_get( | |
| 707 | + | "/api/git/{owner}/{repo}/notes/search", | |
| 708 | + | get(git_notes::search_notes), | |
| 709 | + | ) | |
| 710 | + | .route_get( | |
| 711 | + | "/api/git/{owner}/{repo}/notes/{target}", | |
| 712 | + | get(git_notes::get_note), | |
| 713 | + | ) | |
| 681 | 714 | // Cart (read) | |
| 682 | 715 | .route_get("/api/cart/count", get(cart::cart_count)) | |
| 683 | 716 | // Import system (read) |
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | mod notes_inbox; | |
| 5 | 5 | pub mod notes_index; | |
| 6 | 6 | pub mod notes_view; | |
| 7 | - | mod notes_write; | |
| 7 | + | pub(crate) mod notes_write; | |
| 8 | 8 | mod raw; | |
| 9 | 9 | ||
| 10 | 10 | use std::path::PathBuf; |
| @@ -365,7 +365,7 @@ | |||
| 365 | 365 | /// could not be updated is a stale projection, which the read paths tolerate; | |
| 366 | 366 | /// turning it into an error the writer sees would be the projection vetoing the | |
| 367 | 367 | /// thing it is a projection of. | |
| 368 | - | pub(super) async fn reindex_after_write( | |
| 368 | + | pub(crate) async fn reindex_after_write( | |
| 369 | 369 | db_pool: &PgPool, | |
| 370 | 370 | config: &Config, | |
| 371 | 371 | repo_id: GitRepoId, |
| @@ -125,7 +125,7 @@ | |||
| 125 | 125 | // a 404 before any repository work happens. | |
| 126 | 126 | let target = notes::Oid::from_hex(oid_str.as_bytes()).map_err(|_| AppError::NotFound)?; | |
| 127 | 127 | let gix_target = gix::ObjectId::from_hex(oid_str.as_bytes()).map_err(|_| AppError::NotFound)?; | |
| 128 | - | let who = identity(user); | |
| 128 | + | let who = identity(user.display_name.as_deref(), user.username.as_str()); | |
| 129 | 129 | let written_namespace = namespace.clone(); | |
| 130 | 130 | ||
| 131 | 131 | let merged = resolved | |
| @@ -187,7 +187,7 @@ | |||
| 187 | 187 | /// anyone who could push one over SSH may write one here. Whether a signed-in | |
| 188 | 188 | /// stranger may annotate a public repo is a moderation question with its own | |
| 189 | 189 | /// decision, deliberately not answered by this function. | |
| 190 | - | pub(super) async fn can_write_notes( | |
| 190 | + | pub(crate) async fn can_write_notes( | |
| 191 | 191 | db: &PgPool, | |
| 192 | 192 | resolved: &super::ResolvedRepo, | |
| 193 | 193 | user_id: crate::db::UserId, | |
| @@ -198,23 +198,27 @@ | |||
| 198 | 198 | db::repo_collaborators::can_user_push(db, resolved.db_repo.id, user_id).await | |
| 199 | 199 | } | |
| 200 | 200 | ||
| 201 | - | /// The signature a web-written note carries. | |
| 201 | + | /// The signature a note MNW writes carries, whoever asked for it. | |
| 202 | + | /// | |
| 203 | + | /// Takes the two fields rather than a `SessionUser` because the JSON API | |
| 204 | + | /// authenticates by personal access token and has a `DbUser` instead. The | |
| 205 | + | /// identity is a property of the account, not of how it signed in, and the API | |
| 206 | + | /// writing a different email than the browser would put two addresses for one | |
| 207 | + | /// person into a repository nobody can rewrite. | |
| 202 | 208 | /// | |
| 203 | 209 | /// Name and email are both scrubbed of the characters git's commit format uses | |
| 204 | 210 | /// as delimiters. A display name holding an angle bracket or a newline would | |
| 205 | 211 | /// otherwise write a commit object that parses as something other than what was | |
| 206 | 212 | /// intended, which is a malformed-object bug at best. | |
| 207 | - | fn identity(user: &crate::auth::SessionUser) -> notes::Signature { | |
| 208 | - | let name = user | |
| 209 | - | .display_name | |
| 210 | - | .as_deref() | |
| 213 | + | pub(crate) fn identity(display_name: Option<&str>, username: &str) -> notes::Signature { | |
| 214 | + | let name = display_name | |
| 211 | 215 | .map(str::trim) | |
| 212 | 216 | .filter(|n| !n.is_empty()) | |
| 213 | - | .unwrap_or(user.username.as_str()); | |
| 217 | + | .unwrap_or(username); | |
| 214 | 218 | ||
| 215 | 219 | notes::Signature { | |
| 216 | 220 | name: scrub(name), | |
| 217 | - | email: format!("{}@{NOTE_EMAIL_DOMAIN}", scrub(user.username.as_str())), | |
| 221 | + | email: format!("{}@{NOTE_EMAIL_DOMAIN}", scrub(username)), | |
| 218 | 222 | time: chrono::Utc::now(), | |
| 219 | 223 | } | |
| 220 | 224 | } |
| @@ -1,0 +1,685 @@ | |||
| 1 | + | //! JSON API for `refs/notes/*`. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: mnw-server-git-notes --> | |
| 4 | + | //! | |
| 5 | + | //! The point of this surface is tooling: a build script writing its result onto | |
| 6 | + | //! the commit it built, a review bot recording a verdict. It adds no semantics. | |
| 7 | + | //! Reads come from the repository through `crate::git::notes`, writes go through | |
| 8 | + | //! the same `write_note` the browser form uses, and the authorization, the | |
| 9 | + | //! namespace rules and the committer identity are `notes_write`'s, called rather | |
| 10 | + | //! than restated. | |
| 11 | + | //! | |
| 12 | + | //! **Reads answer from the repository, not from the index.** The one exception | |
| 13 | + | //! is search, which is a full-text query the tree cannot serve; it reports | |
| 14 | + | //! whether the index has ever seen this repository so a caller can tell "no | |
| 15 | + | //! matches" from "nothing indexed yet" (`indexed` in the response). | |
| 16 | + | //! | |
| 17 | + | //! **Writes authenticate by push-scoped personal access token only.** A session | |
| 18 | + | //! cookie is refused, exactly as `receive-pack` refuses one (`raw.rs` | |
| 19 | + | //! `authorize_push`). These routes cannot carry a CSRF token, since the caller | |
| 20 | + | //! is a script rather than a page MNW rendered, so they are registered | |
| 21 | + | //! CSRF-skipped; a cookie would then be an ambient credential behind a route | |
| 22 | + | //! with no CSRF seal. `Authorization: Basic` cannot be set by a cross-site form, | |
| 23 | + | //! so requiring it is the seal. Reads accept either, which is what the browse | |
| 24 | + | //! path already does and costs nothing: a GET changes nothing and no CORS policy | |
| 25 | + | //! lets another origin read the response. | |
| 26 | + | ||
| 27 | + | use axum::{ | |
| 28 | + | Json, | |
| 29 | + | extract::{Path, Query, State}, | |
| 30 | + | http::{HeaderMap, StatusCode}, | |
| 31 | + | response::IntoResponse, | |
| 32 | + | }; | |
| 33 | + | use chrono::{DateTime, Utc}; | |
| 34 | + | use serde::{Deserialize, Serialize}; | |
| 35 | + | use sqlx::PgPool; | |
| 36 | + | use utoipa::ToSchema; | |
| 37 | + | ||
| 38 | + | use crate::{ | |
| 39 | + | auth::MaybeUserVerified, | |
| 40 | + | config::Config, | |
| 41 | + | db::{self, GitRepoId}, | |
| 42 | + | error::{AppError, Result}, | |
| 43 | + | git::notes::{self, GixEngine, Oid}, | |
| 44 | + | routes::git::{GitHttpPrincipal, ResolvedRepo, notes_index, notes_write, resolve_repo}, | |
| 45 | + | validation, | |
| 46 | + | }; | |
| 47 | + | ||
| 48 | + | /// How far back the attribution walk may look, matching the commit page's | |
| 49 | + | /// bound. It is off by default here: it costs a second history walk per note, | |
| 50 | + | /// and a script reading notes in bulk has no use for who typed them. | |
| 51 | + | const ATTRIBUTION_MAX_COMMITS: usize = 50; | |
| 52 | + | ||
| 53 | + | /// Default and ceiling for `limit` on search. The ceiling exists because the | |
| 54 | + | /// index holds whole note bodies, so a large page is a large response. | |
| 55 | + | const SEARCH_LIMIT_DEFAULT: i64 = 50; | |
| 56 | + | const SEARCH_LIMIT_MAX: i64 = 200; | |
| 57 | + | ||
| 58 | + | // ── Response shapes ── | |
| 59 | + | ||
| 60 | + | /// One notes namespace as the repository holds it. | |
| 61 | + | #[derive(Serialize, ToSchema)] | |
| 62 | + | pub(crate) struct NamespaceEntry { | |
| 63 | + | /// Namespace as a person says it: `commits`, `review/security`. | |
| 64 | + | pub name: String, | |
| 65 | + | /// The ref it lives on, for a caller assembling a fetch refspec. | |
| 66 | + | pub git_ref: String, | |
| 67 | + | /// Object id the ref points at. | |
| 68 | + | pub tip: String, | |
| 69 | + | /// Notes in the namespace. | |
| 70 | + | pub notes: i64, | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | #[derive(Serialize, ToSchema)] | |
| 74 | + | pub(crate) struct NamespacesResponse { | |
| 75 | + | pub data: Vec<NamespaceEntry>, | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Who wrote the note and when, from the notes ref's own history. | |
| 79 | + | #[derive(Serialize, ToSchema)] | |
| 80 | + | pub(crate) struct NoteAttribution { | |
| 81 | + | /// The notes commit that set the note to what it says now. | |
| 82 | + | pub commit: String, | |
| 83 | + | pub name: String, | |
| 84 | + | pub email: String, | |
| 85 | + | pub at: DateTime<Utc>, | |
| 86 | + | /// False when the bounded walk ran out before finding the change, so the | |
| 87 | + | /// commit named is as far back as it looked rather than the one responsible. | |
| 88 | + | pub exact: bool, | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | #[derive(Serialize, ToSchema)] | |
| 92 | + | pub(crate) struct NoteResponse { | |
| 93 | + | pub namespace: String, | |
| 94 | + | /// The annotated object. Need not be a commit: notes on blobs and trees are | |
| 95 | + | /// legal and this returns them. | |
| 96 | + | pub target: String, | |
| 97 | + | /// The blob holding the content. | |
| 98 | + | pub blob: String, | |
| 99 | + | /// Note content. Note bodies are bytes, not text; anything that is not UTF-8 | |
| 100 | + | /// is replaced rather than rejected, since a note git accepted has to be | |
| 101 | + | /// readable here. | |
| 102 | + | pub content: String, | |
| 103 | + | /// Present only when the request asked for it. | |
| 104 | + | pub attribution: Option<NoteAttribution>, | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// What a write did. | |
| 108 | + | #[derive(Serialize, ToSchema)] | |
| 109 | + | pub(crate) struct WriteResponse { | |
| 110 | + | pub namespace: String, | |
| 111 | + | pub target: String, | |
| 112 | + | /// `written` when the ref moved, `unchanged` when the namespace already said | |
| 113 | + | /// exactly this. Re-putting an identical note is not an error and costs no | |
| 114 | + | /// commit. | |
| 115 | + | pub status: &'static str, | |
| 116 | + | /// Somebody else annotated the same target while this write was in flight | |
| 117 | + | /// and the two were merged, so the stored note is not byte-for-byte what was | |
| 118 | + | /// sent. Re-read it if that matters. | |
| 119 | + | pub merged: bool, | |
| 120 | + | /// Where the namespace points now, absent when nothing was written. | |
| 121 | + | pub tip: Option<String>, | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// One search hit, out of the index. | |
| 125 | + | #[derive(Serialize, ToSchema)] | |
| 126 | + | pub(crate) struct SearchHit { | |
| 127 | + | pub namespace: String, | |
| 128 | + | pub target: String, | |
| 129 | + | pub blob: String, | |
| 130 | + | pub content: String, | |
| 131 | + | /// Whether the annotated object is a commit. False for a note on a blob or | |
| 132 | + | /// a tree, where `summary` and `time` are empty. | |
| 133 | + | pub target_is_commit: bool, | |
| 134 | + | pub summary: String, | |
| 135 | + | pub time: Option<DateTime<Utc>>, | |
| 136 | + | pub updated_at: DateTime<Utc>, | |
| 137 | + | pub updated_by: String, | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[derive(Serialize, ToSchema)] | |
| 141 | + | pub(crate) struct SearchResponse { | |
| 142 | + | pub data: Vec<SearchHit>, | |
| 143 | + | /// False when the index has never seen this repository, which makes an | |
| 144 | + | /// empty `data` mean "not searchable yet" rather than "no matches". The | |
| 145 | + | /// repository still holds its notes and every other endpoint here returns | |
| 146 | + | /// them; only search needs the index. | |
| 147 | + | pub indexed: bool, | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // ── Request shapes ── | |
| 151 | + | ||
| 152 | + | #[derive(Deserialize, ToSchema)] | |
| 153 | + | pub(crate) struct NamespaceQuery { | |
| 154 | + | /// Defaults to `commits`, git's own default namespace. | |
| 155 | + | pub namespace: Option<String>, | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | #[derive(Deserialize, ToSchema)] | |
| 159 | + | pub(crate) struct GetNoteQuery { | |
| 160 | + | pub namespace: Option<String>, | |
| 161 | + | /// Ask for the attribution walk. Off by default because it costs a walk of | |
| 162 | + | /// the notes ref per note. | |
| 163 | + | #[serde(default)] | |
| 164 | + | pub attribution: bool, | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | #[derive(Deserialize, ToSchema)] | |
| 168 | + | pub(crate) struct PutNoteRequest { | |
| 169 | + | pub namespace: Option<String>, | |
| 170 | + | /// The note body. Trailing whitespace is trimmed and a newline appended, the | |
| 171 | + | /// same shape git's own notes carry. | |
| 172 | + | pub content: String, | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[derive(Deserialize, ToSchema)] | |
| 176 | + | pub(crate) struct SearchQuery { | |
| 177 | + | /// The query, in `websearch_to_tsquery` syntax: bare words, `"quoted | |
| 178 | + | /// phrases"`, `or`, and `-excluded`. | |
| 179 | + | pub q: String, | |
| 180 | + | pub namespace: Option<String>, | |
| 181 | + | /// Restrict to notes on commits, dropping notes on blobs and trees. | |
| 182 | + | #[serde(default)] | |
| 183 | + | pub commits_only: bool, | |
| 184 | + | pub limit: Option<i64>, | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // ── Handlers ── | |
| 188 | + | ||
| 189 | + | /// `GET /api/git/{owner}/{repo}/notes`: the namespaces this repository carries. | |
| 190 | + | #[utoipa::path( | |
| 191 | + | get, | |
| 192 | + | path = "/api/git/{owner}/{repo}/notes", | |
| 193 | + | tag = "Git Notes", | |
| 194 | + | params( | |
| 195 | + | ("owner" = String, Path, description = "Repository owner's username"), | |
| 196 | + | ("repo" = String, Path, description = "Repository name"), | |
| 197 | + | ), | |
| 198 | + | responses( | |
| 199 | + | (status = 200, description = "Namespaces, with a note count each", body = NamespacesResponse), | |
| 200 | + | (status = 404, description = "No such repository, or not visible to the caller"), | |
| 201 | + | ), | |
| 202 | + | )] | |
| 203 | + | #[tracing::instrument(skip_all, name = "api::git_notes::list_namespaces")] | |
| 204 | + | pub(crate) async fn list_namespaces( | |
| 205 | + | State(db): State<PgPool>, | |
| 206 | + | State(config): State<Config>, | |
| 207 | + | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| 208 | + | Path((owner, repo_name)): Path<(String, String)>, | |
| 209 | + | headers: HeaderMap, | |
| 210 | + | ) -> Result<impl IntoResponse> { | |
| 211 | + | let resolved = read_repo(&db, &config, &owner, &repo_name, &headers, maybe_user).await?; | |
| 212 | + | ||
| 213 | + | let data = resolved | |
| 214 | + | .with_repo(|gix_repo| { | |
| 215 | + | let engine = GixEngine::new(gix_repo); | |
| 216 | + | let namespaces = notes::list_namespaces(&engine).map_err(crate::git::GitError::from)?; | |
| 217 | + | let mut out = Vec::with_capacity(namespaces.len()); | |
| 218 | + | for ns in namespaces { | |
| 219 | + | // Counted from the tree rather than from the index: the index | |
| 220 | + | // may be cold or behind, and a count that disagrees with what | |
| 221 | + | // the next request returns is worse than a slightly dearer one. | |
| 222 | + | let count = | |
| 223 | + | notes::count_notes(&engine, ns.tip).map_err(crate::git::GitError::from)?; | |
| 224 | + | out.push(NamespaceEntry { | |
| 225 | + | name: ns.name, | |
| 226 | + | git_ref: ns.full_ref, | |
| 227 | + | tip: ns.tip.to_hex(), | |
| 228 | + | notes: count as i64, | |
| 229 | + | }); | |
| 230 | + | } | |
| 231 | + | Ok(out) | |
| 232 | + | }) | |
| 233 | + | .await?; | |
| 234 | + | ||
| 235 | + | Ok(Json(NamespacesResponse { data })) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | /// `GET /api/git/{owner}/{repo}/notes/{target}`: one note. | |
| 239 | + | #[utoipa::path( | |
| 240 | + | get, | |
| 241 | + | path = "/api/git/{owner}/{repo}/notes/{target}", | |
| 242 | + | tag = "Git Notes", | |
| 243 | + | params( | |
| 244 | + | ("owner" = String, Path, description = "Repository owner's username"), | |
| 245 | + | ("repo" = String, Path, description = "Repository name"), | |
| 246 | + | ("target" = String, Path, description = "Full object id of the annotated object"), | |
| 247 | + | ("namespace" = Option<String>, Query, description = "Notes namespace, default `commits`"), | |
| 248 | + | ("attribution" = Option<bool>, Query, description = "Include who wrote the note; costs a bounded walk of the notes ref"), | |
| 249 | + | ), | |
| 250 | + | responses( | |
| 251 | + | (status = 200, description = "The note", body = NoteResponse), | |
| 252 | + | (status = 404, description = "No such repository, namespace, or note"), | |
| 253 | + | ), | |
| 254 | + | )] | |
| 255 | + | #[tracing::instrument(skip_all, name = "api::git_notes::get_note")] | |
| 256 | + | pub(crate) async fn get_note( | |
| 257 | + | State(db): State<PgPool>, | |
| 258 | + | State(config): State<Config>, | |
| 259 | + | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| 260 | + | Path((owner, repo_name, target_hex)): Path<(String, String, String)>, | |
| 261 | + | Query(query): Query<GetNoteQuery>, | |
| 262 | + | headers: HeaderMap, | |
| 263 | + | ) -> Result<impl IntoResponse> { | |
| 264 | + | let namespace = namespace_or_default(query.namespace.as_deref()); | |
| 265 | + | let target = parse_target(&target_hex)?; | |
| 266 | + | let resolved = read_repo(&db, &config, &owner, &repo_name, &headers, maybe_user).await?; | |
| 267 | + | ||
| 268 | + | let want_attribution = query.attribution; | |
| 269 | + | let ns_for_repo = namespace.clone(); | |
| 270 | + | let note = resolved | |
| 271 | + | .with_repo(move |gix_repo| { | |
| 272 | + | let engine = GixEngine::new(gix_repo); | |
| 273 | + | // A namespace that does not exist and a namespace with no note on | |
| 274 | + | // this target are the same 404: both mean the repository holds | |
| 275 | + | // nothing here, and distinguishing them would tell an anonymous | |
| 276 | + | // caller which namespaces a repository carries by another route. | |
| 277 | + | let Some(ns) = notes::resolve_namespace(&engine, &ns_for_repo) | |
| 278 | + | .map_err(crate::git::GitError::from)? | |
| 279 | + | else { | |
| 280 | + | return Err(AppError::NotFound); | |
| 281 | + | }; | |
| 282 | + | let Some(note) = | |
| 283 | + | notes::note_for(&engine, ns.tip, target).map_err(crate::git::GitError::from)? | |
| 284 | + | else { | |
| 285 | + | return Err(AppError::NotFound); | |
| 286 | + | }; | |
| 287 | + | ||
| 288 | + | let attribution = if want_attribution { | |
| 289 | + | notes::attribution(&engine, ns.tip, target, ATTRIBUTION_MAX_COMMITS) | |
| 290 | + | .map_err(crate::git::GitError::from)? | |
| 291 | + | .map(|a| NoteAttribution { | |
| 292 | + | commit: a.note_commit.to_hex(), | |
| 293 | + | name: a.by.name, | |
| 294 | + | email: a.by.email, | |
| 295 | + | at: a.by.time, | |
| 296 | + | exact: a.exact, | |
| 297 | + | }) | |
| 298 | + | } else { | |
| 299 | + | None | |
| 300 | + | }; | |
| 301 | + | ||
| 302 | + | Ok(NoteResponse { | |
| 303 | + | namespace: ns.name, | |
| 304 | + | target: note.target.to_hex(), | |
| 305 | + | blob: note.blob.to_hex(), | |
| 306 | + | content: note.content_lossy().into_owned(), | |
| 307 | + | attribution, | |
| 308 | + | }) | |
| 309 | + | }) | |
| 310 | + | .await?; | |
| 311 | + | ||
| 312 | + | Ok(Json(note)) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | /// `PUT /api/git/{owner}/{repo}/notes/{target}`: add or replace a note. | |
| 316 | + | #[utoipa::path( | |
| 317 | + | put, | |
| 318 | + | path = "/api/git/{owner}/{repo}/notes/{target}", | |
| 319 | + | tag = "Git Notes", | |
| 320 | + | params( | |
| 321 | + | ("owner" = String, Path, description = "Repository owner's username"), | |
| 322 | + | ("repo" = String, Path, description = "Repository name"), | |
| 323 | + | ("target" = String, Path, description = "Full object id of the commit to annotate"), | |
| 324 | + | ), | |
| 325 | + | request_body = PutNoteRequest, | |
| 326 | + | responses( | |
| 327 | + | (status = 200, description = "What the write did", body = WriteResponse), | |
| 328 | + | (status = 401, description = "No credential; writes need a push-scoped personal access token"), | |
| 329 | + | (status = 403, description = "A session cookie, a read-only token, or an account that cannot push here"), | |
| 330 | + | (status = 404, description = "No such repository, or no such commit in it"), | |
| 331 | + | (status = 422, description = "Reserved namespace, empty or oversized content, or sustained write contention"), | |
| 332 | + | ), | |
| 333 | + | )] | |
| 334 | + | #[tracing::instrument(skip_all, name = "api::git_notes::put_note")] | |
| 335 | + | pub(crate) async fn put_note( | |
| 336 | + | State(db): State<PgPool>, | |
| 337 | + | State(config): State<Config>, | |
| 338 | + | Path((owner, repo_name, target_hex)): Path<(String, String, String)>, | |
| 339 | + | headers: HeaderMap, | |
| 340 | + | Json(request): Json<PutNoteRequest>, | |
| 341 | + | ) -> Result<impl IntoResponse> { | |
| 342 | + | let namespace = namespace_or_default(request.namespace.as_deref()); | |
| 343 | + | validation::validate_note_namespace(&namespace)?; | |
| 344 | + | validation::validate_note_content(&request.content)?; | |
| 345 | + | ||
| 346 | + | let mut content = request.content.trim_end().to_string(); | |
| 347 | + | content.push('\n'); | |
| 348 | + | ||
| 349 | + | write( | |
| 350 | + | &db, | |
| 351 | + | &config, | |
| 352 | + | &owner, | |
| 353 | + | &repo_name, | |
| 354 | + | &target_hex, | |
| 355 | + | &headers, | |
| 356 | + | namespace, | |
| 357 | + | Some(content), | |
| 358 | + | ) | |
| 359 | + | .await | |
| 360 | + | .map(Json) | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | /// `DELETE /api/git/{owner}/{repo}/notes/{target}`: remove a note. | |
| 364 | + | #[utoipa::path( | |
| 365 | + | delete, | |
| 366 | + | path = "/api/git/{owner}/{repo}/notes/{target}", | |
| 367 | + | tag = "Git Notes", | |
| 368 | + | params( | |
| 369 | + | ("owner" = String, Path, description = "Repository owner's username"), | |
| 370 | + | ("repo" = String, Path, description = "Repository name"), | |
| 371 | + | ("target" = String, Path, description = "Full object id of the annotated commit"), | |
| 372 | + | ("namespace" = Option<String>, Query, description = "Notes namespace, default `commits`"), | |
| 373 | + | ), | |
| 374 | + | responses( | |
| 375 | + | (status = 204, description = "The note is gone, whether or not it was there"), | |
| 376 | + | (status = 401, description = "No credential; writes need a push-scoped personal access token"), | |
| 377 | + | (status = 403, description = "A session cookie, a read-only token, or an account that cannot push here"), | |
| 378 | + | (status = 404, description = "No such repository, or no such commit in it"), | |
| 379 | + | ), | |
| 380 | + | )] | |
| 381 | + | #[tracing::instrument(skip_all, name = "api::git_notes::delete_note")] | |
| 382 | + | pub(crate) async fn delete_note( | |
| 383 | + | State(db): State<PgPool>, | |
| 384 | + | State(config): State<Config>, | |
| 385 | + | Path((owner, repo_name, target_hex)): Path<(String, String, String)>, | |
| 386 | + | Query(query): Query<NamespaceQuery>, | |
| 387 | + | headers: HeaderMap, | |
| 388 | + | ) -> Result<impl IntoResponse> { | |
| 389 | + | let namespace = namespace_or_default(query.namespace.as_deref()); | |
| 390 | + | validation::validate_note_namespace(&namespace)?; | |
| 391 | + | ||
| 392 | + | write( | |
| 393 | + | &db, | |
| 394 | + | &config, | |
| 395 | + | &owner, | |
| 396 | + | &repo_name, | |
| 397 | + | &target_hex, | |
| 398 | + | &headers, | |
| 399 | + | namespace, | |
| 400 | + | None, | |
| 401 | + | ) | |
| 402 | + | .await?; | |
| 403 | + | ||
| 404 | + | // Deleting a note that was not there is a no-op rather than a 404: the | |
| 405 | + | // caller asked for a state, and the state holds. | |
| 406 | + | Ok(StatusCode::NO_CONTENT) | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | /// `GET /api/git/{owner}/{repo}/notes/search`: full-text search over the index. | |
| 410 | + | /// | |
| 411 | + | /// `search` is a static segment and an object id is 40 or 64 hex characters, so | |
| 412 | + | /// it can never be shadowed by, or shadow, a real target on `/notes/{target}`. | |
| 413 | + | #[utoipa::path( | |
| 414 | + | get, | |
| 415 | + | path = "/api/git/{owner}/{repo}/notes/search", | |
| 416 | + | tag = "Git Notes", | |
| 417 | + | params( | |
| 418 | + | ("owner" = String, Path, description = "Repository owner's username"), | |
| 419 | + | ("repo" = String, Path, description = "Repository name"), | |
| 420 | + | ("q" = String, Query, description = "Query: bare words, quoted phrases, `or`, `-excluded`"), | |
| 421 | + | ("namespace" = Option<String>, Query, description = "Restrict to one namespace"), | |
| 422 | + | ("commits_only" = Option<bool>, Query, description = "Drop notes on blobs and trees"), | |
| 423 | + | ("limit" = Option<i64>, Query, description = "Maximum hits, default 50, capped at 200"), | |
| 424 | + | ), | |
| 425 | + | responses( | |
| 426 | + | (status = 200, description = "Matching notes, and whether the index has seen this repository", body = SearchResponse), | |
| 427 | + | (status = 404, description = "No such repository, or not visible to the caller"), | |
| 428 | + | ), | |
| 429 | + | )] | |
| 430 | + | #[tracing::instrument(skip_all, name = "api::git_notes::search_notes")] | |
| 431 | + | pub(crate) async fn search_notes( | |
| 432 | + | State(db): State<PgPool>, | |
| 433 | + | State(config): State<Config>, | |
| 434 | + | MaybeUserVerified(maybe_user): MaybeUserVerified, | |
| 435 | + | Path((owner, repo_name)): Path<(String, String)>, | |
| 436 | + | Query(query): Query<SearchQuery>, | |
| 437 | + | headers: HeaderMap, | |
| 438 | + | ) -> Result<impl IntoResponse> { | |
| 439 | + | let resolved = read_repo(&db, &config, &owner, &repo_name, &headers, maybe_user).await?; | |
| 440 | + | let repo_id: GitRepoId = resolved.db_repo.id; | |
| 441 | + | ||
| 442 | + | let indexed = db::git_notes::is_indexed(&db, repo_id).await?; | |
| 443 | + | let limit = query | |
| 444 | + | .limit | |
| 445 | + | .unwrap_or(SEARCH_LIMIT_DEFAULT) | |
| 446 | + | .clamp(1, SEARCH_LIMIT_MAX); | |
| 447 | + | let term = query.q.trim(); | |
| 448 | + | ||
| 449 | + | // An empty query matches everything in `websearch_to_tsquery`, which is not | |
| 450 | + | // what an empty search box means. Answer it as no hits rather than as the | |
| 451 | + | // whole index. | |
| 452 | + | let rows = if term.is_empty() { | |
| 453 | + | Vec::new() | |
| 454 | + | } else { | |
| 455 | + | db::git_notes::search( | |
| 456 | + | &db, | |
| 457 | + | repo_id, | |
| 458 | + | term, | |
| 459 | + | query.namespace.as_deref(), | |
| 460 | + | query.commits_only, | |
| 461 | + | limit, | |
| 462 | + | ) | |
| 463 | + | .await? | |
| 464 | + | }; | |
| 465 | + | ||
| 466 | + | let data = rows | |
| 467 | + | .into_iter() | |
| 468 | + | .map(|n| SearchHit { | |
| 469 | + | namespace: n.namespace, | |
| 470 | + | target: n.target_oid, | |
| 471 | + | blob: n.blob_oid, | |
| 472 | + | content: n.content, | |
| 473 | + | target_is_commit: n.target_is_commit, | |
| 474 | + | summary: n.target_summary, | |
| 475 | + | time: n.target_time, | |
| 476 | + | updated_at: n.updated_at, | |
| 477 | + | updated_by: n.updated_by, | |
| 478 | + | }) | |
| 479 | + | .collect(); | |
| 480 | + | ||
| 481 | + | Ok(Json(SearchResponse { data, indexed })) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | // ── Shared halves ── | |
| 485 | + | ||
| 486 | + | /// Resolve a repository for a read, honouring either credential. | |
| 487 | + | /// | |
| 488 | + | /// Visibility is decided by `resolve_repo` on every request rather than | |
| 489 | + | /// remembered anywhere: a note inherits the visibility of the repository holding | |
| 490 | + | /// it, and that can change between two calls. | |
| 491 | + | async fn read_repo( | |
| 492 | + | db: &PgPool, | |
| 493 | + | config: &Config, | |
| 494 | + | owner: &str, | |
| 495 | + | repo_name: &str, | |
| 496 | + | headers: &HeaderMap, | |
| 497 | + | maybe_user: Option<crate::auth::SessionUser>, | |
| 498 | + | ) -> Result<ResolvedRepo> { | |
| 499 | + | let principal = | |
| 500 | + | crate::routes::git::resolve_git_http_principal(db, headers, maybe_user.map(|u| u.id)).await; |
Lines truncated