Add outline-cli: minimal CLI for search and document retrieval

Adds a command-line client in cli/ as its own Cargo workspace, depending
on the outline library via a path dependency. Covers exactly the requested
scope: login (stores the API key in the system keyring via the Secret
Service D-Bus protocol, verified against auth.info() before storing),
search (full-text), and get (fetch a document, render its markdown to a
self-contained HTML page, open it in the default browser).

Verified end-to-end against a real Outline instance and the local
gnome-keyring: login stores and search/get retrieve the token purely from
the keyring and saved config, with no environment variables set.
This commit is contained in:
2026-07-30 15:15:56 +02:00
parent a32a437557
commit 844d2e716f
16 changed files with 3667 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
!Cargo.lock
+2674
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "outline-cli"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
publish = false
[workspace]
[[bin]]
name = "outline"
path = "src/main.rs"
[dependencies]
outline-sdk = { path = "..", default-features = false, features = ["rustls-tls"] }
anyhow = "1"
clap = { version = "4.6", features = ["derive", "env"] }
directories = "6"
open = "5.4"
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
rpassword = "7.5"
secret-service = { version = "5.1", features = ["rt-tokio-crypto-rust"] }
serde = { version = "1", features = ["derive"] }
toml = "1"
tokio = { version = "1", features = ["rt", "macros"] }
url = "2"
[dev-dependencies]
serde_json = "1"
+68
View File
@@ -0,0 +1,68 @@
# outline-cli
A command-line client for [Outline](https://www.getoutline.com), built on the
[`outline`](../README.md) library. Search documents and open them locally,
rendered from markdown to HTML, in your default browser.
This is a separate Cargo workspace from the library (its own `Cargo.lock`),
depending on it via a path dependency.
## Install / build
```sh
cargo build --release
# binary at target/release/outline
```
## Usage
```sh
# Store an API key (created under Outline Settings → API & Apps) in the
# system keyring (GNOME Keyring / KWallet via the Secret Service D-Bus API).
outline login
# Full-text search
outline search "onboarding"
# Fetch a document by id, urlId, or a pasted document URL — renders its
# markdown to HTML and opens it in your default browser.
outline get hDYep1TPAM
```
For a self-hosted instance:
```sh
outline --base-url https://wiki.example.com login
```
The instance URL is remembered (in `~/.config/outline-cli/config.toml`) after
`login`, so it doesn't need to be repeated on every command.
## Configuration
| Source | Purpose | Precedence |
|---|---|---|
| `--base-url` / `$OUTLINE_URL` | Outline instance | 1 (highest) |
| `~/.config/outline-cli/config.toml` | saved instance URL (written by `login`) | 2 |
| Outline Cloud default | fallback | 3 |
| `--api-key` / `$OUTLINE_API_KEY` | API key | 1 |
| system keyring (Secret Service) | saved API key, per instance (written by `login`) | 2 |
The API key is **never** written to the config file — it lives exclusively in
the system keyring, looked up by the resolved instance URL.
Rendered documents are cached in `~/.cache/outline-cli/` (mode `0600`, not
`/tmp`, since documents may be confidential).
## Requirements
A running [Secret Service](https://specifications.freedesktop.org/secret-service/latest/)
provider (GNOME Keyring or KWallet) is required for `outline login` / for
`search`/`get` to find a stored key. On headless systems, skip the keyring
entirely and pass `--api-key` or set `$OUTLINE_API_KEY` instead.
## Status
Minimal first version: `login`, `search`, `get`. No `logout`, no rate-limit
retries, no export/attachment handling — see the library's own README for
the broader API coverage roadmap.
+108
View File
@@ -0,0 +1,108 @@
:root {
color-scheme: light dark;
}
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
margin: 0;
background: #fff;
color: #1a1a1a;
}
main.doc {
max-width: 44rem;
margin: 3rem auto;
padding: 0 1.5rem;
line-height: 1.65;
}
h1 {
margin-top: 0;
}
pre,
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
pre {
background: #f4f4f4;
padding: 0.75rem 1rem;
border-radius: 6px;
overflow-x: auto;
}
code {
background: #f0f0f0;
padding: 0.1rem 0.3rem;
border-radius: 4px;
}
pre code {
background: none;
padding: 0;
}
table {
border-collapse: collapse;
width: 100%;
}
th,
td {
border: 1px solid #ddd;
padding: 0.4rem 0.6rem;
text-align: left;
}
th {
background: #f4f4f4;
}
blockquote {
border-left: 3px solid #ccc;
margin: 1rem 0;
padding-left: 1rem;
color: #555;
}
img {
max-width: 100%;
}
footer {
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid #ddd;
font-size: 0.9rem;
}
@media (prefers-color-scheme: dark) {
body {
background: #1a1a1a;
color: #e6e6e6;
}
pre,
code {
background: #2a2a2a;
}
th {
background: #2a2a2a;
}
th,
td {
border-color: #3a3a3a;
}
blockquote {
border-left-color: #555;
color: #aaa;
}
footer {
border-top-color: #3a3a3a;
}
}
+52
View File
@@ -0,0 +1,52 @@
use clap::{Args, Parser, Subcommand};
#[derive(Debug, Parser)]
#[command(
name = "outline",
version,
about = "Search and read Outline documents from the terminal"
)]
pub struct Cli {
/// Outline instance to talk to (default: <https://app.getoutline.com>)
#[arg(long, global = true, value_name = "URL", env = "OUTLINE_URL")]
pub base_url: Option<String>,
/// API key. Prefer `outline login`: arguments are visible in the process list.
#[arg(
long,
global = true,
value_name = "KEY",
env = "OUTLINE_API_KEY",
hide_env_values = true
)]
pub api_key: Option<String>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
/// Store an API key for the configured Outline instance in the system keyring
Login,
/// Full-text search documents
Search(SearchArgs),
/// Fetch a document, render it to HTML and open it in the browser
Get(GetArgs),
}
#[derive(Debug, Args)]
pub struct SearchArgs {
/// The search query
pub query: String,
/// Maximum number of results to show
#[arg(short = 'n', long, default_value_t = 10)]
pub limit: u32,
}
#[derive(Debug, Args)]
pub struct GetArgs {
/// Document id, urlId, or a full Outline document URL
pub reference: String,
}
+53
View File
@@ -0,0 +1,53 @@
use anyhow::{Context, Result, bail};
use outline::Client;
use crate::cli::Cli;
use crate::config;
/// Resolves the base URL for this invocation (flag/env > config > default).
pub fn resolve_base_url(cli: &Cli) -> Result<String> {
config::resolve_base_url(cli.base_url.as_deref())
}
/// Builds a [`Client`] using the same precedence as the Outline API key
/// itself: `--api-key`/`$OUTLINE_API_KEY` > the system keyring.
pub async fn build(cli: &Cli) -> Result<Client> {
let base_url = resolve_base_url(cli)?;
let api_key = resolve_api_key(cli, &base_url).await?;
Client::builder()
.base_url(&base_url)
.api_key(api_key)
.build()
.context("failed to build the Outline client")
}
async fn resolve_api_key(cli: &Cli, base_url: &str) -> Result<String> {
if let Some(key) = &cli.api_key {
return Ok(key.clone());
}
if let Some(key) = crate::secret::load(base_url).await? {
return Ok(key);
}
bail!("no API key found for {base_url} — run `outline login` or set OUTLINE_API_KEY")
}
/// Turns an [`outline::Error`] into a message that tells the user what to do
/// about it, rather than just the raw API error.
pub fn explain(err: outline::Error) -> anyhow::Error {
use outline::ErrorKind;
match err.kind() {
ErrorKind::Unauthenticated => {
anyhow::anyhow!(
"authentication failed — run `outline login` (or check OUTLINE_API_KEY): {err}"
)
}
ErrorKind::RateLimited => match err.retry_after() {
Some(duration) => anyhow::anyhow!(
"rate limited by the server — retry in {}s",
duration.as_secs()
),
None => anyhow::anyhow!("rate limited by the server — please retry shortly"),
},
_ => anyhow::Error::new(err),
}
}
+79
View File
@@ -0,0 +1,79 @@
use anyhow::Context;
use url::Url;
use crate::cli::{Cli, GetArgs};
use crate::{client, config, render, view};
pub async fn run(cli: &Cli, args: &GetArgs) -> anyhow::Result<()> {
let outline_client = client::build(cli).await?;
let base_url = client::resolve_base_url(cli)?;
let instance =
Url::parse(&base_url).with_context(|| format!("invalid base url: {base_url}"))?;
let reference = normalize_reference(&args.reference);
let document = outline_client
.documents()
.info(reference.as_str())
.await
.map_err(|err| explain(err, &reference))?;
let page = render::document_to_page(&document, &instance);
let cache_dir = config::cache_dir()?;
let slug = document.url_id.as_deref().unwrap_or(document.id.as_str());
let path = view::write_page(&cache_dir, slug, &page.html)?;
view::open_in_browser(&path)?;
Ok(())
}
/// Outline document URLs look like `https://host/doc/title-urlId`. If the
/// user pasted a full URL (e.g. copied from the browser), use its last path
/// segment as the reference instead of the whole URL.
fn normalize_reference(input: &str) -> String {
if let Ok(url) = Url::parse(input) {
if let Some(mut segments) = url.path_segments() {
if let Some(last) = segments.rfind(|s| !s.is_empty()) {
return last.to_string();
}
}
}
input.to_string()
}
fn explain(err: outline::Error, reference: &str) -> anyhow::Error {
if err.is_not_found() {
anyhow::anyhow!("no document found for `{reference}`")
} else {
client::explain(err)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_a_bare_id_or_url_id_unchanged() {
assert_eq!(normalize_reference("hDYep1TPAM"), "hDYep1TPAM");
assert_eq!(
normalize_reference("5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2"),
"5c3fa3dd-eb47-4239-8a5f-de5b5d6bf6e2"
);
}
#[test]
fn extracts_the_last_segment_from_a_full_document_url() {
assert_eq!(
normalize_reference("https://wiki.example.com/doc/welcome-hDYep1TPAM"),
"welcome-hDYep1TPAM"
);
}
#[test]
fn extracts_the_last_segment_ignoring_a_trailing_slash() {
assert_eq!(
normalize_reference("https://wiki.example.com/doc/welcome-hDYep1TPAM/"),
"welcome-hDYep1TPAM"
);
}
}
+42
View File
@@ -0,0 +1,42 @@
use anyhow::Context;
use crate::cli::Cli;
use crate::{client, config, secret};
pub async fn run(cli: &Cli) -> anyhow::Result<()> {
let base_url = client::resolve_base_url(cli)?;
let token = match &cli.api_key {
Some(key) => key.clone(),
None => {
rpassword::prompt_password("Outline API key: ").context("failed to read the API key")?
}
};
// Verify the key works before storing it, so a typo surfaces immediately
// instead of on the next `search`/`get`.
let verifying_client = outline::Client::builder()
.base_url(&base_url)
.api_key(token.clone())
.build()
.context("invalid client configuration")?;
let auth = verifying_client
.auth()
.info()
.await
.context("the API key was rejected by the server")?;
secret::store(&base_url, &token)
.await
.context("failed to store the API key in the system keyring")?;
let mut cfg = config::Config::load()?;
cfg.base_url = Some(base_url.clone());
cfg.save()?;
println!(
"Logged in as {} ({}) at {base_url}",
auth.user.name, auth.team.name
);
Ok(())
}
+13
View File
@@ -0,0 +1,13 @@
mod get;
mod login;
mod search;
use crate::cli::{Cli, Command};
pub async fn dispatch(cli: Cli) -> anyhow::Result<()> {
match cli.command {
Command::Login => login::run(&cli).await,
Command::Search(ref args) => search::run(&cli, args).await,
Command::Get(ref args) => get::run(&cli, args).await,
}
}
+71
View File
@@ -0,0 +1,71 @@
use crate::cli::{Cli, SearchArgs};
use crate::client;
pub async fn run(cli: &Cli, args: &SearchArgs) -> anyhow::Result<()> {
let outline_client = client::build(cli).await?;
let page = outline_client
.documents()
.search(args.query.clone())
.limit(args.limit)
.send()
.await
.map_err(client::explain)?;
if page.items.is_empty() {
println!("No results for \"{}\".", args.query);
return Ok(());
}
for result in &page.items {
let reference = result
.document
.url_id
.as_deref()
.unwrap_or(result.document.id.as_str());
let snippet = strip_tags(result.context.as_deref().unwrap_or(""));
println!("{} [{reference}]", result.document.title);
if !snippet.is_empty() {
println!(" {snippet}");
}
}
Ok(())
}
/// Outline wraps matched search terms in `<b>...</b>` for its own web UI;
/// strip any such markup before printing a snippet to the terminal.
fn strip_tags(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut in_tag = false;
for ch in input.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => output.push(ch),
_ => {}
}
}
output.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn removes_highlight_tags() {
assert_eq!(
strip_tags("Code Style <b>Guide</b> overview"),
"Code Style Guide overview"
);
}
#[test]
fn leaves_plain_text_untouched() {
assert_eq!(strip_tags("no markup here"), "no markup here");
}
#[test]
fn trims_surrounding_whitespace() {
assert_eq!(strip_tags(" padded "), "padded");
}
}
+92
View File
@@ -0,0 +1,92 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
const DEFAULT_BASE_URL: &str = "https://app.getoutline.com";
const APPLICATION: &str = "outline-cli";
fn project_dirs() -> Result<ProjectDirs> {
ProjectDirs::from("", "", APPLICATION)
.context("could not determine the user's config/cache directory")
}
/// The directory the rendered document HTML is cached in (not `/tmp`, which
/// is world-readable and documents may be confidential).
pub fn cache_dir() -> Result<PathBuf> {
Ok(project_dirs()?.cache_dir().to_path_buf())
}
fn config_path() -> Result<PathBuf> {
Ok(project_dirs()?.config_dir().join("config.toml"))
}
/// Non-secret settings, written by `outline login`. The API key itself is
/// never stored here — it lives exclusively in the system keyring.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Config {
pub base_url: Option<String>,
}
impl Config {
pub fn load() -> Result<Self> {
let path = config_path()?;
match std::fs::read_to_string(&path) {
Ok(raw) => {
toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
Err(err) => Err(err).context(format!("failed to read {}", path.display())),
}
}
pub fn save(&self) -> Result<()> {
let path = config_path()?;
let dir = path.parent().expect("config path has a parent");
std::fs::create_dir_all(dir)
.with_context(|| format!("failed to create {}", dir.display()))?;
let raw = toml::to_string_pretty(self)?;
std::fs::write(&path, raw).with_context(|| format!("failed to write {}", path.display()))
}
}
/// Resolves the base URL to use: `--base-url`/`$OUTLINE_URL` (already folded
/// into `flag` by clap) > saved config > the Outline Cloud default.
pub fn resolve_base_url(flag: Option<&str>) -> Result<String> {
let saved = Config::load()?.base_url;
Ok(precedence(flag, saved.as_deref()))
}
fn precedence(flag: Option<&str>, saved: Option<&str>) -> String {
flag.or(saved).unwrap_or(DEFAULT_BASE_URL).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flag_takes_precedence_over_saved_config() {
assert_eq!(
precedence(
Some("https://flag.example.com"),
Some("https://saved.example.com")
),
"https://flag.example.com"
);
}
#[test]
fn saved_config_is_used_without_a_flag() {
assert_eq!(
precedence(None, Some("https://saved.example.com")),
"https://saved.example.com"
);
}
#[test]
fn falls_back_to_the_outline_cloud_default() {
assert_eq!(precedence(None, None), DEFAULT_BASE_URL);
}
}
+29
View File
@@ -0,0 +1,29 @@
mod cli;
mod client;
mod commands;
mod config;
mod render;
mod secret;
mod view;
use std::process::ExitCode;
use clap::Parser;
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err:#}");
ExitCode::FAILURE
}
}
}
fn run() -> anyhow::Result<()> {
let cli = cli::Cli::parse();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(commands::dispatch(cli))
}
+223
View File
@@ -0,0 +1,223 @@
//! Renders a document's markdown body to a small, self-contained HTML page.
use outline::models::Document;
use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, html};
use url::Url;
const STYLE: &str = include_str!("assets/style.css");
pub struct Page {
pub html: String,
}
/// Renders `document` to HTML, absolutizing any instance-relative links and
/// images (Outline stores these as paths like `/doc/...`) against `instance`
/// so they resolve when the page is opened as a local file.
pub fn document_to_page(document: &Document, instance: &Url) -> Page {
let markdown = document
.text
.as_deref()
.unwrap_or("_This document has no text content._");
let markdown = strip_leading_title(markdown, &document.title);
let options = Options::ENABLE_TABLES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_SMART_PUNCTUATION
| Options::ENABLE_HEADING_ATTRIBUTES;
// Outline documents are foreign, editable content rendered locally in a
// browser — raw HTML is filtered out rather than trusted.
let events = Parser::new_ext(markdown, options)
.map(|event| absolutize(event, instance))
.filter(|event| !matches!(event, Event::Html(_) | Event::InlineHtml(_)));
let mut body = String::with_capacity(markdown.len() * 3 / 2);
html::push_html(&mut body, events);
let title = escape(&document.title);
let source = document
.url
.as_deref()
.and_then(|path| instance.join(path).ok());
Page {
html: wrap(&title, &body, source.as_ref()),
}
}
fn absolutize<'a>(event: Event<'a>, base: &Url) -> Event<'a> {
fn abs<'a>(dest: CowStr<'a>, base: &Url) -> CowStr<'a> {
if dest.starts_with('/') {
if let Ok(url) = base.join(&dest) {
return CowStr::from(url.to_string());
}
}
dest
}
match event {
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}) => Event::Start(Tag::Link {
link_type,
dest_url: abs(dest_url, base),
title,
id,
}),
Event::Start(Tag::Image {
link_type,
dest_url,
title,
id,
}) => Event::Start(Tag::Image {
link_type,
dest_url: abs(dest_url, base),
title,
id,
}),
other => other,
}
}
/// Outline's editor shows the title as the document's own first heading, so
/// many documents' markdown repeats it verbatim as a leading `# Title` line.
/// Strip that line when it matches, since the page template renders its own
/// `<h1>` from `document.title` — otherwise the title would appear twice.
fn strip_leading_title<'a>(markdown: &'a str, title: &str) -> &'a str {
let trimmed = markdown.trim_start();
if let Some(rest) = trimmed.strip_prefix("# ") {
let (first_line, remainder) = match rest.find('\n') {
Some(idx) => (&rest[..idx], &rest[idx + 1..]),
None => (rest, ""),
};
if first_line.trim() == title.trim() {
return remainder.trim_start();
}
}
markdown
}
fn escape(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn wrap(title: &str, body: &str, source: Option<&Url>) -> String {
let footer = source
.map(|url| format!(r#"<a href="{url}">Open in Outline</a>"#))
.unwrap_or_default();
format!(
r#"<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src https: data:; style-src 'unsafe-inline'; font-src data:">
<title>{title}</title>
<style>{STYLE}</style>
</head><body><main class="doc">
<h1>{title}</h1>
{body}
<footer>{footer}</footer>
</main></body></html>"#
)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn document(title: &str, text: &str, url: Option<&str>) -> Document {
serde_json::from_value(json!({
"id": "doc-1",
"title": title,
"text": text,
"url": url,
}))
.unwrap()
}
#[test]
fn strips_leading_title_when_it_matches() {
let md = "# My Title\n\nBody text.";
assert_eq!(strip_leading_title(md, "My Title"), "Body text.");
}
#[test]
fn keeps_leading_heading_when_it_differs_from_title() {
let md = "# Something else\n\nBody text.";
assert_eq!(strip_leading_title(md, "My Title"), md);
}
#[test]
fn keeps_markdown_without_a_leading_heading() {
let md = "Just a paragraph.";
assert_eq!(strip_leading_title(md, "My Title"), md);
}
#[test]
fn renders_exactly_one_title_heading() {
let instance = Url::parse("https://wiki.example.com").unwrap();
let doc = document("My Title", "# My Title\n\nBody text.", None);
let page = document_to_page(&doc, &instance);
assert_eq!(page.html.matches("<h1>").count(), 1);
assert!(page.html.contains("<h1>My Title</h1>"));
}
#[test]
fn absolutizes_relative_links_and_images_against_the_instance() {
let instance = Url::parse("https://wiki.example.com").unwrap();
let doc = document(
"T",
"![alt](/api/attachments.redirect?id=1) [link](/doc/abc)",
None,
);
let page = document_to_page(&doc, &instance);
assert!(
page.html
.contains(r#"src="https://wiki.example.com/api/attachments.redirect?id=1""#)
);
assert!(
page.html
.contains(r#"href="https://wiki.example.com/doc/abc""#)
);
}
#[test]
fn leaves_absolute_links_untouched() {
let instance = Url::parse("https://wiki.example.com").unwrap();
let doc = document("T", "[external](https://other.example.com/page)", None);
let page = document_to_page(&doc, &instance);
assert!(
page.html
.contains(r#"href="https://other.example.com/page""#)
);
}
#[test]
fn filters_raw_html_out_of_the_body() {
let instance = Url::parse("https://wiki.example.com").unwrap();
let doc = document("T", "before <script>alert(1)</script> after", None);
let page = document_to_page(&doc, &instance);
assert!(!page.html.contains("<script>"));
}
#[test]
fn links_to_the_source_document_when_a_url_is_known() {
let instance = Url::parse("https://wiki.example.com").unwrap();
let doc = document("T", "body", Some("/doc/t-abc123"));
let page = document_to_page(&doc, &instance);
assert!(
page.html
.contains(r#"href="https://wiki.example.com/doc/t-abc123""#)
);
}
}
+83
View File
@@ -0,0 +1,83 @@
//! Token storage in the FreeDesktop Secret Service (GNOME Keyring, KWallet, ...).
//!
//! This is the only module that knows about the secret backend. Swapping in
//! the `libsecret` FFI crate or the `keyring` crate means rewriting this
//! file, nothing else.
use std::collections::HashMap;
use anyhow::{Context, Result};
use secret_service::{EncryptionType, SecretService};
const SCHEMA: &str = "com.getoutline.cli.ApiKey";
const APPLICATION: &str = "outline-cli";
const CONTENT_TYPE: &str = "text/plain";
fn attributes(base_url: &str) -> HashMap<&str, &str> {
HashMap::from([
("xdg:schema", SCHEMA),
("application", APPLICATION),
("base_url", base_url),
])
}
async fn connect() -> Result<SecretService<'static>> {
SecretService::connect(EncryptionType::Dh).await.context(
"could not reach a Secret Service (is gnome-keyring or kwallet running? \
on headless systems, set OUTLINE_API_KEY instead)",
)
}
/// Stores `token` for `base_url`, replacing any previously stored token for
/// the same instance.
pub async fn store(base_url: &str, token: &str) -> Result<()> {
let service = connect().await?;
let collection = service
.get_default_collection()
.await
.context("no default keyring collection")?;
collection
.ensure_unlocked()
.await
.context("the login keyring is locked and could not be unlocked")?;
// `create_item(replace = true)` only replaces an item whose attribute set
// matches *exactly*; delete any existing match first to avoid duplicates.
for item in collection.search_items(attributes(base_url)).await? {
item.delete().await?;
}
let label = format!("Outline API token ({base_url})");
collection
.create_item(
&label,
attributes(base_url),
token.as_bytes(),
true,
CONTENT_TYPE,
)
.await
.context("failed to write the token to the keyring")?;
Ok(())
}
/// Loads the token stored for `base_url`, if any.
pub async fn load(base_url: &str) -> Result<Option<String>> {
let service = connect().await?;
let found = service.search_items(attributes(base_url)).await?;
let item = match (found.unlocked.first(), found.locked.first()) {
(Some(item), _) => item,
(None, Some(item)) => {
item.unlock().await?;
item
}
(None, None) => return Ok(None),
};
let secret = item
.get_secret()
.await
.context("failed to read the token from the keyring")?;
Ok(Some(
String::from_utf8(secret).context("stored token is not valid UTF-8")?,
))
}
+49
View File
@@ -0,0 +1,49 @@
//! Writes a rendered page to disk and opens it in the default browser.
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
/// Writes `html` into `cache_dir/<slug>.html` with restrictive permissions
/// (`0600`, directory `0700`): documents may be confidential and `/tmp` is
/// world-readable. The name is deterministic per document so re-opening it
/// overwrites instead of littering the cache directory.
pub fn write_page(cache_dir: &Path, slug: &str, html: &str) -> Result<PathBuf> {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(cache_dir)
.with_context(|| format!("failed to create {}", cache_dir.display()))?;
let path = cache_dir.join(format!("{}.html", sanitize(slug)));
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.with_context(|| format!("failed to write {}", path.display()))?;
file.write_all(html.as_bytes())?;
Ok(path)
}
/// Opens `path` in the user's default browser without blocking on it.
pub fn open_in_browser(path: &Path) -> Result<()> {
open::that_detached(path)
.with_context(|| format!("could not open {} in a browser", path.display()))
}
fn sanitize(slug: &str) -> String {
slug.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}