mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
feature(SHARE) added secret token to share to prevent getting crawled
This commit is contained in:
1 parent
7646ecc397
commit
76f0887ac5
12 files changed
+199
-67
No files matched your search
@@ -51,7 +51,7 @@ The desktop UI is modal. In the browse pane:
|
|||||||
| `i`, `a` | edit the open entry |
|
| `i`, `a` | edit the open entry |
|
||||||
| `Esc`, `h`, `q` | back out to the list |
|
| `Esc`, `h`, `q` | back out to the list |
|
||||||
| `/` | full-text search (`n` clears it) |
|
| `/` | full-text search (`n` clears it) |
|
||||||
| `s` / `y` | toggle sharing / copy the share link |
|
| `s` / `y` | toggle sharing / mint a new share link |
|
||||||
| `x`, `dd` | delete the entry |
|
| `x`, `dd` | delete the entry |
|
||||||
| `:` | command line |
|
| `:` | command line |
|
||||||
| `Ctrl-S` | save from anywhere |
|
| `Ctrl-S` | save from anywhere |
|
||||||
@@ -143,11 +143,25 @@ backup at all.
|
|||||||
|
|
||||||
## Sharing
|
## Sharing
|
||||||
|
|
||||||
`:share` mints a 192-bit random token and copies `https://your-host/s/<token>`.
|
`:share` mints two 192-bit random secrets and copies
|
||||||
That page needs no session, renders the entry read-only, and serves only the
|
`https://your-host/s/<token>#<key>`. That page needs no session, renders the
|
||||||
media files that entry currently embeds — dropping a picture out of a shared
|
entry read-only, and serves only the media files that entry currently embeds —
|
||||||
entry immediately makes it unreachable through the link. `:unshare` destroys the
|
dropping a picture out of a shared entry immediately makes it unreachable
|
||||||
token, and a new `:share` mints a different one.
|
through the link.
|
||||||
|
|
||||||
|
The two halves do different jobs. The token names the entry; the key proves the
|
||||||
|
reader was given the whole link. A browser never sends a fragment to the server,
|
||||||
|
so the key stays on the reader's machine and out of every access log, referrer
|
||||||
|
and link preview along the way — a crawler or a chat client that harvested only
|
||||||
|
the path holds a URL that answers `404`. The server stores nothing but a
|
||||||
|
SHA-256 of the key and compares in constant time, and a wrong key is answered
|
||||||
|
exactly like an unknown token, so guessing never confirms that a token is live.
|
||||||
|
|
||||||
|
Because only the hash is kept, a link exists in full exactly once: in the moment
|
||||||
|
`:share` copies it. It cannot be re-read from the entry afterwards. `:link`
|
||||||
|
mints a fresh key for the same token — which retires the previous link, making
|
||||||
|
it the way to cut off a reader without unpublishing — and `:unshare` destroys
|
||||||
|
both halves.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@@ -161,11 +175,11 @@ Everything except the two share routes requires the session cookie.
|
|||||||
| `GET`/`PUT`/`DELETE` | `/api/entries/{id}` | read / update / delete |
|
| `GET`/`PUT`/`DELETE` | `/api/entries/{id}` | read / update / delete |
|
||||||
| `GET` | `/api/export` | every entry and file, as a zip |
|
| `GET` | `/api/export` | every entry and file, as a zip |
|
||||||
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
|
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now |
|
||||||
| `POST`/`DELETE` | `/api/entries/{id}/share` | mint / revoke a share token |
|
| `POST`/`DELETE` | `/api/entries/{id}/share` | mint or rotate / revoke a share link |
|
||||||
| `GET`/`POST` | `/api/media` | list / upload (multipart) |
|
| `GET`/`POST` | `/api/media` | list / upload (multipart) |
|
||||||
| `GET`/`DELETE` | `/api/media/{id}` | serve / delete a file |
|
| `GET`/`DELETE` | `/api/media/{id}` | serve / delete a file |
|
||||||
| `GET` | `/api/share/{token}` | public: read a shared entry |
|
| `GET` | `/api/share/{token}/{key}` | public: read a shared entry |
|
||||||
| `GET` | `/api/share/{token}/media/{id}` | public: media inside a shared entry |
|
| `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared entry |
|
||||||
|
|
||||||
## Deploying
|
## Deploying
|
||||||
|
|
||||||
@@ -177,6 +191,8 @@ The app sends its own `Content-Security-Policy`, `Referrer-Policy: no-referrer`,
|
|||||||
`X-Content-Type-Options` and `X-Frame-Options`; a proxy that adds its own should
|
`X-Content-Type-Options` and `X-Frame-Options`; a proxy that adds its own should
|
||||||
not weaken them. `no-referrer` matters in particular, because a share token
|
not weaken them. `no-referrer` matters in particular, because a share token
|
||||||
lives in the URL and would otherwise leak to any site a shared entry links to.
|
lives in the URL and would otherwise leak to any site a shared entry links to.
|
||||||
|
Share links minted before the key existed are revoked by migration `0005`;
|
||||||
|
re-run `:share` on those entries to publish them again.
|
||||||
Uploaded files are only ever served as types that cannot execute — anything else
|
Uploaded files are only ever served as types that cannot execute — anything else
|
||||||
is handed back as an opaque download.
|
is handed back as an opaque download.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Shared links gain a secret that rides in the URL fragment: /s/<token>#<key>.
|
||||||
|
-- Browsers never send a fragment to the origin, so a crawler that harvests the
|
||||||
|
-- link's path alone still cannot read the entry. The server keeps only the
|
||||||
|
-- key's hash; the plaintext exists once, in the link the author copied.
|
||||||
|
--
|
||||||
|
-- Links minted before this migration have no key, and there is no way to give
|
||||||
|
-- them one without leaving a permanent keyless bypass, so they are revoked.
|
||||||
|
ALTER TABLE entries ADD COLUMN share_key_hash TEXT;
|
||||||
|
|
||||||
|
UPDATE entries SET share_token = NULL WHERE share_token IS NOT NULL;
|
||||||
+2
-2
@@ -27,6 +27,6 @@ pub fn api_router() -> Router<AppState> {
|
|||||||
.route("/entries/{id}/share", delete(share::disable))
|
.route("/entries/{id}/share", delete(share::disable))
|
||||||
.route("/media", post(media::upload).get(media::list))
|
.route("/media", post(media::upload).get(media::list))
|
||||||
.route("/media/{id}", get(media::serve).delete(media::remove))
|
.route("/media/{id}", get(media::serve).delete(media::remove))
|
||||||
.route("/share/{token}", get(share::read))
|
.route("/share/{token}/{key}", get(share::read))
|
||||||
.route("/share/{token}/media/{id}", get(share::serve_media))
|
.route("/share/{token}/{key}/media/{id}", get(share::serve_media))
|
||||||
}
|
}
|
||||||
+100
-30
@@ -7,7 +7,9 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine};
|
|||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::Session,
|
auth::Session,
|
||||||
@@ -25,39 +27,63 @@ pub struct SharedEntry {
|
|||||||
pub token: String,
|
pub token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new_token() -> String {
|
fn random_secret() -> String {
|
||||||
let mut bytes = [0u8; 24];
|
let mut bytes = [0u8; 24];
|
||||||
rand::thread_rng().fill_bytes(&mut bytes);
|
rand::thread_rng().fill_bytes(&mut bytes);
|
||||||
B64.encode(bytes)
|
B64.encode(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn hash_key(key: &str) -> String {
|
||||||
|
B64.encode(Sha256::digest(key.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time check of a presented key against the stored hash. A share with
|
||||||
|
/// no hash at all is unreadable rather than open: that combination should not
|
||||||
|
/// exist, and failing closed is the safe reading of it.
|
||||||
|
fn key_matches(stored: Option<&str>, presented: &str) -> bool {
|
||||||
|
let Some(stored) = stored else { return false };
|
||||||
|
bool::from(hash_key(presented).as_bytes().ct_eq(stored.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish an entry, or mint a fresh key for one that is already published.
|
||||||
|
///
|
||||||
|
/// The plaintext key is returned exactly once, here. Nothing but its hash is
|
||||||
|
/// stored, so a link that the author loses cannot be recovered — only replaced,
|
||||||
|
/// which is what calling this again does. Rotating the key kills the old link
|
||||||
|
/// while leaving the token, so a revoked reader loses access even if they kept
|
||||||
|
/// the path.
|
||||||
pub async fn enable(
|
pub async fn enable(
|
||||||
_: Session,
|
_: Session,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
let existing: Option<Option<String>> = sqlx::query("SELECT share_token FROM entries WHERE id = ?1")
|
let existing: Option<Option<String>> =
|
||||||
.bind(id)
|
sqlx::query("SELECT share_token FROM entries WHERE id = ?1")
|
||||||
.fetch_optional(&state.db)
|
.bind(id)
|
||||||
.await?
|
.fetch_optional(&state.db)
|
||||||
.map(|row| row.get("share_token"));
|
.await?
|
||||||
|
.map(|row| row.get("share_token"));
|
||||||
|
|
||||||
let token = match existing {
|
let token = match existing {
|
||||||
None => return Err(AppError::NotFound),
|
None => return Err(AppError::NotFound),
|
||||||
Some(Some(token)) => token,
|
Some(Some(token)) => token,
|
||||||
Some(None) => {
|
Some(None) => random_secret(),
|
||||||
let token = new_token();
|
|
||||||
sqlx::query("UPDATE entries SET share_token = ?1 WHERE id = ?2")
|
|
||||||
.bind(&token)
|
|
||||||
.bind(id)
|
|
||||||
.execute(&state.db)
|
|
||||||
.await?;
|
|
||||||
token
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
let key = random_secret();
|
||||||
|
|
||||||
|
sqlx::query("UPDATE entries SET share_token = ?1, share_key_hash = ?2 WHERE id = ?3")
|
||||||
|
.bind(&token)
|
||||||
|
.bind(hash_key(&key))
|
||||||
|
.bind(id)
|
||||||
|
.execute(&state.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
state.backup.signal();
|
state.backup.signal();
|
||||||
Ok(Json(json!({ "token": token, "path": format!("/s/{token}") })))
|
Ok(Json(json!({
|
||||||
|
"token": token,
|
||||||
|
"key": key,
|
||||||
|
"path": format!("/s/{token}#{key}"),
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn disable(
|
pub async fn disable(
|
||||||
@@ -65,11 +91,12 @@ pub async fn disable(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> AppResult<Json<serde_json::Value>> {
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
let affected = sqlx::query("UPDATE entries SET share_token = NULL WHERE id = ?1")
|
let affected =
|
||||||
.bind(id)
|
sqlx::query("UPDATE entries SET share_token = NULL, share_key_hash = NULL WHERE id = ?1")
|
||||||
.execute(&state.db)
|
.bind(id)
|
||||||
.await?
|
.execute(&state.db)
|
||||||
.rows_affected();
|
.await?
|
||||||
|
.rows_affected();
|
||||||
if affected == 0 {
|
if affected == 0 {
|
||||||
return Err(AppError::NotFound);
|
return Err(AppError::NotFound);
|
||||||
}
|
}
|
||||||
@@ -77,19 +104,28 @@ pub async fn disable(
|
|||||||
Ok(Json(json!({ "ok": true })))
|
Ok(Json(json!({ "ok": true })))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public: no session required, the unguessable token is the credential.
|
/// Public: no session required. The credential is the pair — the token from the
|
||||||
|
/// link's path and the key the reader's browser lifted out of its fragment.
|
||||||
pub async fn read(
|
pub async fn read(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(token): Path<String>,
|
Path((token, key)): Path<(String, String)>,
|
||||||
) -> AppResult<Json<SharedEntry>> {
|
) -> AppResult<Json<SharedEntry>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT title, body, created_at, updated_at FROM entries WHERE share_token = ?1",
|
"SELECT title, body, created_at, updated_at, share_key_hash
|
||||||
|
FROM entries WHERE share_token = ?1",
|
||||||
)
|
)
|
||||||
.bind(&token)
|
.bind(&token)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(AppError::NotFound)?;
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
|
let stored: Option<String> = row.get("share_key_hash");
|
||||||
|
if !key_matches(stored.as_deref(), &key) {
|
||||||
|
// Deliberately the same answer as an unknown token: a wrong key must not
|
||||||
|
// confirm that the token itself is live.
|
||||||
|
return Err(AppError::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(SharedEntry {
|
Ok(Json(SharedEntry {
|
||||||
title: row.get("title"),
|
title: row.get("title"),
|
||||||
body: row.get("body"),
|
body: row.get("body"),
|
||||||
@@ -99,13 +135,14 @@ pub async fn read(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Public: serves a media file only while it is embedded in a shared entry.
|
/// Public: serves a media file only while it is embedded in a shared entry, and
|
||||||
|
/// only to a caller holding that entry's key.
|
||||||
pub async fn serve_media(
|
pub async fn serve_media(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((token, id)): Path<(String, String)>,
|
Path((token, key, id)): Path<(String, String, String)>,
|
||||||
) -> AppResult<Response> {
|
) -> AppResult<Response> {
|
||||||
let allowed = sqlx::query(
|
let stored: Option<String> = sqlx::query(
|
||||||
"SELECT 1 AS ok
|
"SELECT e.share_key_hash
|
||||||
FROM entry_media em
|
FROM entry_media em
|
||||||
JOIN entries e ON e.id = em.entry_id
|
JOIN entries e ON e.id = em.entry_id
|
||||||
WHERE em.media_id = ?1 AND e.share_token = ?2",
|
WHERE em.media_id = ?1 AND e.share_token = ?2",
|
||||||
@@ -114,10 +151,43 @@ pub async fn serve_media(
|
|||||||
.bind(&token)
|
.bind(&token)
|
||||||
.fetch_optional(&state.db)
|
.fetch_optional(&state.db)
|
||||||
.await?
|
.await?
|
||||||
.is_some();
|
.and_then(|row| row.get("share_key_hash"));
|
||||||
|
|
||||||
if !allowed {
|
if !key_matches(stored.as_deref(), &key) {
|
||||||
return Err(AppError::NotFound);
|
return Err(AppError::NotFound);
|
||||||
}
|
}
|
||||||
media::stream_media(&state, &id).await
|
media::stream_media(&state, &id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{hash_key, key_matches, random_secret};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_key_matches_only_its_own_hash() {
|
||||||
|
let key = random_secret();
|
||||||
|
assert!(key_matches(Some(&hash_key(&key)), &key));
|
||||||
|
assert!(!key_matches(Some(&hash_key(&key)), &random_secret()));
|
||||||
|
assert!(!key_matches(Some(&hash_key(&key)), ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_stored_hash_is_not_the_key() {
|
||||||
|
let key = random_secret();
|
||||||
|
let stored = hash_key(&key);
|
||||||
|
assert_ne!(stored, key);
|
||||||
|
// The hash itself must not open the link either.
|
||||||
|
assert!(!key_matches(Some(&stored), &stored));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_share_without_a_hash_stays_shut() {
|
||||||
|
assert!(!key_matches(None, &random_secret()));
|
||||||
|
assert!(!key_matches(None, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secrets_do_not_repeat() {
|
||||||
|
assert_ne!(random_secret(), random_secret());
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-1
@@ -11,9 +11,14 @@
|
|||||||
import StatusBar from './lib/StatusBar.svelte'
|
import StatusBar from './lib/StatusBar.svelte'
|
||||||
import { diary } from './lib/store.svelte'
|
import { diary } from './lib/store.svelte'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A share link is /s/<token>#<key>. The fragment never leaves the browser, so
|
||||||
|
* a crawler that only scraped the path cannot ask the server for the entry.
|
||||||
|
*/
|
||||||
const shareToken = location.pathname.startsWith('/s/')
|
const shareToken = location.pathname.startsWith('/s/')
|
||||||
? decodeURIComponent(location.pathname.slice(3))
|
? decodeURIComponent(location.pathname.slice(3))
|
||||||
: null
|
: null
|
||||||
|
const shareKey = shareToken ? decodeURIComponent(location.hash.slice(1)) : ''
|
||||||
|
|
||||||
let cmdline = $state<string | null>(null)
|
let cmdline = $state<string | null>(null)
|
||||||
let fileInput = $state<HTMLInputElement>()
|
let fileInput = $state<HTMLInputElement>()
|
||||||
@@ -196,7 +201,7 @@
|
|||||||
<svelte:window {onkeydown} />
|
<svelte:window {onkeydown} />
|
||||||
|
|
||||||
{#if shareToken}
|
{#if shareToken}
|
||||||
<Shared token={shareToken} />
|
<Shared token={shareToken} shareKey={shareKey} />
|
||||||
{:else if diary.booting}
|
{:else if diary.booting}
|
||||||
<div class="boot faint">booting ~/diary…</div>
|
<div class="boot faint">booting ~/diary…</div>
|
||||||
{:else if !diary.user}
|
{:else if !diary.user}
|
||||||
|
|||||||
@@ -9,7 +9,12 @@
|
|||||||
const words = $derived(
|
const words = $derived(
|
||||||
(diary.editing ? diary.draft.body : entry.body).split(/\s+/).filter(Boolean).length,
|
(diary.editing ? diary.draft.body : entry.body).split(/\s+/).filter(Boolean).length,
|
||||||
)
|
)
|
||||||
const shareUrl = $derived(entry.share_token ? `${location.origin}/s/${entry.share_token}` : null)
|
/*
|
||||||
|
* Only the token half of a share link is recoverable — the key lives in the
|
||||||
|
* fragment, and the server kept nothing but its hash — so the bar shows the
|
||||||
|
* link's shape and offers a fresh one rather than a copy of the old.
|
||||||
|
*/
|
||||||
|
const sharePath = $derived(entry.share_token ? `${location.origin}/s/${entry.share_token}#…` : null)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="pane">
|
<section class="pane">
|
||||||
@@ -43,11 +48,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if shareUrl}
|
{#if sharePath}
|
||||||
<div class="sharebar">
|
<div class="sharebar">
|
||||||
<span class="accent">◉ public</span>
|
<span class="accent">◉ public</span>
|
||||||
<a href={shareUrl} target="_blank" rel="noreferrer">{shareUrl}</a>
|
<span class="faint">{sharePath}</span>
|
||||||
<button onclick={() => runCommand('link')}>copy</button>
|
<button onclick={() => runCommand('link')}>new link</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
['/', 'full-text search'],
|
['/', 'full-text search'],
|
||||||
['n', 'clear the search'],
|
['n', 'clear the search'],
|
||||||
['s', 'toggle sharing for the entry'],
|
['s', 'toggle sharing for the entry'],
|
||||||
['y', 'copy the share link'],
|
['y', 'mint a new share link and copy it'],
|
||||||
['x · dd', 'delete the entry (asks first)'],
|
['x · dd', 'delete the entry (asks first)'],
|
||||||
[':', 'command line'],
|
[':', 'command line'],
|
||||||
['Ctrl-S', 'save, from anywhere'],
|
['Ctrl-S', 'save, from anywhere'],
|
||||||
|
|||||||
@@ -3,14 +3,18 @@
|
|||||||
import { renderMarkdown } from './markdown'
|
import { renderMarkdown } from './markdown'
|
||||||
import { formatStamp, formatWeekday } from './util'
|
import { formatStamp, formatWeekday } from './util'
|
||||||
|
|
||||||
const { token }: { token: string } = $props()
|
const { token, shareKey }: { token: string; shareKey: string } = $props()
|
||||||
|
|
||||||
let entry = $state<SharedEntry | null>(null)
|
let entry = $state<SharedEntry | null>(null)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
if (!shareKey) {
|
||||||
|
error = 'this link is incomplete — the part after # is missing from it.'
|
||||||
|
return
|
||||||
|
}
|
||||||
api
|
api
|
||||||
.readShared(token)
|
.readShared(token, shareKey)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
entry = data
|
entry = data
|
||||||
document.title = data.title.trim() || `~/diary ${formatStamp(data.created_at)}`
|
document.title = data.title.trim() || `~/diary ${formatStamp(data.created_at)}`
|
||||||
@@ -30,7 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if entry.title.trim()}<h1>{entry.title}</h1>{/if}
|
{#if entry.title.trim()}<h1>{entry.title}</h1>{/if}
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
|
||||||
{@html renderMarkdown(entry.body, token)}
|
{@html renderMarkdown(entry.body, { token, key: shareKey })}
|
||||||
</article>
|
</article>
|
||||||
<footer class="faint">— written in ~/diary</footer>
|
<footer class="faint">— written in ~/diary</footer>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
+5
-2
@@ -105,7 +105,9 @@ export const api = {
|
|||||||
|
|
||||||
deleteEntry: (id: number) => request<{ ok: true }>(`/entries/${id}`, { method: 'DELETE' }),
|
deleteEntry: (id: number) => request<{ ok: true }>(`/entries/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
share: (id: number) => request<{ token: string; path: string }>(`/entries/${id}/share`, { method: 'POST' }),
|
/** Publishes the entry, or mints a fresh key for one already published. */
|
||||||
|
share: (id: number) =>
|
||||||
|
request<{ token: string; key: string; path: string }>(`/entries/${id}/share`, { method: 'POST' }),
|
||||||
|
|
||||||
unshare: (id: number) => request<{ ok: true }>(`/entries/${id}/share`, { method: 'DELETE' }),
|
unshare: (id: number) => request<{ ok: true }>(`/entries/${id}/share`, { method: 'DELETE' }),
|
||||||
|
|
||||||
@@ -123,5 +125,6 @@ export const api = {
|
|||||||
|
|
||||||
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
|
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
|
||||||
|
|
||||||
readShared: (token: string) => request<SharedEntry>(`/share/${encodeURIComponent(token)}`),
|
readShared: (token: string, key: string) =>
|
||||||
|
request<SharedEntry>(`/share/${encodeURIComponent(token)}/${encodeURIComponent(key)}`),
|
||||||
}
|
}
|
||||||
+10
-6
@@ -279,7 +279,7 @@ export const commands: CommandSpec[] = [
|
|||||||
const id = diary.open?.id ?? diary.selected?.id
|
const id = diary.open?.id ?? diary.selected?.id
|
||||||
if (!id) return diary.say('no entry selected', 'error')
|
if (!id) return diary.say('no entry selected', 'error')
|
||||||
const entry = diary.open?.id === id ? diary.open : diary.selected
|
const entry = diary.open?.id === id ? diary.open : diary.selected
|
||||||
if (entry?.shared) return diary.say('already shared — :link to copy, :unshare to revoke')
|
if (entry?.shared) return diary.say('already shared — :link for a new link, :unshare to revoke')
|
||||||
await diary.guard(() => diary.toggleShare(id))
|
await diary.guard(() => diary.toggleShare(id))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -298,12 +298,16 @@ export const commands: CommandSpec[] = [
|
|||||||
{
|
{
|
||||||
name: 'link',
|
name: 'link',
|
||||||
group: 'sharing',
|
group: 'sharing',
|
||||||
help: 'copy the share link of the current entry',
|
help: 'mint a new share link for the entry and copy it',
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const token = diary.open?.share_token ?? diary.selected?.share_token
|
const id = diary.open?.id ?? diary.selected?.id
|
||||||
if (!token) return diary.say('entry is not shared — :share first', 'error')
|
if (!id) return diary.say('no entry selected', 'error')
|
||||||
const url = `${location.origin}/s/${token}`
|
const entry = diary.open?.id === id ? diary.open : diary.selected
|
||||||
diary.say((await diary.copy(url)) ? `copied ${url}` : url)
|
if (!entry?.shared) return diary.say('entry is not shared — :share first', 'error')
|
||||||
|
await diary.guard(async () => {
|
||||||
|
const url = await diary.mintShareLink(id)
|
||||||
|
diary.say(`new link → ${url} (copied) — the previous one is dead`)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+10
-6
@@ -4,13 +4,17 @@ import { marked } from 'marked'
|
|||||||
marked.setOptions({ gfm: true, breaks: true })
|
marked.setOptions({ gfm: true, breaks: true })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render entry markdown to sanitised HTML. When a share token is given, media
|
* Render entry markdown to sanitised HTML. When share credentials are given,
|
||||||
* URLs are rewritten to the public route so the page works for a reader who has
|
* media URLs are rewritten to the public route — token and key both, since that
|
||||||
* no session.
|
* route demands the same pair the entry itself did — so the page works for a
|
||||||
|
* reader who has no session.
|
||||||
*/
|
*/
|
||||||
export function renderMarkdown(source: string, shareToken?: string): string {
|
export function renderMarkdown(source: string, share?: { token: string; key: string }): string {
|
||||||
const md = shareToken
|
const md = share
|
||||||
? source.replaceAll('/api/media/', `/api/share/${encodeURIComponent(shareToken)}/media/`)
|
? source.replaceAll(
|
||||||
|
'/api/media/',
|
||||||
|
`/api/share/${encodeURIComponent(share.token)}/${encodeURIComponent(share.key)}/media/`,
|
||||||
|
)
|
||||||
: source
|
: source
|
||||||
|
|
||||||
const html = marked.parse(md, { async: false }) as string
|
const html = marked.parse(md, { async: false }) as string
|
||||||
|
|||||||
@@ -257,15 +257,26 @@ class Diary {
|
|||||||
await api.unshare(id)
|
await api.unshare(id)
|
||||||
this.say(`entry #${id} is private again`)
|
this.say(`entry #${id} is private again`)
|
||||||
} else {
|
} else {
|
||||||
const { token } = await api.share(id)
|
const url = await this.mintShareLink(id)
|
||||||
const url = `${location.origin}/s/${token}`
|
|
||||||
await this.copy(url)
|
|
||||||
this.say(`shared → ${url} (copied)`)
|
this.say(`shared → ${url} (copied)`)
|
||||||
}
|
}
|
||||||
if (this.open?.id === id) this.open = await api.getEntry(id)
|
if (this.open?.id === id) this.open = await api.getEntry(id)
|
||||||
await this.refresh()
|
await this.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The server keeps only a hash of the key that lives in the link's fragment,
|
||||||
|
* so a link exists in full exactly once: here, in the moment it is minted.
|
||||||
|
* There is nothing to re-copy later — asking again mints a new link and
|
||||||
|
* retires the old one.
|
||||||
|
*/
|
||||||
|
async mintShareLink(id: number) {
|
||||||
|
const { path } = await api.share(id)
|
||||||
|
const url = `${location.origin}${path}`
|
||||||
|
await this.copy(url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
async copy(text: string) {
|
async copy(text: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
|
|||||||
Reference in new issue
Block a user