OXYGENHUB

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('"', "&quot;")
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