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
+6
-1
@@ -11,9 +11,14 @@
|
||||
import StatusBar from './lib/StatusBar.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/')
|
||||
? decodeURIComponent(location.pathname.slice(3))
|
||||
: null
|
||||
const shareKey = shareToken ? decodeURIComponent(location.hash.slice(1)) : ''
|
||||
|
||||
let cmdline = $state<string | null>(null)
|
||||
let fileInput = $state<HTMLInputElement>()
|
||||
@@ -196,7 +201,7 @@
|
||||
<svelte:window {onkeydown} />
|
||||
|
||||
{#if shareToken}
|
||||
<Shared token={shareToken} />
|
||||
<Shared token={shareToken} shareKey={shareKey} />
|
||||
{:else if diary.booting}
|
||||
<div class="boot faint">booting ~/diary…</div>
|
||||
{:else if !diary.user}
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
const words = $derived(
|
||||
(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>
|
||||
|
||||
<section class="pane">
|
||||
@@ -43,11 +48,11 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if shareUrl}
|
||||
{#if sharePath}
|
||||
<div class="sharebar">
|
||||
<span class="accent">◉ public</span>
|
||||
<a href={shareUrl} target="_blank" rel="noreferrer">{shareUrl}</a>
|
||||
<button onclick={() => runCommand('link')}>copy</button>
|
||||
<span class="faint">{sharePath}</span>
|
||||
<button onclick={() => runCommand('link')}>new link</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
['/', 'full-text search'],
|
||||
['n', 'clear the search'],
|
||||
['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)'],
|
||||
[':', 'command line'],
|
||||
['Ctrl-S', 'save, from anywhere'],
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
import { renderMarkdown } from './markdown'
|
||||
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 error = $state('')
|
||||
|
||||
$effect(() => {
|
||||
if (!shareKey) {
|
||||
error = 'this link is incomplete — the part after # is missing from it.'
|
||||
return
|
||||
}
|
||||
api
|
||||
.readShared(token)
|
||||
.readShared(token, shareKey)
|
||||
.then((data) => {
|
||||
entry = data
|
||||
document.title = data.title.trim() || `~/diary ${formatStamp(data.created_at)}`
|
||||
@@ -30,7 +34,7 @@
|
||||
</div>
|
||||
{#if entry.title.trim()}<h1>{entry.title}</h1>{/if}
|
||||
<!-- 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>
|
||||
<footer class="faint">— written in ~/diary</footer>
|
||||
{:else}
|
||||
|
||||
+5
-2
@@ -105,7 +105,9 @@ export const api = {
|
||||
|
||||
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' }),
|
||||
|
||||
@@ -123,5 +125,6 @@ export const api = {
|
||||
|
||||
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
|
||||
if (!id) return diary.say('no entry selected', 'error')
|
||||
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))
|
||||
},
|
||||
},
|
||||
@@ -298,12 +298,16 @@ export const commands: CommandSpec[] = [
|
||||
{
|
||||
name: 'link',
|
||||
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 () => {
|
||||
const token = diary.open?.share_token ?? diary.selected?.share_token
|
||||
if (!token) return diary.say('entry is not shared — :share first', 'error')
|
||||
const url = `${location.origin}/s/${token}`
|
||||
diary.say((await diary.copy(url)) ? `copied ${url}` : url)
|
||||
const id = diary.open?.id ?? diary.selected?.id
|
||||
if (!id) return diary.say('no entry selected', 'error')
|
||||
const entry = diary.open?.id === id ? diary.open : diary.selected
|
||||
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 })
|
||||
|
||||
/**
|
||||
* Render entry markdown to sanitised HTML. When a share token is given, media
|
||||
* URLs are rewritten to the public route so the page works for a reader who has
|
||||
* no session.
|
||||
* Render entry markdown to sanitised HTML. When share credentials are given,
|
||||
* media URLs are rewritten to the public route — token and key both, since that
|
||||
* 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 {
|
||||
const md = shareToken
|
||||
? source.replaceAll('/api/media/', `/api/share/${encodeURIComponent(shareToken)}/media/`)
|
||||
export function renderMarkdown(source: string, share?: { token: string; key: string }): string {
|
||||
const md = share
|
||||
? source.replaceAll(
|
||||
'/api/media/',
|
||||
`/api/share/${encodeURIComponent(share.token)}/${encodeURIComponent(share.key)}/media/`,
|
||||
)
|
||||
: source
|
||||
|
||||
const html = marked.parse(md, { async: false }) as string
|
||||
|
||||
@@ -257,15 +257,26 @@ class Diary {
|
||||
await api.unshare(id)
|
||||
this.say(`entry #${id} is private again`)
|
||||
} else {
|
||||
const { token } = await api.share(id)
|
||||
const url = `${location.origin}/s/${token}`
|
||||
await this.copy(url)
|
||||
const url = await this.mintShareLink(id)
|
||||
this.say(`shared → ${url} (copied)`)
|
||||
}
|
||||
if (this.open?.id === id) this.open = await api.getEntry(id)
|
||||
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) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
|
||||
Reference in new issue
Block a user