Side B is a focused, real bugfix: it fixes production 403 blocks by making OAuth mandatory when creds exist, adds a token-refresh-and-retry path on 401/403, improves error messages, and pins a config value in fly.toml—directly addressing a deployment-breaking issue. Side A is a larger feature/refactor (raw entity import, explicit fetch UI, dotenv) that is useful but more diffuse, mixes unrelated concerns (UI, config loading, event schema), and includes a bespoke Clojure integration test that adds maintenance surface without clear necessity.
constitution · epochs · watch · epoch 3
c_e57094c6229a (tommy-mor) vs c_ca9169f732b8 (tommy-mor)
download prompt · raw event · cmp_1fe99f6f3fa7a0
council reasoning
A adds lasting architecture: event-sourced EntityImported with full payload storage, explicit user-driven fetch UX (replacing auto-fetch on browse), configurable API bases, and a real mock-backed integration test. B is a focused, correct production fix (mandatory OAuth when creds exist, 401/403 refresh, fly.toml base URL) but much narrower in scope and impact on the codebase.
Side A introduces substantial new functionality and architecture: explicit user-triggered Reddit fetching, persistent `EntityImported` events with raw payload storage and replay, derived entity views, configurable API bases, UI controls, dotenv loading, and tests covering replay and end-to-end import. Side B is a valuable reliability fix that requires OAuth when credentials exist, retries after 401/403 with token refresh, and improves error handling for blocked responses, but it is a focused robustness improvement built on top of existing behavior rather than a broader lasting capability.
sides
A — c_e57094c6229a (tommy-mor)
message
[40b975bf] nice
diff preview
diff --git a/Cargo.lock b/Cargo.lock
index 266e876bb7ccbe788beb1d5bd53ad5b45ee5825b..2cea973082716e761ef6f5dd5886acc08ff9aac0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -222,6 +222,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1238,6 +1244,7 @@ version = "0.0.1"
dependencies = [
"axum",
"axum-extra",
+ "dotenvy",
"maud",
"reqwest",
"serde",
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 4677fedcb45292eebebe7e9cf6ce2f5738f18ddf..bd600138b613bd0f546bdec217a5334cdcb20aa5 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -17,6 +17,7 @@ tower-http = { version = "0.5", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
reqwest = { version = "0.12", features = ["json"] }
+dotenvy = "0.15"
[dev-dependencies]
reqwest = { version = "0.12", features = ["json"] }
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index d2024bd4582bcc8482b461b2ba4fedbd8bff7c66..b33a84e8bb5e817b26592868d88090e6d664d950 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -6,7 +6,7 @@ use axum::{
use std::collections::HashMap;
use crate::{
- html::{input_panel, js_string_literal, ranking_panel, JsBuilder},
+ html::{entity_section, input_panel, js_string_literal, ranking_panel, JsBuilder},
parser::parse_reddit_url,
path_types::ItemId,
reddit::ensure_partial_tree,
@@ -87,6 +87,20 @@ pub async fn post_ui_html(
.into_response()
}
},
+ HtmlUiAction::FetchEntity { item } => {
+ let id = parse_item_param(&item);
+ if id.is_root() {
+ return ui_js_warn("nothing to fetch for the root").into_response();
+ }
+ state.queue_entity_fetch(id.clone());
+ let tree = state.tree.read().await;
+ let empty = crate::reducer::NodeState::default();
+ let node = tree.get(&id).unwrap_or(&empty);
+ let panel = entity_section(&id, node, true);
+ JsBuilder::new()
+ .morph_selector("#entity-section", panel)
+ .into_response()
+ },
}
}
diff --git a/server/src/events.rs b/server/src/events.rs
index ed5be6b13b9d46e838831d6ce0f96f569b401730..07ce24b5e56cf72b0b442c3c3241efbf6c3b006a 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
+use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
@@ -18,4 +19,10 @@ pub enum Event {
},
/// Register a node path in the fractal tree (no external fetch).
NodeEnsured { id: String },
+ /// Full upstream API payload for a node (domain-specific view derived at replay/render time).
+ EntityImported {
+ id: String,
+ ts: i64,
+ payload: Value,
+ },
}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index df6505021d9f446c2b453e20e3eb3cf696a111f9..caf1309c8d93b47104499c57f9cc35ee7631fbb9 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -10,6 +10,7 @@ use crate::{
form_template::template_json_compact,
path_types::ItemId,
ranking::{top_bottom, RankedItem},
+ reddit::is_fetchable,
reducer::{GroupState, NodeState},
state::AppState,
ui_action::UI_RPC_FIELD,
@@ -151,7 +152,7 @@ pub fn breadcrumb_path(item: &ItemId) -> Markup {
fn entity_panel(node: &NodeState) -> Markup {
html! {
@if let Some(data) = &node.data {
- section id="entity-panel" class="demo-panel entity-card" {
+ div id="entity-panel" class="entity-card" {
h2 { (data.title) }
@if let Some(author) = &data.author {
p class="muted small" { "by " (author) }
@@ -164,6 +165,42 @@ fn entity_panel(node: &NodeState) -> Markup {
}
}
+/// Reddit/API import control — only shown on fetchable pages; never auto-fires.
+pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {
+ if !is_fetchable(item) {
+ return html! {};
+ }
+ let label = if fetching {
+ "Fetching…"
+ } else if has_data {
+ "Fetch more"
+ } else {
+ "Fetch from Reddit"
+ };
+ let rpc = template_json_compact(&serde_json::json!({
+ "action": "fetch_entity",
+ "item": item.as_str(),
+ }))
+ .expect("fetch_entity rpc template");
+ html! {
+ form method="post" action="/ui" id="fetch-entity-form" class="fetch-entity-form" {
+ input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
+ button type="submit" class="btn-secondary" disabled=(fetching) { (label) }
+ }
+ }
+}
+
+/// Entity card + explicit fetch control (morphed as `#entity-section`).
+pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool) -> Markup {
+ let has_data = node.data.is_some();
+ html! {
+ section id="entity-section" class="demo-panel" {
+ (entity_panel(node))
+ (fetch_entity_panel(item, has_data, fetching))
+ }
+ }
+}
+
fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
html! {
@if !items.is_empty() {
@@ -260,7 +297,7 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {
h1 { "sorter" }
(input_panel("", None))
(breadcrumb_path(&item))
- (entity_panel(node))
+ (entity_section(&item, node, false))
(ranking_panel(&item, group))
};
layout("sorter2", body, views)
@@ -272,16 +309,5 @@ pub async fn home(State(state): State<AppState>, uri: Uri) -> impl IntoResponse
pub async fn browse(State(state): State<AppState>, uri: Uri) -> impl IntoResponse {
let item = ItemId::from_browse_uri(uri.path()).unwrap_or(ItemId::root());
- if item.as_str().starts_with("reddit.com") {
- let needs_fetch = {
- let tree = state.tree.read().await;
- tree.get(&item)
- .map(|n| n.data.is_none())
- .unwrap_or(true)
- };
- if needs_fetch {
- state.reddit.request_fetch(item.clone());
- }
- }
item_page(state, uri, item).await
}
diff --git a/server/src/main.rs b/server/src/main.rs
index c22ec6c9f5358e5ec99fb83210dc351938505a93..1f0cddc39302b35b0cd6a6219f44c9d59202facf 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -2,6 +2,10 @@ use sorter2_server::state::AppConfig;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ if std::env::var("SORTER2_SKIP_DOTENV").is_err() {
+ let _ = dotenvy::dotenv();
+ }
+
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
index 90053ad03b1d7c8e94f325dd4ee64c2b4f7da900..ff0f01e57b18af878eb5be3efc47204a7673589d 100644
--- a/server/src/reddit.rs
+++ b/server/src/reddit.rs
@@ -6,11 +6,15 @@ use std::time::{Duration, Instant};
use reqwest::{header, Client, StatusCode};
use serde::Deserialize;
+use serde_json::Value;
use tokio::sync::{mpsc, RwLock};
use crate::{
+ event_log::EventLog,
+ events::Event,
+ html::now_ms,
path_types::ItemId,
- reducer::{EntityData, GlobalTree},
+ reducer::GlobalTree,
};
/// Bootstrap blank nodes along a URL path so breadcrumbs and voting work before fetch.
@@ -20,6 +24,8 @@ pub fn ensure_partial_tree(tree: &mut GlobalTree, id: &ItemId) {
pub struct RedditCommand {
pub id: ItemId,
+ /// User-initiated fetch bypasses the in-memory "recently fetched" cache.
+ pub force: bool,
}
#[derive(Clone)]
@@ -33,19 +39,31 @@ struct RedditCredentials {
client_secret: String,
}
+#[derive(Clone)]
+pub struct RedditApiConfig {
+ pub api_base: String,
+ pub oauth_base: String,
+ pub user_agent: String,
+ creds: Option<RedditCredentials>,
+}
+
struct OAuthToken {
access_token: String,
expires_at: Instant,
}
impl RedditBroker {
- pub fn spawn(tree: Arc<RwLock<GlobalTree>>, user_agent: &str) -> Self {
+ pub fn spawn(
+ tree: Arc<RwLock<GlobalTree>>,
+ event_log: Arc<EventLog>,
+ config: RedditApiConfig,
+ ) -> Self {
let (tx, rx) = mpsc::channel(100);
let mut headers = header::HeaderMap::new();
headers.insert(
header::USER_AGENT,
- header::HeaderValue::from_str(user_agent).expect("valid user agent"),
+ header::HeaderValue::from_str(&config.user_agent).expect("valid user agent"),
);
let client = Client::builder()
@@ -54,22 +72,38 @@ impl RedditBroker {
.build()
.expect("reqwest client");
- let creds = RedditCredentials::from_env();
- tokio::spawn(reddit_worker(rx, tree, client, creds));
+ tokio::spawn(reddit_worker(rx, tree, event_log, client, config));
Self { tx }
}
- /// Fire-and-forget: queue a fetch; worker updates the tree when done.
- pub fn request_fetch(&self, id: ItemId) {
- let _ = self.tx.try_send(RedditCommand { id });
+ /// Queue a fetch; drops when the channel is full (backpressure).
+ pub fn request_fetch(&self, id: ItemId, force: bool) {
+ let _ = self.tx.try_send(RedditCommand { id, force });
+ }
+}
+
+impl RedditApiConfig {
+ pub fn from_env() -> Self {
+ Self {
+ api_base: reddit_api_base(),
+ oauth_base: reddit_oauth_base(),
+ user_agent: default_user_agent(),
+ creds: RedditCredentials::from_env(),
+ }
}
}
impl RedditCredentials {
+ /// Reddit's OAuth docs call these "client id" and "client secret"; the app
+ /// registration UI often labels them "app id" / "app secret" — same values.
fn from_env() -> Option<Self> {
- let client_id = std::env::var("REDDIT_CLIENT_ID").ok()?;
- let client_secret = std::env::var("REDDIT_CLIENT_SECRET").ok()?;
+ let client_id = std::env::var("REDDIT_CLIENT_ID")
+ .or_else(|_| std::env::var("REDDIT_APP_ID"))
+ .ok()?;
+ let client_secret = std::env::var("REDDIT_CLIENT_SECRET")
+ .or_else(|_| std::env::var("REDDIT_APP_SECRET"))
+ .ok()?;
if client_id.is_empty() || client_secret.is_empty() {
return None;
}
@@ -80,29 +114,63 @@ impl RedditCredentials {
}
}
+pub fn reddit_api_base() -> String {
+ std::env::var("REDDIT_API_BASE").unwrap_or_else(|_| "https://www.reddit.com".into())
+}
+
+pub fn reddit_oauth_base() -> String {
+ std::env::var("REDDIT_OAUTH_BASE").unwrap_or_else(|_| "https://www.reddit.com".into())
+}
+
pub fn default_user_agent() -> String {
std::env::var("REDDIT_USER_AGENT").unwrap_or_else(|_| {
"web:sorter2.social:v0.0.1 (by /u/sorter2)".to_string()
})
}
+/// True when this node can be loaded from the Reddit JSON API.
+pub fn is_fetchable(id: &ItemId) -> bool {
+ !map_item_to_reddit_api(id, "https://example.com").is_empty()
+}
+
+/// Derive UI-facing fields from a stored payload (Reddit-specific when under reddit.com).
+pub fn entity_view_from_payload(id: &ItemId, payload: &Value) -> Option<crate::reducer::EntityData> {
+ if id.as_str().starts_with("reddit.com") {
+ return parse_reddit_view(id, payload);
+ }
+ None
+}
+
+/// Apply a full API payload to the in-memory tree (view derived for known domains).
+pub fn apply_entity_import(tree: &mut GlobalTree, id: &ItemId, payload: Value) {
+ let view = entity_view_from_payload(id, &payload);
+ tree.apply_entity_raw(id, payload, view);
+}
+
async fn red
… preview truncated; 22,673 characters omittedB — c_ca9169f732b8 (tommy-mor)
message
[8f69c309] Require Reddit OAuth when credentials are set and refresh on 401/403. Avoid falling back to the public www.reddit.com API from cloud IPs, which returns Reddit's network-security block page. Also pin SORTER2_BASE_URL in fly.toml. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/fly.toml b/fly.toml
index f0c6a39f643c204987debc177234d95a8ef44b65..ca7e0088a7efd7d58d29d808f8d89080b2bff233 100644
--- a/fly.toml
+++ b/fly.toml
@@ -5,6 +5,7 @@ primary_region = "iad"
dockerfile = "Dockerfile"
[env]
+ SORTER2_BASE_URL = "https://reddit.sorter.social"
SORTER2_DATA_DIR = "/data"
SORTER2_EVENT_LOG = "/data/events.jsonl"
PORT = "8080"
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
index a874814f8927192ee62cab2d0db1efd27dcd57b7..f409764c1e1f36216f1b08107043c2eab905694c 100644
--- a/server/src/reddit.rs
+++ b/server/src/reddit.rs
@@ -283,26 +283,35 @@ async fn reddit_worker(
);
tokio::time::sleep(current_delay).await;
- if let Some(c) = &creds {
- oauth = ensure_oauth_token(&client, &oauth_token_base, c, oauth.take()).await;
- }
-
- let token = oauth.as_ref().map(|t| t.access_token.as_str());
- let fetch_base = if token.is_some() {
- tracing::debug!(
- item = %fetch_id,
- base = %oauth_api_base,
- "reddit fetch using OAuth bearer"
- );
- &oauth_api_base
- } else {
- &api_base
- };
- let url = match kind {
- FetchKind::SelfEntity => map_item_to_reddit_api(&fetch_id, fetch_base),
- FetchKind::Children => map_children_url(&fetch_id, fetch_base),
+ let outcome = match &creds {
+ Some(c) => {
+ // OAuth is required when credentials are configured — never fall
+ // back to the public www.reddit.com JSON endpoints (cloud IPs
+ // get blocked with a 403 HTML interstitial).
+ fetch_with_oauth(
+ &client,
+ &oauth_token_base,
+ &oauth_api_base,
+ c,
+ &mut oauth,
+ &fetch_id,
+ kind,
+ )
+ .await
+ }
+ None => {
+ let url = match kind {
+ FetchKind::SelfEntity => map_item_to_reddit_api(&fetch_id, &api_base),
+ FetchKind::Children => map_children_url(&fetch_id, &api_base),
+ };
+ match do_fetch(&client, &url, &fetch_id, None).await {
+ Ok(FetchOutcome::AuthRejected { status, detail }) => {
+ Err(format!("Reddit API {status}: {detail}"))
+ }
+ other => other,
+ }
+ }
};
- let outcome = do_fetch(&client, &url, &fetch_id, token).await;
match outcome {
Ok(FetchOutcome::Payload(payload)) => {
@@ -342,6 +351,12 @@ async fn reddit_worker(
current_delay = (current_delay * 2).min(Duration::from_secs(60));
notify(done, FetchJobResult::RateLimited { reset_secs });
}
+ Ok(FetchOutcome::AuthRejected { status, detail }) => {
+ let e = format!("Reddit API {status}: {detail}");
+ tracing::warn!(item = %fetch_id, err = %e, "reddit fetch auth rejected");
+ current_delay = (current_delay * 2).min(Duration::from_secs(60));
+ notify(done, FetchJobResult::Failed(e));
+ }
Err(e) => {
tracing::warn!(item = %fetch_id, err = %e, "reddit fetch failed");
current_delay = (current_delay * 2).min(Duration::from_secs(60));
@@ -357,6 +372,60 @@ enum FetchOutcome {
Payload(Value),
NotFound,
RateLimited { reset_secs: u64 },
+ /// Bearer rejected — caller should drop the cached token and retry once.
+ AuthRejected { status: StatusCode, detail: String },
+}
+
+async fn fetch_with_oauth(
+ client: &Client,
+ oauth_token_base: &str,
+ oauth_api_base: &str,
+ creds: &RedditCredentials,
+ oauth: &mut Option<OAuthToken>,
+ fetch_id: &ItemId,
+ kind: FetchKind,
+) -> Result<FetchOutcome, String> {
+ for attempt in 0..2 {
+ let force_refresh = attempt > 0;
+ *oauth = Some(
+ ensure_oauth_token(client, oauth_token_base, creds, oauth.take(), force_refresh)
+ .await?,
+ );
+ let token = oauth
+ .as_ref()
+ .expect("token set above")
+ .access_token
+ .clone();
+
+ tracing::debug!(
+ item = %fetch_id,
+ base = %oauth_api_base,
+ attempt,
+ "reddit fetch using OAuth bearer"
+ );
+
+ let url = match kind {
+ FetchKind::SelfEntity => map_item_to_reddit_api(fetch_id, oauth_api_base),
+ FetchKind::Children => map_children_url(fetch_id, oauth_api_base),
+ };
+ match do_fetch(client, &url, fetch_id, Some(&token)).await? {
+ FetchOutcome::AuthRejected { status, detail } if attempt == 0 => {
+ tracing::warn!(
+ item = %fetch_id,
+ %status,
+ %detail,
+ "reddit OAuth rejected; refreshing token and retrying"
+ );
+ *oauth = None;
+ continue;
+ }
+ FetchOutcome::AuthRejected { status, detail } => {
+ return Err(format!("Reddit API {status}: {detail}"));
+ }
+ other => return Ok(other),
+ }
+ }
+ unreachable!("loop always returns")
}
async fn ensure_oauth_token(
@@ -364,35 +433,35 @@ async fn ensure_oauth_token(
oauth_base: &str,
creds: &RedditCredentials,
existing: Option<OAuthToken>,
-) -> Option<OAuthToken> {
- if let Some(t) = existing {
- if Instant::now() < t.expires_at - Duration::from_secs(60) {
- tracing::debug!("reddit OAuth token still valid");
- return Some(t);
+ force_refresh: bool,
+) -> Result<OAuthToken, String> {
+ if !force_refresh {
+ if let Some(t) = existing {
+ if Instant::now() < t.expires_at - Duration::from_secs(60) {
+ tracing::debug!("reddit OAuth token still valid");
+ return Ok(t);
+ }
}
}
let url = format!("{}/api/v1/access_token", oauth_base.trim_end_matches('/'));
- tracing::debug!(%url, "reddit OAuth token request");
+ tracing::debug!(%url, force_refresh, "reddit OAuth token request");
let resp = client
.post(&url)
.basic_auth(&creds.client_id, Some(&creds.client_secret))
.form(&[("grant_type", "client_credentials")])
.send()
- .await;
-
- let resp = match resp {
- Ok(r) => r,
- Err(e) => {
- tracing::warn!("reddit OAuth token request failed: {e}");
- return None;
- }
- };
+ .await
+ .map_err(|e| format!("Reddit OAuth token request failed: {e}"))?;
if !resp.status().is_success() {
- tracing::warn!("reddit OAuth token HTTP {}", resp.status());
- return None;
+ let status = resp.status();
+ let body = resp.text().await.unwrap_or_default();
+ return Err(format!(
+ "Reddit OAuth token HTTP {status}: {}",
+ truncate_for_error(&body)
+ ));
}
#[derive(Deserialize)]
@@ -401,21 +470,40 @@ async fn ensure_oauth_token(
expires_in: u64,
}
- let body: TokenResponse = match resp.json().await {
- Ok(b) => b,
- Err(e) => {
- tracing::warn!("reddit OAuth token parse failed: {e}");
- return None;
- }
- };
+ let body: TokenResponse = resp
+ .json()
+ .await
+ .map_err(|e| format!("Reddit OAuth token parse failed: {e}"))?;
- tracing::debug!(expires_in = body.expires_in, "reddit OAuth token acquired");
- Some(OAuthToken {
+ tracing::info!(expires_in = body.expires_in, "reddit OAuth token acquired");
+ Ok(OAuthToken {
access_token: body.access_token,
expires_at: Instant::now() + Duration::from_secs(body.expires_in),
})
}
+fn truncate_for_error(body: &str) -> String {
+ let compact: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
+ if compact.is_empty() {
+ return "(empty body)".into();
+ }
+ // Prefer the human-readable block message over dumping Reddit's CSS.
+ if let Some(idx) = compact.find("You've been blocked") {
+ let slice: String = compact.chars().skip(idx).take(160).collect();
+ return if compact.chars().count() > idx + 160 {
+ format!("{slice}…")
+ } else {
+ slice
+ };
+ }
+ let chars: String = compact.chars().take(200).collect();
+ if compact.chars().count() > 200 {
+ format!("{chars}…")
+ } else {
+ chars
+ }
+}
+
async fn do_fetch(
client: &Client,
url: &str,
@@ -460,15 +548,16 @@ async fn do_fetch(
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
+ let detail = truncate_for_error(&body);
tracing::debug!(
item = %id,
%status,
body_len = body.len(),
- body_prefix = %body.chars().take(240).collect::<String>(),
+ %detail,
"reddit non-success body"
);
if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED {
- return Err(format!("Reddit API {status}: {body}"));
+ return Ok(FetchOutcome::AuthRejected { status, detail });
}
return Ok(FetchOutcome::NotFound);
}
@@ -716,6 +805,15 @@ fn reddit_direct_image_url(url: &str) -> bool {
mod tests {
use super::*;
+ #[test]
+ fn truncate_error_prefers_block_message() {
+ let html = r#"<style>.x{color:red}</style><div>You've been blocked by network security. To continue, log in</div>"#;
+ let msg = truncate_for_error(html);
+ assert!(msg.starts_with("You've been blocked"));
+ assert!(msg.len() < 200);
+ assert!(!msg.contains(".x{color"));
+ }
+
#[test]
fn map_subreddit_about_url() {
let id = ItemId::from_url("https://reddit.com/r/rust").unwrap();
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.