13 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-3 | 01 | execute | 1 |
|
true |
|
|
Purpose: Enable the BBS to link to external markdown content (documentation sites, wikis, READMEs) while maintaining security through domain whitelisting.
Output: Working remote link following with config, fetching, validation, rendering, error states, and history integration.
<execution_context> @/Users/ruohki/.claude/get-shit-done/workflows/execute-plan.md @/Users/ruohki/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/STATE.md @Cargo.toml @src/config.rs @src/vault.rs @src/app.rs @src/renderer.rs @src/main.rs Task 1: Add config field, ureq dependency, and remote fetch function Cargo.toml src/config.rs src/vault.rs 1. **Cargo.toml**: Add `ureq = "2.12"` to `[dependencies]`. ureq is a synchronous HTTP client with no async runtime requirement -- fits the project's "no tokio/async runtime" decision.-
src/config.rs: Add
allowed_remote_domainsfield to theConfigstruct:#[serde(default)] pub allowed_remote_domains: Vec<String>,Also add the field to the
Defaultimpl with an empty Vec default. IMPORTANT: The struct uses#[serde(deny_unknown_fields)]-- this field MUST be added to the struct, not just documented. The#[serde(default)]attribute ensures existing configs without this field still parse correctly. -
src/vault.rs: Add a new public enum and function:
/// Result of attempting to fetch a remote markdown document. pub enum RemoteDocument { /// Successfully fetched and content appears to be markdown. Loaded { url: String, content: String }, /// Domain is not in the whitelist. DomainNotAllowed { domain: String }, /// HTTP request failed (network error, timeout, non-2xx status). FetchError { url: String, reason: String }, /// Response content does not appear to be markdown (e.g. HTML page, binary). NotMarkdown { url: String, content_type: String }, }Add function
pub fn fetch_remote_markdown(url: &str, allowed_domains: &[String]) -> RemoteDocument:- Extract the domain from the URL using simple string parsing (split on
://, then take everything before the first/or end of string, then strip any port number with rfind(':')) - Check domain against
allowed_domains(case-insensitive comparison). Also check if the URL domain ENDS WITH a whitelisted domain prefixed by.-- this allowssub.example.comto match whitelist entryexample.com. - If not whitelisted, return
RemoteDocument::DomainNotAllowed. - Use
ureq::get(url).call()with a 10-second timeout via.timeout(Duration::from_secs(10)). - On success, check the Content-Type header: accept
text/markdown,text/plain,text/x-markdown, or any response where the URL path ends in.md. If Content-Type indicates HTML (text/html) or binary, returnRemoteDocument::NotMarkdown. - Read the response body as a string (with a 5MB size limit to prevent memory exhaustion:
use
.into_reader().take(5_000_000)and read to string). - Return
RemoteDocument::Loadedwith the URL and content. - On any ureq error, return
RemoteDocument::FetchErrorwith the error message.cargo checkpasses with no errors. The newallowed_remote_domainsfield exists in Config. Thefetch_remote_markdownfunction compiles and is public. Config struct hasallowed_remote_domains: Vec<String>with serde default.vault::fetch_remote_markdownfunction exists, handles domain whitelisting, HTTP fetching, content-type validation, and returns appropriate RemoteDocument variants. ureq dependency is in Cargo.toml.
- Extract the domain from the URL using simple string parsing (split on
-
src/app.rs -- Add a
current_urlfield toAppstruct:/// When viewing a remote document, stores the source URL for display in breadcrumb. /// None when viewing a local vault document. current_url: Option<String>,Initialize to
NoneinApp::new(). Set toSome(url)when navigating to a remote page, set back toNonewhen navigating to a local page. -
src/app.rs -- Modify
follow_selected_link(): In theelse(non-wiki) branch, BEFORE the existingresolve_standard_linklogic, add a check:if dest.starts_with("http://") || dest.starts_with("https://") { self.navigate_to_remote(&dest); return; } -
src/app.rs -- Add new method
navigate_to_remote(&mut self, url: &str):- Save current state to history (same pattern as
navigate_to). - Truncate forward history.
- Call
crate::vault::fetch_remote_markdown(url, &self.config.allowed_remote_domains). - Match on the result:
RemoteDocument::Loaded { url, content }:- Derive filename from the URL path (last segment, or the domain if path is
/). - Render with
crate::renderer::render_markdown(&content, width, None)-- passNonefor vault_path since wiki-links in remote content cannot resolve to the local vault. - Set
self.document = DocumentState::Loaded { filename, lines }. - Set
self.raw_content = Some(content). - Set
self.current_url = Some(url). - Set
self.current_pathto the URL string (for breadcrumb display). - Update link_records, copyable_blocks, reset scroll, push history entry.
- Do NOT call
self.rewatch_for_current_page()(no file to watch). - But DO stop the watcher from watching irrelevant dirs: unwatch is optional, just skip the rewatch call.
- Derive filename from the URL path (last segment, or the domain if path is
RemoteDocument::DomainNotAllowed { domain }:- Show an error screen: set
self.document = DocumentState::Errorwithpath: PathBuf::from(url)andreason: format!("Domain '{}' is not in the allowed remote domains list. Add it to allowed_remote_domains in bbs.toml.", domain). - Clear link_records, copyable_blocks, raw_content. Set current_url to None.
- Do NOT push to history (same pattern as Missing/ReadError in navigate_to).
- Show an error screen: set
RemoteDocument::FetchError { url, reason }:- Show error:
DocumentState::Error { path: PathBuf::from(&url), reason }. - Same cleanup as DomainNotAllowed. Do NOT push to history.
- Show error:
RemoteDocument::NotMarkdown { url, content_type }:- Show error:
DocumentState::Error { path: PathBuf::from(&url), reason: format!("Remote content is not markdown (Content-Type: {})", content_type) }. - Same cleanup. Do NOT push to history.
- Show error:
- Save current state to history (same pattern as
-
src/app.rs -- Update
navigate_back()andnavigate_forward(): When restoring a history entry whose path starts with "http://" or "https://", callnavigate_to_remotelogic instead ofload_document. The simplest approach: checkif target_path.starts_with("http://") || target_path.starts_with("https://"), then re-fetch the remote content (same as initial navigation -- no caching, consistent with the existing "re-load from disk" pattern for local docs). -
src/app.rs -- Update
handle_resize(): Whenself.current_url.is_some(), re-render using the storedraw_content(same as local docs) but passNonefor vault_path. Currently handle_resize always passesSome(&vault_path)-- add a conditional:let vault_ref = if self.current_url.is_some() { None } else { Some(&*vault_path) }; -
src/app.rs -- Update
reload_current_document(): Whenself.current_url.is_some(), skip reload entirely (remote content does not live-reload from filesystem). Add early return:if self.current_url.is_some() { return; }. -
src/app.rs -- Update
build_breadcrumb()or the status bar: Whencurrent_pathstarts withhttp, show a truncated URL in the breadcrumb instead of path components. The existingbuild_breadcrumbfunction will naturally split on/which works acceptably for URLs (e.g. "https: > example.com > docs > page.md"). This is acceptable BBS behavior. No change needed unless it looks terrible -- use your judgment. -
src/app.rs -- In
navigate_to()(the local navigation method), setself.current_url = Noneto clear remote state when navigating back to a local page.cargo buildsucceeds. Test manually: -
Create a
bbs.tomlwithallowed_remote_domains = ["raw.githubusercontent.com"] -
Create a vault markdown file with a link like
[Test](https://raw.githubusercontent.com/some/repo/main/README.md) -
Run
cargo run -- --config bbs.toml, navigate to the link, press Enter -
Verify the remote markdown renders in the TUI
-
Verify pressing Backspace returns to the previous local page
-
Test a non-whitelisted domain link shows the domain-not-allowed error
-
Verify
cargo clippyhas no errors (warnings acceptable) HTTP/HTTPS links in markdown are detected and visually distinguished (LightMagenta color). Following a whitelisted remote link fetches, validates, and renders the markdown content. Non-whitelisted domains show a clear error with instructions to add to config. Fetch failures and non-markdown content show appropriate error screens. Remote pages participate in back/forward history navigation. Resize re-renders remote content correctly. Live reload is skipped for remote pages.
<success_criteria>
- bbs.toml
allowed_remote_domains = ["example.com"]config field works - HTTP/HTTPS links render in LightMagenta (visually distinct from local LightCyan links)
- Following whitelisted HTTP links fetches and displays remote markdown
- Non-whitelisted domains show BBS-themed error with domain name and config hint
- Fetch errors and non-markdown responses show appropriate error screens
- Browser history (back/forward) works through local and remote pages
- No regressions in local vault navigation, wiki-links, or directory listing </success_criteria>