Author:
Hash:
Timestamp:
+326 -73 +/-9 browse
Kevin Schoon [me@kevinschoon.com]
792cc943f763302db3e45b66b1d2634e856363a1
Tue, 22 Apr 2025 11:31:54 +0000 (1 month ago)
1 | diff --git a/examples/server.rs b/examples/server.rs |
2 | index 1299471..3696881 100644 |
3 | --- a/examples/server.rs |
4 | +++ b/examples/server.rs |
5 | @@ -51,7 +51,10 @@ async fn main() -> Result<(), Box<dyn Error>> { |
6 | }), |
7 | ) |
8 | .layer(NormalizePathLayer::trim_trailing_slash()) |
9 | - .fallback_service(middleware.layer(web_interface)); |
10 | + .fallback_service( |
11 | + tower::util::MapRequestLayer::new(papyri::axum::extract_namespace_optional) |
12 | + .layer(web_interface), |
13 | + ); |
14 | |
15 | axum::serve(listener, router).await?; |
16 | Ok(()) |
17 | diff --git a/src/axum/mod.rs b/src/axum/mod.rs |
18 | index f7f09d1..687136b 100644 |
19 | --- a/src/axum/mod.rs |
20 | +++ b/src/axum/mod.rs |
21 | @@ -33,7 +33,7 @@ pub fn read_ns(uri: &str) -> Option<(Namespace, String)> { |
22 | let mut components: Vec<String> = uri.split("/").map(|cmp| cmp.to_string()).collect(); |
23 | components.reverse(); |
24 | let stop = components.iter().enumerate().find_map(|(i, entry)| { |
25 | - if *entry == "blobs" || *entry == "manifests" || *entry == "tags" { |
26 | + if *entry == "blobs" || *entry == "manifests" || *entry == "tags" || *entry == "tag" || *entry == "index" { |
27 | Some(i + 1) |
28 | } else { |
29 | None |
30 | @@ -67,6 +67,19 @@ pub fn extract_namespace(mut req: Request<axum::body::Body>) -> Request<axum::bo |
31 | req |
32 | } |
33 | |
34 | + pub fn extract_namespace_optional(mut req: Request<axum::body::Body>) -> Request<axum::body::Body> { |
35 | + let uri_str = req.uri().to_string(); |
36 | + let extensions = req.extensions_mut(); |
37 | + if let Some((namespace, route)) = read_ns(&uri_str) { |
38 | + extensions.insert(Some(namespace)); |
39 | + let uri = req.uri_mut(); |
40 | + *uri = Uri::from_str(&route).unwrap(); |
41 | + } else { |
42 | + extensions.insert(None::<Namespace>); |
43 | + } |
44 | + req |
45 | + } |
46 | + |
47 | // async fn propagate_header<B>(req: Request<B>, next: Next<B>) -> Response { |
48 | async fn global_headers(req: Request, next: Next) -> Response { |
49 | let mut res = next.run(req).await; |
50 | diff --git a/src/axum/web/link_tree.rs b/src/axum/web/link_tree.rs |
51 | index d6f32c4..4a7089a 100644 |
52 | --- a/src/axum/web/link_tree.rs |
53 | +++ b/src/axum/web/link_tree.rs |
54 | @@ -1,15 +1,27 @@ |
55 | use crate::tree::Node; |
56 | |
57 | - pub fn generate<F>(root: &Node, link_builder: &F) -> String |
58 | + pub fn generate<F>(root: &Node, link_builder: &F, selected: Option<&String>) -> String |
59 | where |
60 | - F: Fn(&str) -> String |
61 | + F: Fn(&str) -> String, |
62 | { |
63 | let mut start = format!("<ul id='{}'>", root.name); |
64 | if root.name != "/" { |
65 | - start.push_str(&format!("<a href='{}'>{}</a>", link_builder(&root.name), root.name)); |
66 | + let mut class = String::default(); |
67 | + if selected.is_some_and(|selected| root.name == *selected) { |
68 | + class.push_str("active"); |
69 | + } |
70 | + start.push_str(&format!( |
71 | + "<a class='{}' href='{}'>{}</a>", |
72 | + class, |
73 | + link_builder(&root.name), |
74 | + root.name |
75 | + )); |
76 | } |
77 | for child in root.children.iter() { |
78 | - start.push_str(&format!("<li>{}</li>", generate(child, link_builder))); |
79 | + start.push_str(&format!( |
80 | + "<li>{}</li>", |
81 | + generate(child, link_builder, selected) |
82 | + )); |
83 | } |
84 | start.push_str("</ul>"); |
85 | start |
86 | diff --git a/src/axum/web/router.rs b/src/axum/web/router.rs |
87 | index 02b2c26..c9aae33 100644 |
88 | --- a/src/axum/web/router.rs |
89 | +++ b/src/axum/web/router.rs |
90 | @@ -2,7 +2,7 @@ use std::sync::Arc; |
91 | |
92 | use axum::{ |
93 | Extension, Router, |
94 | - extract::State, |
95 | + extract::{Path, State}, |
96 | response::{Html, Response}, |
97 | routing::get, |
98 | }; |
99 | @@ -11,7 +11,10 @@ use http::header::CONTENT_TYPE; |
100 | |
101 | use crate::{Namespace, axum::AppState, oci_interface::OciInterface, storage::Storage}; |
102 | |
103 | - use super::{link_tree, template::{Repository, RepositoryIndex, LOGO, STYLESHEET}}; |
104 | + use super::{ |
105 | + link_tree, |
106 | + template::{self, LOGO, Repository, RepositoryIndex, STYLESHEET}, |
107 | + }; |
108 | |
109 | pub async fn logo() -> Response { |
110 | let mut res = Response::new(Bytes::from_static(LOGO).into()); |
111 | @@ -28,30 +31,74 @@ pub async fn stylesheet() -> Response { |
112 | } |
113 | |
114 | pub async fn index( |
115 | - // Extension(namespace): Extension<Option<Namespace>>, |
116 | + Extension(namespace): Extension<Option<Namespace>>, |
117 | + current_tag: Option<Path<String>>, |
118 | State(state): State<Arc<AppState>>, |
119 | ) -> Result<Html<String>, crate::axum::error::Error> { |
120 | - let namespaces = state.oci.list_namespaces().await?; |
121 | + let namespaces = state.oci.list_namespaces(None).await?; |
122 | let ns_str: Vec<String> = namespaces.iter().map(|ns| ns.to_string()).collect(); // FIXME |
123 | let root = crate::tree::Builder::default().build(&ns_str); |
124 | - let link_builder = |name: &str| { |
125 | - name.to_string() |
126 | - }; |
127 | - let tree = link_tree::generate(&root, &link_builder); |
128 | - let template = RepositoryIndex { |
129 | + let link_builder = |name: &str| format!("/{}/index", name); |
130 | + let tree = link_tree::generate( |
131 | + &root, |
132 | + &link_builder, |
133 | + namespace.as_ref().map(|ns| ns.to_string()).as_ref(), |
134 | + ); |
135 | + let mut template = RepositoryIndex { |
136 | title: "Repositories", |
137 | - tree: &tree |
138 | + tree: &tree, |
139 | + namespace: None, |
140 | + manifest: None, |
141 | + tags: Vec::new(), |
142 | }; |
143 | |
144 | - Ok(Html::from(template.to_string())) |
145 | - } |
146 | - |
147 | - pub async fn repository( |
148 | - Extension(namespace): Extension<Namespace>, |
149 | - ) -> Result<Html<String>, crate::axum::error::Error> { |
150 | - let template = Repository { |
151 | - title: "REPOSITORY A", |
152 | + if let Some(ns) = namespace { |
153 | + let tags = state |
154 | + .oci |
155 | + .list_tags(&ns, None, None) |
156 | + .await |
157 | + .map_or(Vec::new(), |tags| { |
158 | + tags.tags() |
159 | + .iter() |
160 | + .map(|tag| template::Tag { |
161 | + name: tag.clone(), |
162 | + namespace: ns.to_string(), |
163 | + selected: current_tag |
164 | + .as_ref() |
165 | + .is_some_and(|current_tag| current_tag.as_str() == tag.as_str()), |
166 | + }) |
167 | + .collect() |
168 | + }); |
169 | + template.tags = tags; |
170 | + if let Some(current_tag) = current_tag { |
171 | + let manifest = state |
172 | + .oci |
173 | + .read_manifest(&ns, &crate::TagOrDigest::Tag(current_tag.to_string())) |
174 | + .await?; |
175 | + template.manifest = Some(template::Manifest { |
176 | + pretty: manifest.to_string_pretty().unwrap(), |
177 | + created_at: manifest.annotations().as_ref().and_then(|annotations| { |
178 | + annotations |
179 | + .get(oci_spec::image::ANNOTATION_CREATED) |
180 | + .map(|value| value.to_string()) |
181 | + }), |
182 | + source: None, |
183 | + upstream_url: None, |
184 | + layers: manifest |
185 | + .layers() |
186 | + .iter() |
187 | + .map(|layer| template::Layer { |
188 | + digest: layer.digest().to_string(), |
189 | + size: layer.size(), |
190 | + }) |
191 | + .collect(), |
192 | + }); |
193 | + } |
194 | + template.namespace = Some(template::Namespace { |
195 | + name: ns.to_string(), |
196 | + }); |
197 | }; |
198 | + |
199 | Ok(Html::from(template.to_string())) |
200 | } |
201 | |
202 | @@ -59,35 +106,10 @@ pub fn router(storage: &Storage) -> Router { |
203 | let store = Arc::new(storage.inner()); |
204 | Router::new() |
205 | .route("/", get(index)) |
206 | + .route("/index", get(index)) |
207 | + .route("/tag/{name}", get(index)) |
208 | .route("/style.css", get(stylesheet)) |
209 | .route("/logo.png", get(logo)) |
210 | - // // .route("/{name}/blobs/{digest}", head(crate::handlers::stat_blob)) |
211 | - // // .route( |
212 | - // // "/{name}/manifests/{reference}", |
213 | - // // get(crate::handlers::read_manifest), |
214 | - // // ) |
215 | - // // .route( |
216 | - // // "/{name}/manifests/{reference}", |
217 | - // // head(crate::handlers::read_manifest), |
218 | - // // ) |
219 | - // // .route("/{name}/blobs/uploads", post(crate::handlers::initiate_blob)) |
220 | - // // .route( |
221 | - // // "/{name}/blobs/uploads/{reference}", |
222 | - // // patch(crate::handlers::write_blob), |
223 | - // // ) |
224 | - // // .route( |
225 | - // // "/{name}/manifests/{reference}", |
226 | - // // put(crate::handlers::write_manifest), |
227 | - // // ) |
228 | - // // .route("/{name}/tags/list", get(crate::handlers::read_tags)) |
229 | - // // .route( |
230 | - // // "/{name}/manifests/{reference}", |
231 | - // // delete(crate::handlers::delete_manifest), |
232 | - // // ) |
233 | - // // .route( |
234 | - // // "/{name}/blobs/{digest}", |
235 | - // // delete(crate::handlers::delete_blob), |
236 | - // // ) |
237 | .with_state(Arc::new(AppState { |
238 | oci: OciInterface { storage: store }, |
239 | })) |
240 | diff --git a/src/axum/web/template.rs b/src/axum/web/template.rs |
241 | index 4d02675..f6f4019 100644 |
242 | --- a/src/axum/web/template.rs |
243 | +++ b/src/axum/web/template.rs |
244 | @@ -1,13 +1,70 @@ |
245 | + use std::fmt::Display; |
246 | + |
247 | use askama::Template; |
248 | + use serde::Serialize; |
249 | |
250 | pub const STYLESHEET: &[u8] = include_bytes!("../../../templates/style.css"); |
251 | pub const LOGO: &[u8] = include_bytes!("../../../assets/logo.png"); |
252 | |
253 | + #[derive(Debug, Serialize)] |
254 | + pub struct Tag { |
255 | + pub name: String, |
256 | + pub namespace: String, |
257 | + pub selected: bool, |
258 | + } |
259 | + |
260 | + impl Display for Tag { |
261 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
262 | + write!(f, "{}", self.name) |
263 | + } |
264 | + } |
265 | + |
266 | + #[derive(Debug, Serialize)] |
267 | + pub struct Namespace { |
268 | + pub name: String, |
269 | + } |
270 | + |
271 | + impl Display for Namespace { |
272 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
273 | + write!(f, "{}", self.name) |
274 | + } |
275 | + } |
276 | + |
277 | + #[derive(Debug, Serialize)] |
278 | + pub struct Layer { |
279 | + pub digest: String, |
280 | + pub size: u64, |
281 | + } |
282 | + |
283 | + impl Display for Layer { |
284 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
285 | + write!(f, "{}", self.digest) |
286 | + } |
287 | + } |
288 | + |
289 | + #[derive(Debug, Serialize)] |
290 | + pub struct Manifest { |
291 | + pub pretty: String, |
292 | + pub layers: Vec<Layer>, |
293 | + pub created_at: Option<String>, |
294 | + pub source: Option<String>, |
295 | + pub upstream_url: Option<String>, |
296 | + } |
297 | + |
298 | + impl Display for Manifest { |
299 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
300 | + todo!() |
301 | + } |
302 | + } |
303 | + |
304 | #[derive(Template)] |
305 | #[template(path = "index.html")] |
306 | pub struct RepositoryIndex<'a> { |
307 | pub title: &'a str, |
308 | pub tree: &'a str, |
309 | + pub namespace: Option<Namespace>, |
310 | + pub tags: Vec<Tag>, |
311 | + pub manifest: Option<Manifest>, |
312 | } |
313 | |
314 | #[derive(Template)] |
315 | diff --git a/src/oci_interface.rs b/src/oci_interface.rs |
316 | index 09df8e2..4c1538f 100644 |
317 | --- a/src/oci_interface.rs |
318 | +++ b/src/oci_interface.rs |
319 | @@ -357,18 +357,26 @@ impl OciInterface { |
320 | } |
321 | } |
322 | |
323 | - pub async fn list_namespaces(&self) -> Result<Vec<Namespace>, Error> { |
324 | + pub async fn list_namespaces(&self, ns: Option<&Namespace>) -> Result<Vec<Namespace>, Error> { |
325 | + let addr = if let Some(namespace) = ns { |
326 | + &Address::Repository { |
327 | + namespace: namespace.clone(), |
328 | + } |
329 | + } else { |
330 | + &Address::RepositoriesRoot |
331 | + }; |
332 | let results = self |
333 | .storage |
334 | - .find(&Address::RepositoriesRoot, Some(Kind::Repository)) |
335 | + .find(addr, Some(Kind::Repository)) |
336 | .await |
337 | .map_err(Error::Storage)?; |
338 | - let namespaces: Vec<Namespace> = results.iter().filter_map(|addr| { |
339 | - match addr { |
340 | + let namespaces: Vec<Namespace> = results |
341 | + .iter() |
342 | + .filter_map(|addr| match addr { |
343 | Address::Repository { namespace } => Some(namespace.clone()), |
344 | - _ => None |
345 | - } |
346 | - }).collect(); |
347 | + _ => None, |
348 | + }) |
349 | + .collect(); |
350 | Ok(namespaces) |
351 | } |
352 | } |
353 | diff --git a/templates/base.html b/templates/base.html |
354 | index ed2bdc2..9cb9954 100644 |
355 | --- a/templates/base.html |
356 | +++ b/templates/base.html |
357 | @@ -7,16 +7,15 @@ |
358 | </head> |
359 | <body> |
360 | <header> |
361 | - <!-- |
362 | - <ul class="nav-links"> |
363 | - <li><a href="#home">Home</a></li> |
364 | - </ul> |
365 | - --> |
366 | + <nav class="navbar"> |
367 | + <div class="logo-container"> |
368 | + <a href="/"><img src="/logo.png" alt="Logo" class="logo"></a> |
369 | + </div> |
370 | + <ul class="nav-list"></ul> |
371 | + </nav> |
372 | </header> |
373 | <main> |
374 | - <div class="content"> |
375 | {% block content %}{% endblock %} |
376 | - </div> |
377 | </main> |
378 | <footer class="sticky"> |
379 | <p>Papyri</p> |
380 | diff --git a/templates/index.html b/templates/index.html |
381 | index 10f4e59..7654879 100644 |
382 | --- a/templates/index.html |
383 | +++ b/templates/index.html |
384 | @@ -1,9 +1,45 @@ |
385 | {% extends "base.html" %} |
386 | {% block content %} |
387 | - <div class="tree-display"> |
388 | + {% if let Some(manifest) = manifest %} |
389 | + <section class="pane details"> |
390 | + <span id="total-size"></span> |
391 | + |
392 | + <p>Created At:</p> |
393 | + <span class="badge">hello</span> |
394 | + |
395 | + <p>Revision:</p> |
396 | + <span id="revision"></span> |
397 | + |
398 | + <p>Url:</p> |
399 | + <span id="url"></span> |
400 | + </section> |
401 | + {% endif %} |
402 | + <section class="flex"> |
403 | + <section class="pane tree"> |
404 | <ul class="tree"> |
405 | {{ tree | safe }} |
406 | </ul> |
407 | - </div> |
408 | - <a href="/"><img class="logo" src="/logo.png" alt="Logo"></a> |
409 | + </section> |
410 | + <section class="pane tags"> |
411 | + <ul> |
412 | + {% for tag in tags %} |
413 | + <li> <code class="tag"><a href="/{{tag.namespace}}/tag/{{tag}}">{{ tag }}</a></code> </li> |
414 | + {% endfor %} |
415 | + </ul> |
416 | + </section> |
417 | + <section class="pane manifest"> |
418 | + {% if let Some(manifest) = manifest %} |
419 | + <h2>Manifest</h2> |
420 | + <section class="pane"> |
421 | + <pre>{{ manifest.pretty | safe }}</pre> |
422 | + </section> |
423 | + <h2>Layers</h2> |
424 | + {% for layer in manifest.layers %} |
425 | + <section class="pane"> |
426 | + {{ layer }} |
427 | + </section> |
428 | + {% endfor %} |
429 | + {% endif %} |
430 | + </section> |
431 | + </section> |
432 | {% endblock %} |
433 | diff --git a/templates/style.css b/templates/style.css |
434 | index 8ad41b1..ff23674 100644 |
435 | --- a/templates/style.css |
436 | +++ b/templates/style.css |
437 | @@ -4,6 +4,36 @@ |
438 | box-sizing: border-box; |
439 | } |
440 | |
441 | + a { |
442 | + text-decoration: none; |
443 | + } |
444 | + |
445 | + a.active { |
446 | + text-decoration: underline; |
447 | + } |
448 | + |
449 | + ul { |
450 | + list-style-type: none; |
451 | + } |
452 | + |
453 | + code.tag { |
454 | + font-size: large; |
455 | + font-weight: bold |
456 | + } |
457 | + |
458 | + section.pane.manifest >h2 { |
459 | + float:right; |
460 | + } |
461 | + |
462 | + .badge { |
463 | + background-color: #f7dc6f; /* yellow */ |
464 | + color: #333; |
465 | + padding: 10px 20px; |
466 | + border-radius: 50%; |
467 | + font-size: 16px; |
468 | + font-weight: bold; |
469 | + } |
470 | + |
471 | .logo { |
472 | border-radius: 50%; |
473 | background-color: #333; |
474 | @@ -12,13 +42,42 @@ |
475 | 0 2px 4px rgba(0, 0, 0, 0.25); |
476 | } |
477 | |
478 | - .content { |
479 | + section.flex { |
480 | display: flex; |
481 | - width: 80%; |
482 | padding: 2em; |
483 | margin: 3em auto; |
484 | } |
485 | |
486 | + section.pane { |
487 | + margin-right: 10px; |
488 | + padding: 20px; |
489 | + border: 1px solid #ccc; |
490 | + border-radius: 5px; |
491 | + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
492 | + width: 100%; |
493 | + overflow: scroll; |
494 | + } |
495 | + |
496 | + section.details { |
497 | + background-color: #f0f0f0; |
498 | + border-radius: 10px; |
499 | + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); |
500 | + font-size: 18px; |
501 | + color: #333; |
502 | + } |
503 | + |
504 | + section.tree { |
505 | + width: 20%; |
506 | + } |
507 | + |
508 | + section.tags { |
509 | + width: 10%; |
510 | + } |
511 | + |
512 | + section.manifest { |
513 | + width: 70%; |
514 | + } |
515 | + |
516 | footer { |
517 | position: absolute; |
518 | bottom: 0; |
519 | @@ -42,13 +101,57 @@ footer { |
520 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); |
521 | } |
522 | |
523 | + .navbar { |
524 | + display: flex; |
525 | + justify-content: space-between; |
526 | + align-items: center; |
527 | + padding: 0.5rem; |
528 | + border-bottom: 1px solid #ccc; |
529 | + } |
530 | + |
531 | + .logo-container { |
532 | + display: flex; |
533 | + align-items: center; |
534 | + margin-right: 1rem; |
535 | + } |
536 | + |
537 | + .logo { |
538 | + height: 10em; |
539 | + width: auto; |
540 | + margin-right: 0.5rem; |
541 | + } |
542 | + |
543 | + .nav-list { |
544 | + list-style: none; |
545 | + margin: 0; |
546 | + padding: 0; |
547 | + display: flex; |
548 | + align-items: center; |
549 | + } |
550 | + |
551 | + .nav-list li { |
552 | + margin-right: 1rem; |
553 | + } |
554 | + |
555 | + .nav-list a { |
556 | + color: #333; |
557 | + text-decoration: none; |
558 | + transition: color 0.2s ease-in-out; |
559 | + } |
560 | + |
561 | + .nav-list a:hover { |
562 | + color: #555; |
563 | + } |
564 | + |
565 | + |
566 | + |
567 | /* |
568 | Tree structure using CSS: |
569 | http://stackoverflow.com/questions/14922247/how-to-get-a-tree-in-html-using-pure-css |
570 | */ |
571 | |
572 | .tree, .tree ul { |
573 | - font: 32px Helvetica, Arial, sans-serif; |
574 | + font: 16px monospace; |
575 | list-style-type: none; |
576 | margin-left: 0 0 0 10px; |
577 | padding: 0; |
578 | @@ -70,7 +173,7 @@ footer { |
579 | |
580 | /* horizontal line on inner list items */ |
581 | .tree li::before{ |
582 | - border-top: 3px solid #999; |
583 | + border-top: 1px solid #999; |
584 | top: 10px; |
585 | width: 10px; |
586 | height: 0; |
587 | @@ -78,7 +181,7 @@ footer { |
588 | |
589 | /* vertical line on list items */ |
590 | .tree li:after{ |
591 | - border-left: 3px solid #999; |
592 | + border-left: 1px solid #999; |
593 | height: 100%; |
594 | width: 0px; |
595 | top: -10px; |