Embed a drop (closes #280) 8ac2aa4ca6d1
/{owner}/drops/{id}/embed shows a drop's files and a link back, without
the site's chrome, with its own policy: any site may frame it, and it
runs no script and posts no form. The drop's page offers the iframe to
copy. With backups, forks, stars and comments, #280 is done.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key d78a5b218b33
- When
- · 2026-10-21 03:53 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
8ac2aa4ca6d171b3734238905f62b979d7be5530c6063b64d4c641d636c4fae7- Depends on
- 166f6e9bf4f1 8bf3010760d7 93be9ce4653e a7e6e8e6094c adbc38e7ea09 b5668108487f c419455a82dd ca0c443c30cf d23464c0ac60
- Depended on by
- 04f9c984439e 84e3c09656b2 8ad8e656a3a6
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 11 atoms, 1 tree operation, 8 files
Browse the tree at this change
added templates/drop_embed.html +28 −0
| 1 | + | <!doctype html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="utf-8"> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> |
|
| 6 | + | <title>{{ title }} · {{ owner }}'s drop</title> |
|
| 7 | + | <link rel="stylesheet" href="/static/style.css?v={{ assets }}"> |
|
| 8 | + | </head> |
|
| 9 | + | <body class="drop-embedded"> |
|
| 10 | + | <header class="embed-head"><a href="{{ url }}" target="_blank" rel="noopener"><strong>{{ title }}</strong></a> <span class="small mute">a drop by {{ owner }} on <a href="{{ site_url }}" target="_blank" rel="noopener">{{ site }}</a></span></header> |
|
| 11 | + | {% for f in files %} |
|
| 12 | + | <section class="filebox drop-filebox"> |
|
| 13 | + | <header><strong>{{ f.path }}</strong> <span class="small mute">{{ f.size|bytes }}{% if let Some(l) = f.language %} · {{ l }}{% endif %}</span></header> |
|
| 14 | + | {% if let Some(html) = f.markdown %} |
|
| 15 | + | <article class="doc">{{ html|safe }}</article> |
|
| 16 | + | {% else if let Some(note) = f.note %} |
|
| 17 | + | <p class="empty">{{ note }} <a href="{{ url }}" target="_blank" rel="noopener">Open the drop</a>.</p> |
|
| 18 | + | {% else %} |
|
| 19 | + | <table class="code"> |
|
| 20 | + | {% for line in f.lines %} |
|
| 21 | + | <tr><td class="n">{{ loop.index }}</td><td class="t"><pre>{{ line|safe }}</pre></td></tr> |
|
| 22 | + | {% endfor %} |
|
| 23 | + | </table> |
|
| 24 | + | {% endif %} |
|
| 25 | + | </section> |
|
| 26 | + | {% endfor %} |
|
| 27 | + | </body> |
|
| 28 | + | </html> |
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Embed a drop (#280):** a drop's page offers an `<iframe>` snippet; `/{owner}/drops/{id}/embed` shows its files with a link back, framed by any site, with no scripts and no forms. |
|
| 7 | 8 | - **Comments on drops (#280):** a drop's page has comments: signed-in readers write them in Markdown, the drop's owner is told, and the author, the owner or a site administrator deletes one. |
|
| 8 | 9 | - **Star a drop (#280):** a signed-in visitor stars a drop, or takes the star back, from its page, which shows how many it has. |
|
| 9 | 10 | ## 0.7.0 (2026-10-21) |
|
| … | 283 unchanged lines |
modified crates/hub/src/web/drops.rs +57 −0
| … | 62 unchanged lines |
||
| 63 | 63 | can_delete: bool, |
|
| 64 | 64 | /// Signed in and not its owner: may fork it (#280). |
|
| 65 | 65 | can_fork: bool, |
|
| 66 | + | /// The iframe that puts it on another site (#280). |
|
| 67 | + | embed: String, |
|
| 66 | 68 | /// Its comments, rendered, and whether the viewer may delete each (#280). |
|
| 67 | 69 | comments: Vec<ShownComment>, |
|
| 68 | 70 | /// Its stars, and whether the viewer gave one (#280). |
|
| … | 10 unchanged lines |
||
| 79 | 81 | updated_ms: i64, |
|
| 80 | 82 | } |
|
| 81 | 83 | ||
| 84 | + | /// A drop as another site frames it (#280): its files, a link back, no |
|
| 85 | + | /// chrome, no scripts. |
|
| 86 | + | #[derive(Template)] |
|
| 87 | + | #[template(path = "drop_embed.html")] |
|
| 88 | + | pub struct EmbedPage { |
|
| 89 | + | title: String, |
|
| 90 | + | owner: String, |
|
| 91 | + | /// The drop's address, whole, to open it on the hub. |
|
| 92 | + | url: String, |
|
| 93 | + | site: String, |
|
| 94 | + | site_url: String, |
|
| 95 | + | assets: &'static str, |
|
| 96 | + | files: Vec<ShownFile>, |
|
| 97 | + | } |
|
| 98 | + | ||
| 99 | + | /// What an embedded drop may do: be framed anywhere, show its styles and |
|
| 100 | + | /// images, and nothing else (no scripts, no forms). |
|
| 101 | + | const EMBED_POLICY: &str = |
|
| 102 | + | "default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; frame-ancestors *; base-uri 'none'; form-action 'none'"; |
|
| 103 | + | ||
| 104 | + | /// `GET /{owner}/drops/{id}/embed` (#280): the drop to put in an iframe on |
|
| 105 | + | /// another site; readable by whoever may read the drop. |
|
| 106 | + | pub async fn embed(State(app): State<Shared>, viewer: Viewer, Path((owner, id)): Path<(String, String)>) -> Result<Response, Error> { |
|
| 107 | + | let shell = Shell::new(&app, &format!("/{owner}/drops/{id}/embed"), &viewer); |
|
| 108 | + | let row = lookup(&app, &shell, &viewer, &owner, &id).await?; |
|
| 109 | + | let (target, files) = drops::files(&app, &row, "").await.map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 110 | + | let url = drops::url_of(&row); |
|
| 111 | + | let title = drops::shown_title(&row, &files); |
|
| 112 | + | let label = target.label.clone(); |
|
| 113 | + | let files = shown(files, &format!("{url}/file/{label}/"), &format!("{url}/raw/{label}/")); |
|
| 114 | + | let base = app.config.base_url().trim_end_matches('/').to_string(); |
|
| 115 | + | let page = EmbedPage { |
|
| 116 | + | title, |
|
| 117 | + | owner: row.owner.clone(), |
|
| 118 | + | url: format!("{base}{url}"), |
|
| 119 | + | site: shell.site.clone(), |
|
| 120 | + | site_url: base, |
|
| 121 | + | assets: shell.assets(), |
|
| 122 | + | files, |
|
| 123 | + | }; |
|
| 124 | + | let html = page.render().map_err(|e| Error::bad(shell.clone(), e.to_string()))?; |
|
| 125 | + | Ok(( |
|
| 126 | + | [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8"), (axum::http::header::CONTENT_SECURITY_POLICY, EMBED_POLICY)], |
|
| 127 | + | html, |
|
| 128 | + | ) |
|
| 129 | + | .into_response()) |
|
| 130 | + | } |
|
| 131 | + | ||
| 82 | 132 | /// A comment as the drop's page shows it. |
|
| 83 | 133 | pub struct ShownComment { |
|
| 84 | 134 | pub id: i64, |
|
| … | 128 unchanged lines |
||
| 213 | 263 | created_ms: c.created_ms, |
|
| 214 | 264 | }) |
|
| 215 | 265 | .collect(); |
|
| 266 | + | let embed = format!( |
|
| 267 | + | "<iframe src=\"{}{}/embed\" width=\"100%\" height=\"400\" style=\"border:0\" title=\"{}\"></iframe>", |
|
| 268 | + | app.config.base_url().trim_end_matches('/'), |
|
| 269 | + | drops::url_of(&row), |
|
| 270 | + | title.replace('"', """) |
|
| 271 | + | ); |
|
| 216 | 272 | let stars = db::repos::counts(&app.db, row.id).await.map(|(s, _)| s).unwrap_or(0); |
|
| 217 | 273 | let starred = match viewer.account() { |
|
| 218 | 274 | Some(a) => db::repos::starred(&app.db, a.id, row.id).await.unwrap_or(false), |
|
| 219 | 275 | None => false, |
|
| 220 | 276 | }; |
|
| 221 | 277 | Ok(Page(DropPage { |
|
| 278 | + | embed, |
|
| 222 | 279 | comments, |
|
| 223 | 280 | stars, |
|
| 224 | 281 | starred, |
|
| … | 406 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 196 unchanged lines |
||
| 197 | 197 | .route("/{owner}/drops/{id}/delete", post(drops::delete)) |
|
| 198 | 198 | .route("/{owner}/drops/{id}/fork", post(drops::fork)) |
|
| 199 | 199 | .route("/{owner}/drops/{id}/star", post(drops::star)) |
|
| 200 | + | .route("/{owner}/drops/{id}/embed", get(drops::embed)) |
|
| 200 | 201 | .route("/{owner}/drops/{id}/comment", post(drops::comment)) |
|
| 201 | 202 | .route("/{owner}/drops/{id}/comment/{n}/delete", post(drops::delete_comment)) |
|
| 202 | 203 | .route("/{owner}/drops/{id}/changes/{r}", get(drops::changes)) |
|
| … | 704 unchanged lines |
modified crates/hub/tests/drops.rs +13 −0
| … | 527 unchanged lines |
||
| 528 | 528 | assert_eq!(delete(carol.clone(), from_andy.clone()).await, StatusCode::SEE_OTHER, "the drop's owner may"); |
|
| 529 | 529 | assert_eq!(delete(carol.clone(), from_carol.clone()).await, StatusCode::SEE_OTHER, "and the author"); |
|
| 530 | 530 | assert!(get(app, &source, None).await.body.contains("No comments yet.")); |
|
| 531 | + | ||
| 532 | + | // Embedding: the snippet on its page; the embed page frames anywhere, |
|
| 533 | + | // runs no script and links back. |
|
| 534 | + | let page = get(app, &source, None).await.body; |
|
| 535 | + | let at = page.find("id=\"embed\"").expect("the embed field"); |
|
| 536 | + | assert!(page[at..].contains("iframe src=") && page[at..at + 400].contains(&format!("{source}/embed")), "{}", &page[at..at + 400]); |
|
| 537 | + | let res = oxygenhub::router(app.clone()).oneshot(Request::get(format!("{source}/embed")).body(Body::empty()).unwrap()).await.unwrap(); |
|
| 538 | + | assert_eq!(res.status(), StatusCode::OK); |
|
| 539 | + | let csp = res.headers()[header::CONTENT_SECURITY_POLICY].to_str().unwrap().to_string(); |
|
| 540 | + | assert!(csp.contains("frame-ancestors *") && csp.contains("default-src 'none'") && !csp.contains("script-src"), "{csp}"); |
|
| 541 | + | assert!(res.headers().get(header::X_FRAME_OPTIONS).is_none(), "framed by other sites"); |
|
| 542 | + | let body = String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned(); |
|
| 543 | + | assert!(body.contains("a.txt") && body.contains(&format!("{}{source}", h.base)) && !body.contains("<script"), "{body}"); |
|
| 531 | 544 | } |
|
| 532 | 545 | ||
| 533 | 546 | #[tokio::test(flavor = "multi_thread")] |
|
| … | 27 unchanged lines |
modified docs/spec.md +1 −0
| … | 180 unchanged lines |
||
| 181 | 181 | - **Forks** (#280): a signed-in visitor who is not the owner may fork a drop they can read (`POST /{owner}/drops/{id}/fork`, the page's FORK): a new drop of theirs whose store is a fork of the source (objects linked, bookmarks copied, as a repository fork), with its title, description and secrecy, `forked_from` naming the source; its page says "forked from" with the source's address while it exists. Audited as `drop.forked`. |
|
| 182 | 182 | - **Stars** (#280): a signed-in visitor stars a drop, or takes the star back, from its page (`POST /{owner}/drops/{id}/star`, the repositories' `stars` table); the page shows the count, which never joins a list of starred repositories. |
|
| 183 | 183 | - **Comments** (#280): signed-in readers comment on a drop in Markdown (`POST /{owner}/drops/{id}/comment`, table `drop_comments`, migration 0092, at most 64 KiB), shown oldest first under its files; the owner is told (`drop.comment`); the author, the owner or a site administrator deletes one (`…/comment/{n}/delete`). |
|
| 184 | + | - **Embedding** (#280): `GET /{owner}/drops/{id}/embed` (whoever may read the drop) is the drop's files and a link back, without the site's chrome, sent with its own policy (`default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; frame-ancestors *; …`, so no `X-Frame-Options`): any site may frame it, and it runs no script and posts no form. The drop's page offers the `<iframe>` to copy. |
|
| 184 | 185 | - **API:** `GET /api/v1/drops` (yours, `repo:read`), `POST /api/v1/drops` (`NewDrop`, `repo:write`), `GET /api/v1/users/{name}/drops` (someone's public drops), `GET /api/v1/drops/{id}` (anyone with the id; each text file's content up to 512 KiB), `PATCH /api/v1/drops/{id}` (`DropEdit`: title, description, `secret`, and files to write or, with `content: null`, remove; others are kept) and `DELETE /api/v1/drops/{id}`. `oh drop create|list|view|delete` (docs/oh.md). |
|
| 185 | 186 | - **Later:** forks, stars, comments, embedding a drop in another page, and drops in backups. |
|
| 186 | 187 | ||
| … | 705 unchanged lines |
modified templates/drop.html +1 −0
| … | 12 unchanged lines |
||
| 13 | 13 | {% if signed_in || can_edit || can_delete || can_fork %}<div class="drop-actions">{% if signed_in %}<form class="inline" method="post" action="{{ repo.url }}/star"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" title="{% if starred %}Take your star back{% else %}Star this drop{% endif %}" aria-pressed="{% if starred %}true{% else %}false{% endif %}">{% if starred %}★ STARRED{% else %}☆ STAR{% endif %} {{ stars }}</button></form>{% endif %}{% if can_fork %}<form class="inline" method="post" action="{{ repo.url }}/fork"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" title="A copy of this drop, history and all, as a drop of yours">FORK</button></form>{% endif %}{% if can_edit %}<a class="go" href="{{ repo.url }}/edit">EDIT</a>{% endif %}{% if can_delete %}<form class="inline" method="post" action="{{ repo.url }}/delete" data-confirm="Delete this drop and its history? Its address stops working."><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="danger-btn">DELETE</button></form>{% endif %}</div>{% endif %} |
|
| 14 | 14 | </div> |
|
| 15 | 15 | {% if secret %}<div class="strip">Secret: this drop is listed nowhere, but anyone with its address can read it.</div>{% endif %} |
|
| 16 | + | <div class="drop-clone"><label for="embed">Embed</label><input type="text" id="embed" class="code" readonly value="{{ embed }}" data-select><button type="button" class="small" data-copy="{{ embed }}">copy</button></div> |
|
| 16 | 17 | <div class="drop-clone"><label for="clone">Clone</label><input type="text" id="clone" class="code" readonly value="oxy clone {{ clone_url }}" data-select><button type="button" class="small" data-copy="oxy clone {{ clone_url }}">copy</button></div> |
|
| 17 | 18 | {% if files.is_empty() %}<p class="empty">No files.</p>{% endif %} |
|
| 18 | 19 | {% for f in files %} |
|
| … | 38 unchanged lines |
modified templates/style.css +3 −0
| … | 1425 unchanged lines |
||
| 1426 | 1426 | .drop-clone label { color: var(--mute); } |
|
| 1427 | 1427 | .drop-clone input { flex: 0 1 640px; min-width: 0; } |
|
| 1428 | 1428 | .drop-filebox article.doc { max-width: none; margin: 0; border: 0; } |
|
| 1429 | + | /* A drop framed on another site (#280): its files and a link back. */ |
|
| 1430 | + | body.drop-embedded { margin: 0; padding: 8px; } |
|
| 1431 | + | .embed-head { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; margin: 0 0 8px; } |
|
| 1429 | 1432 | .drop-comment-rows li { align-items: flex-start; } |
|
| 1430 | 1433 | .drop-comment { flex: 1; min-width: 0; } |
|
| 1431 | 1434 | .drop-comment .doc { margin-top: 4px; overflow-wrap: anywhere; } |
|
| … | 3 unchanged lines |