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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
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",
|
||||
" [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""#)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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")?,
|
||||
))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user