OXYGENHUB

Comments on drops (refs #280) ca0c443c30cf

Signed-in readers comment on a drop in Markdown (drop_comments, migration
0092, at most 64 KiB), shown under its files; the drop's owner is told;
the author, the owner or a site administrator deletes one.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key d6f7fec46fbd
When
· 2026-10-21 03:41 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
ca0c443c30cfa854e929cc374594a0e4a80f7fa24e0e42e4701734a6d6ef72fb
Depends on
166f6e9bf4f1 18e381ea88c2 3b1bc27fe805 8bf3010760d7 93be9ce4653e a7e6e8e6094c adbc38e7ea09 b5668108487f b91e2d81b3f1 c419455a82dd d23464c0ac60
Depended on by
04f9c984439e 8ac2aa4ca6d1
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
16 atoms, 2 tree operations, 11 files

Browse the tree at this change

added crates/hub/migrations-pg/0092_drop_comments.sql +11 −0
1 +
-- Comments on a drop (oxygenhub#280): its readers', in Markdown; the
2 +
-- author, the drop's owner or a site administrator deletes one.
3 +
CREATE TABLE drop_comments (
4 +
    id         BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
5 +
    repo_id    BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
6 +
    author_id  BIGINT REFERENCES accounts(id) ON DELETE SET NULL,
7 +
    author     TEXT NOT NULL DEFAULT '',
8 +
    body       TEXT NOT NULL,
9 +
    created_ms BIGINT NOT NULL
10 +
);
11 +
CREATE INDEX drop_comments_by_drop ON drop_comments (repo_id, created_ms);
added crates/hub/migrations/0092_drop_comments.sql +11 −0
1 +
-- Comments on a drop (oxygenhub#280): its readers', in Markdown; the
2 +
-- author, the drop's owner or a site administrator deletes one.
3 +
CREATE TABLE drop_comments (
4 +
    id         INTEGER PRIMARY KEY,
5 +
    repo_id    INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
6 +
    author_id  INTEGER REFERENCES accounts(id) ON DELETE SET NULL,
7 +
    author     TEXT NOT NULL DEFAULT '',
8 +
    body       TEXT NOT NULL,
9 +
    created_ms INTEGER NOT NULL
10 +
);
11 +
CREATE INDEX drop_comments_by_drop ON drop_comments (repo_id, created_ms);
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **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.
7 8
- **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.
8 9
## 0.7.0 (2026-10-21)
9 10
…
282 unchanged lines
modified crates/hub/src/db/repos.rs +36 −0
…
182 unchanged lines
183 183
    .await
184 184
}
185 185
186 +
/// A comment on a drop (#280).
187 +
#[derive(Clone, Debug, FromRow)]
188 +
pub struct DropComment {
189 +
    pub id: i64,
190 +
    pub repo_id: i64,
191 +
    pub author_id: Option<i64>,
192 +
    pub author: String,
193 +
    pub body: String,
194 +
    pub created_ms: i64,
195 +
}
196 +
197 +
pub async fn add_drop_comment(pool: &Pool, repo_id: i64, author_id: i64, author: &str, body: &str) -> sqlx::Result<i64> {
198 +
    sqlx::query_scalar("INSERT INTO drop_comments (repo_id, author_id, author, body, created_ms) VALUES ($1, $2, $3, $4, $5) RETURNING id")
199 +
        .bind(repo_id)
200 +
        .bind(author_id)
201 +
        .bind(author)
202 +
        .bind(body)
203 +
        .bind(now_ms())
204 +
        .fetch_one(pool)
205 +
        .await
206 +
}
207 +
208 +
/// A drop's comments, oldest first.
209 +
pub async fn drop_comments(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<DropComment>> {
210 +
    sqlx::query_as("SELECT * FROM drop_comments WHERE repo_id = $1 ORDER BY created_ms, id").bind(repo_id).fetch_all(pool).await
211 +
}
212 +
213 +
pub async fn drop_comment(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<DropComment>> {
214 +
    sqlx::query_as("SELECT * FROM drop_comments WHERE repo_id = $1 AND id = $2").bind(repo_id).bind(id).fetch_optional(pool).await
215 +
}
216 +
217 +
pub async fn delete_drop_comment(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<()> {
218 +
    sqlx::query("DELETE FROM drop_comments WHERE repo_id = $1 AND id = $2").bind(repo_id).bind(id).execute(pool).await?;
219 +
    Ok(())
220 +
}
221 +
186 222
/// An owner's drops, newest activity first.
187 223
pub async fn drops_of(pool: &Pool, owner: &str) -> sqlx::Result<Vec<RepoRow>> {
188 224
    sqlx::query_as("SELECT * FROM repositories WHERE kind = 'drop' AND owner = $1 ORDER BY updated_ms DESC, id DESC")
…
315 unchanged lines
modified crates/hub/src/notify/mod.rs +1 −1
…
108 unchanged lines
109 109
        "proposal.review_requested" => "👀 Review Requested",
110 110
        "proposal.review" => "📝 New Review",
111 111
        "proposal.landed" => "🚀 Proposal Landed",
112 -
        "proposal.comment" | "issue.comment" => "💬 New Comment",
112 +
        "proposal.comment" | "issue.comment" | "drop.comment" => "💬 New Comment",
113 113
        "issue.opened" => "🐛 New Issue",
114 114
        "issue.assigned" => "📌 Issue Assigned",
115 115
        "issue.closed" => "✔️ Issue Closed",
…
46 unchanged lines
modified crates/hub/src/web/drops.rs +99 −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 +
    /// Its comments, rendered, and whether the viewer may delete each (#280).
67 +
    comments: Vec<ShownComment>,
66 68
    /// Its stars, and whether the viewer gave one (#280).
67 69
    stars: i64,
68 70
    starred: bool,
…
8 unchanged lines
77 79
    updated_ms: i64,
78 80
}
79 81
82 +
/// A comment as the drop's page shows it.
83 +
pub struct ShownComment {
84 +
    pub id: i64,
85 +
    pub author: String,
86 +
    pub created_ms: i64,
87 +
    pub html: String,
88 +
    pub may_delete: bool,
89 +
}
90 +
80 91
#[derive(Template)]
81 92
#[template(path = "drop_form.html")]
82 93
pub struct DropForm {
…
107 unchanged lines
190 201
        Some(id) => db::repos::get_by_id(&app.db, id).await.ok().flatten().filter(|r| r.is_drop()).map(|r| drops::url_of(&r)),
191 202
        None => None,
192 203
    };
204 +
    let comments = db::repos::drop_comments(&app.db, row.id)
205 +
        .await
206 +
        .unwrap_or_default()
207 +
        .into_iter()
208 +
        .map(|c| ShownComment {
209 +
            may_delete: may_delete_comment(viewer, &row, &c),
210 +
            id: c.id,
211 +
            html: markdown::render(&c.body, "", ""),
212 +
            author: c.author,
213 +
            created_ms: c.created_ms,
214 +
        })
215 +
        .collect();
193 216
    let stars = db::repos::counts(&app.db, row.id).await.map(|(s, _)| s).unwrap_or(0);
194 217
    let starred = match viewer.account() {
195 218
        Some(a) => db::repos::starred(&app.db, a.id, row.id).await.unwrap_or(false),
196 219
        None => false,
197 220
    };
198 221
    Ok(Page(DropPage {
222 +
        comments,
199 223
        stars,
200 224
        starred,
201 225
        signed_in: viewer.account().is_some(),
…
246 unchanged lines
448 472
    let row = writable(&app, &shell, &viewer, &owner, &id, true).await?;
449 473
    drops::delete(&app, &me.account, &row).await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
450 474
    Ok(Redirect::to(&format!("/{}/drops?deleted=1", row.owner)).into_response())
475 +
}
476 +
477 +
/// Who may delete a comment: its author, the drop's owner, a site
478 +
/// administrator.
479 +
fn may_delete_comment(viewer: &Viewer, row: &db::repos::RepoRow, c: &db::repos::DropComment) -> bool {
480 +
    viewer.account().is_some_and(|a| !a.blocked() && (Some(a.id) == c.author_id || a.username == row.owner || a.is_admin))
481 +
}
482 +
483 +
/// The longest comment taken, in bytes.
484 +
const COMMENT_MAX: usize = 64 * 1024;
485 +
486 +
#[derive(Deserialize)]
487 +
pub struct CommentForm {
488 +
    #[serde(rename = "_csrf")]
489 +
    csrf_token: String,
490 +
    body: String,
491 +
}
492 +
493 +
/// `POST /{owner}/drops/{id}/comment` (#280): a reader's comment, in
494 +
/// Markdown; the drop's owner is told.
495 +
pub async fn comment(
496 +
    State(app): State<Shared>,
497 +
    viewer: Viewer,
498 +
    Path((owner, id)): Path<(String, String)>,
499 +
    Form(f): Form<CommentForm>,
500 +
) -> Result<Response, Error> {
501 +
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer);
502 +
    let me = require(&viewer, &shell)?;
503 +
    csrf(&viewer, &f.csrf_token, &shell)?;
504 +
    let row = lookup(&app, &shell, &viewer, &owner, &id).await?;
505 +
    let body = f.body.trim();
506 +
    if body.is_empty() || body.len() > COMMENT_MAX || me.account.blocked() {
507 +
        return Err(Error::bad(shell, "A comment is some text, at most 64 KiB."));
508 +
    }
509 +
    let n = db::repos::add_drop_comment(&app.db, row.id, me.account.id, &me.account.username, body)
510 +
        .await
511 +
        .map_err(|e| Error::from_db(shell.clone(), e))?;
512 +
    let url = drops::url_of(&row);
513 +
    if me.account.username != row.owner {
514 +
        if let Ok(Some(owner_account)) = db::accounts::account_by_name(&app.db, &row.owner).await {
515 +
            let title = format!("{} on your drop {}", me.account.username, drops::shown_title(&row, &[]));
516 +
            crate::notify::tell(
517 +
                &app,
518 +
                "drop.comment",
519 +
                &title,
520 +
                &format!("{url}#comment-{n}"),
521 +
                &me.account.username,
522 +
                Some(row.id),
523 +
                vec![(owner_account.id, db::notify::Reason::Participating)],
524 +
            )
525 +
            .await;
526 +
        }
527 +
    }
528 +
    Ok(Redirect::to(&format!("{url}#comment-{n}")).into_response())
529 +
}
530 +
531 +
/// `POST /{owner}/drops/{id}/comment/{n}/delete` (#280).
532 +
pub async fn delete_comment(
533 +
    State(app): State<Shared>,
534 +
    viewer: Viewer,
535 +
    Path((owner, id, n)): Path<(String, String, i64)>,
536 +
    Form(f): Form<DeleteForm>,
537 +
) -> Result<Response, Error> {
538 +
    let shell = Shell::new(&app, &format!("/{owner}/drops/{id}"), &viewer);
539 +
    require(&viewer, &shell)?;
540 +
    csrf(&viewer, &f.csrf_token, &shell)?;
541 +
    let row = lookup(&app, &shell, &viewer, &owner, &id).await?;
542 +
    let c = db::repos::drop_comment(&app.db, row.id, n).await.map_err(|e| Error::from_db(shell.clone(), e))?;
543 +
    match c {
544 +
        Some(c) if may_delete_comment(&viewer, &row, &c) => {
545 +
            db::repos::delete_drop_comment(&app.db, row.id, n).await.map_err(|e| Error::from_db(shell.clone(), e))?;
546 +
            Ok(Redirect::to(&format!("{}#comments", drops::url_of(&row))).into_response())
547 +
        }
548 +
        _ => Err(Error::not_found(shell)),
549 +
    }
451 550
}
452 551
453 552
/// `POST /{owner}/drops/{id}/star` (#280): star it, or take the star back.
…
78 unchanged lines
modified crates/hub/src/web/mod.rs +2 −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}/comment", post(drops::comment))
201 +
        .route("/{owner}/drops/{id}/comment/{n}/delete", post(drops::delete_comment))
200 202
        .route("/{owner}/drops/{id}/changes/{r}", get(drops::changes))
201 203
        .route("/{owner}/drops/{id}/change/{hash}", get(drops::change))
202 204
        .route("/{owner}/drops/{id}/raw/{r}/{*path}", get(drops::raw))
…
702 unchanged lines
modified crates/hub/tests/drops.rs +32 −0
…
495 unchanged lines
496 496
    star(andy.clone()).await;
497 497
    assert!(get(app, &source, Some(&andy)).await.body.contains("☆ STAR 1"));
498 498
    assert!(get(app, &source, None).await.body.contains("1 star"), "the signed out see the count");
499 +
500 +
    // Comments: in Markdown; its owner is told; the author or the owner
501 +
    // deletes one, nobody else.
502 +
    let comment = |cookie: String, body: &'static str| {
503 +
        let (app, source) = (app.clone(), source.clone());
504 +
        async move {
505 +
            let csrf = csrf_of(&get(&app, &source, Some(&cookie)).await.body);
506 +
            let r = post(&app, &format!("{source}/comment"), Some(&cookie), &format!("_csrf={csrf}&body={}", enc(body))).await;
507 +
            assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
508 +
            r.location.unwrap().rsplit_once("#comment-").unwrap().1.to_string()
509 +
        }
510 +
    };
511 +
    let from_andy = comment(andy.clone(), "Nice **notes**").await;
512 +
    let from_carol = comment(carol.clone(), "Thanks").await;
513 +
    let page = get(app, &source, None).await.body;
514 +
    assert!(page.contains("<strong>notes</strong>") && page.contains(&format!("id=\"comment-{from_andy}\"")), "{page}");
515 +
    assert!(page.contains("Sign in to comment."));
516 +
    let carol_row = db::accounts::account_by_name(&app.db, "carol").await.unwrap().unwrap();
517 +
    let told = db::notify::inbox(&app.db, carol_row.id, 10).await.unwrap();
518 +
    assert!(told.iter().any(|n| n.kind == "drop.comment" && n.url.ends_with(&format!("#comment-{from_andy}"))), "{told:?}");
519 +
    let delete = |cookie: String, n: String| {
520 +
        let (app, source) = (app.clone(), source.clone());
521 +
        async move {
522 +
            let csrf = csrf_of(&get(&app, &source, Some(&cookie)).await.body);
523 +
            post(&app, &format!("{source}/comment/{n}/delete"), Some(&cookie), &format!("_csrf={csrf}")).await.status
524 +
        }
525 +
    };
526 +
    let dave = register(app, "dave").await;
527 +
    assert_eq!(delete(dave, from_andy.clone()).await, StatusCode::NOT_FOUND, "not someone else's");
528 +
    assert_eq!(delete(carol.clone(), from_andy.clone()).await, StatusCode::SEE_OTHER, "the drop's owner may");
529 +
    assert_eq!(delete(carol.clone(), from_carol.clone()).await, StatusCode::SEE_OTHER, "and the author");
530 +
    assert!(get(app, &source, None).await.body.contains("No comments yet."));
499 531
}
500 532
501 533
#[tokio::test(flavor = "multi_thread")]
…
27 unchanged lines
modified docs/spec.md +1 −0
…
179 unchanged lines
180 180
- **Deleting** moves the repository to the trash under its exclusive lock (oxygenhub#249) and removes the row; the address stops answering. Audited as `drop.created`, `drop.edited`, `drop.visibility` and `drop.deleted`.
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 +
- **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`).
183 184
- **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).
184 185
- **Later:** forks, stars, comments, embedding a drop in another page, and drops in backups.
185 186
…
705 unchanged lines
modified templates/drop.html +19 −0
…
33 unchanged lines
34 34
  {% endif %}
35 35
</section>
36 36
{% endfor %}
37 +
<section class="card-box drop-comments" id="comments">
38 +
  <div class="card-head"><h2>Comments</h2><span class="count-chip">{{ comments.len() }}</span></div>
39 +
  {% if comments.is_empty() %}<p class="empty small">No comments yet.</p>{% else %}
40 +
  <ul class="rows drop-comment-rows">{% for c in comments %}
41 +
    <li id="comment-{{ c.id }}">
42 +
      <div class="drop-comment">
43 +
        <div class="small mute"><a href="/{{ c.author }}">{{ c.author }}</a> · <time title="{{ c.created_ms|when_i }}">{{ c.created_ms|ago_i }}</time></div>
44 +
        <div class="doc">{{ c.html|safe }}</div>
45 +
      </div>
46 +
      {% if c.may_delete %}<form class="inline" method="post" action="{{ repo.url }}/comment/{{ c.id }}/delete" data-confirm="Delete this comment?"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="small danger">Delete</button></form>{% endif %}
47 +
    </li>{% endfor %}
48 +
  </ul>{% endif %}
49 +
  {% if signed_in %}<form class="pad drop-comment-form" method="post" action="{{ repo.url }}/comment">
50 +
    <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
51 +
    <label for="comment-body" class="small mute">Comment (Markdown)</label>
52 +
    <textarea id="comment-body" name="body" rows="4" required maxlength="65536"></textarea>
53 +
    <div class="actions"><button type="submit">COMMENT</button></div>
54 +
  </form>{% else %}<p class="small mute pad">Sign in to comment.</p>{% endif %}
55 +
</section>
37 56
{% endblock %}
modified templates/style.css +4 −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 +
.drop-comment-rows li { align-items: flex-start; }
1430 +
.drop-comment { flex: 1; min-width: 0; }
1431 +
.drop-comment .doc { margin-top: 4px; overflow-wrap: anywhere; }
1432 +
.drop-comment-form textarea { width: 100%; box-sizing: border-box; }
1429 1433
.drop-file { border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; margin: 0 0 12px; }
1430 1434
.drop-file-actions { display: flex; align-items: flex-end; justify-content: flex-end; }