8000 fix(clipper): sign in, or use your API token, not only an import webhook's by adeelahmad · Pull Request #406 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ The backend (`fastapi_backend`) and the frontend (`nextjs-frontend`) are version
shares; then a new access key's dialog gives those addresses. Signing in is the same: a person's own access key,
read-only. MQTT, syslog, SMB and NFS aren't HTTP and stay on their own ports
([The graph as files](docs/file-tree.md#at-the-web-apps-address), [Remote access](docs/remote-access.md#what-it-reaches)).
- **The web clipper takes your API token, or just signs in.** It used to take only an import webhook's token, so an API
token was refused with "Lens didn't recognise this token: make a new import webhook and paste its token". Now its
options have **Sign in**: Lens asks you to allow the clipper, nothing to copy, and clips go into the namespace you
pick (the popup offers each one you can add to). **Use a token instead** takes an API token with write access or a
hook's token, and when Lens refuses one the clipper says why: unknown, revoked, read-only, or a Lens too old for
API tokens. Anything acting as you (an agent, a script) can push to `POST /api/v1/hooks/import?namespace=` with
your own token too, and `GET` there says where a token's pushes go ([Web clipper](docs/web-clipper.md#setting-it-up),
[API](docs/api.md#import-webhooks)).
- **Versions in conversations.** A conversation is now a tree: editing a question makes a new version beside it
instead of deleting what followed, regenerating an answer (`regenerate`, with the same model or another) writes one
beside it, and a question can go on from any message (`parent`). `GET /api/v1/chats/<id>` shows each message's
Expand Down
21 changes: 18 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1033,6 +1033,7 @@ POST /api/v1/namespaces/{name}/import-hooks
PATCH /api/v1/namespaces/{name}/import-hooks/{hid}
POST /api/v1/namespaces/{name}/import-hooks/{hid}/token
DELETE /api/v1/namespaces/{name}/import-hooks/{hid}
GET /api/v1/hooks/import
POST /api/v1/hooks/import
POST /api/v1/hooks/import/{token}
```
Expand All @@ -1053,14 +1054,28 @@ a URL; the address form ends up in proxy logs, so prefer the header). Three ways
- **JSON** `{url?, text?, title?}`: a web page or PDF kept as a document, as [web pages](#web-pages) are (a page needs
Chromium on the server; a link to a PDF doesn't), and text imported as pasted text is.

The answer (202) is `{namespace, items: [{kind, name, recording, job, duplicate}]}`. 401 for a token that isn't a hook's,
403 while the hook is paused, 413 over `uploads.max_mb`, 507 when the disk can't hold the file. Each item is audited as
`import.hook`, by `import_hook:<id>` rather than a person.
The answer (202) is `{namespace, items: [{kind, name, recording, job, duplicate}]}`. 401 for a token Lens doesn't know,
saying which kind it took it for (a hook's, an API token, a sign-in), 403 while the hook is paused, 413 over
`uploads.max_mb`, 507 when the disk can't hold the file. Each item is audited as `import.hook`, by `import_hook:<id>`
rather than a person.

**Your own token works there too**, for the [web clipper](web-clipper.md), an agent or a script acting as you: an API
token with write access (`la_...`), an app's token (`lo_...`, with the write scope) or the web app's own, with
`?namespace=` naming one you can import into (the import permission; editors have it). Without `?namespace=`, it's
the only namespace you can import into, else 400 listing them. A read-only token gets 403. What you push this way is
yours, audited as `import.push`, and isn't counted on any hook. Your own token only goes in the `Authorization`
header: the address form takes hook tokens only.

`GET /api/v1/hooks/import` says where a token's pushes go without importing anything:
`{kind: "hook" | "person", hook, email, namespace, namespaces, write}`. For a hook's token, `namespace` is the hook's;
for yours, `namespaces` lists every namespace you can import into and `namespace` is the one used without
`?namespace=` (null when there's a choice), and `write` is false for a read-only key.

```
curl -H "Authorization: Bearer $TOKEN" --data-binary @minutes.pdf "https://lens.example/api/v1/hooks/import?filename=minutes.pdf"
curl -H "Authorization: Bearer $TOKEN" -F file=@call.m4a -F title="Call with Sam" https://lens.example/api/v1/hooks/import
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"url": "https://example.org/report.pdf"}' https://lens.example/api/v1/hooks/import
curl -H "Authorization: Bearer $LENS_API_TOKEN" -H "Content-Type: application/json" -d '{"text": "Notes", "title": "Notes"}' "https://lens.example/api/v1/hooks/import?namespace=pods"
```

### Uploads
Expand Down
28 changes: 20 additions & 8 deletions docs/web-clipper.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,26 @@ tables, strikethrough and task lists become GitHub-style Markdown.

## Setting it up

1. In Lens, open a namespace's admin page and make an **import webhook** (see [the API](api.md), Import webhooks).
Copy its token: it's shown once.
2. In the clipper's options, enter your Lens address and the token and press **Add and check**. The browser asks to
let the clipper talk to that address; the clipper then checks the token without importing anything.

Add one per namespace you clip into, and pick between them in the popup. The tokens stay on this device; every other
setting syncs through your browser account. Plain `http://` addresses are only accepted on this computer or the local
network.
In the clipper's options, enter your Lens address and press **Sign in** (or <kbd>Enter</kbd>). The browser asks to let
the clipper talk to that address, then Lens shows its own page asking you (signed in with your passkey there) to allow
the clipper. That's all: nothing to copy or paste. Clips go into the namespace you pick in the options, and the popup
lets you pick another one you can add to. Signing in here signs the assistant's side panel in too, and the other way
round. The sign-in renews itself; removing the clipper's access in Lens, or signing out in the side panel, ends it.

Instead of signing in, **Use a token instead** takes either of these, and the clipper tells them apart:

- **Your API token** (`la_...`), made in Lens under Settings, API keys, with write access. Clips go into the namespace
you pick, as if you'd added them yourself. A read-only key can't send clips, and the clipper says so.
- **An import webhook's token** (`lih_...`), from a namespace's admin page (see [the API](api.md), Import webhooks).
Clips go into that hook's namespace and are counted and audited as the hook's, not yours.

Safari can't show Lens's page from an extension, so there you paste your API token.

The clipper checks the sign-in or token without importing anything and says what's wrong if Lens refuses it: a token
Lens doesn't know (revoked, replaced, run out, or from another Lens), a read-only key, or a Lens too old to take API
tokens (update it, or use an import webhook's token). Add as many Lenses as you like; tokens and the sign-in stay on
this device, and every other setting syncs through your browser account. Plain `http://` addresses are only accepted
on this computer or the local network.

## Ways to clip

Expand Down
3 changes: 2 additions & 1 deletion extensions/browser/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Lens web clipper

A browser extension that clips pages, selections or any part of a page into Lens as Markdown, with the page's
metadata and images, through a namespace's import webhook. See [docs/web-clipper.md](../../docs/web-clipper.md).
metadata and images, signed in as you (or with your API token, or a namespace's import webhook token). See
[docs/web-clipper.md](../../docs/web-clipper.md).

```sh
npm ci && npm test && npm run build # then Load unpacked: dist/chrome
Expand Down
68 changes: 47 additions & 21 deletions extensions/browser/src/background.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// The extension's background: context menus, shortcuts, and every push to Lens (so a send finishes even when the
// popup closes). The assistant lives in the side panel (ui/sidepanel.js); this only opens it.
import { fetchImages, push } from "./core/lens.js";
import * as account from "./core/account.js";
import { fetchImages, push, pushLink } from "./core/lens.js";
import * as settingsStore from "./core/settings.js";

const ext = globalThis.browser ?? globalThis.chrome;
Expand Down Expand Up @@ -63,28 +64,55 @@ async function startPicker(tabId, intent) {
}

// ---------- sending ----------
async function send(clip, destinationId) {
const settings = await settingsStore.load();
const signInAgain = (dest) => new Error(`sign in to ${new URL(dest.server).host} again in the clipper's options`);

// The person's sign-in for a signed-in destination (renewed first when it's about to run out), else the token kept on
// this device for it.
async function credentials(dest, namespace) {
const where = { server: dest.server, namespace: settingsStore.asPerson(dest) ? (namespace || dest.namespace) || undefined : undefined };
if (dest.kind !== "signin") return { ...where, token: await settingsStore.token(dest.id) };
if ((await account.load())?.server !== dest.server) throw signInAgain(dest);
try {
return { ...where, token: await account.accessToken() };
} catch (e) {
throw e instanceof account.SignedOut ? signInAgain(dest) : e;
}
}

/** `fn` with a destination's credentials; a signed-in one renews its sign-in once when Lens says it has run out. */
async function withCredentials(dest, namespace, fn) {
const creds = await credentials(dest, namespace);
try {
return await fn(creds);
} catch (e) {
if (e.status !== 401 || dest.kind !== "signin") throw e;
let token;
try {
token = await account.accessToken({ force: true });
} catch (again) {
throw again instanceof account.SignedOut ? signInAgain(dest) : again;
}
return fn({ ...creds, token });
}
}

function chosen(settings, destinationId) {
const dest = settingsStore.destination(settings, destinationId);
if (!dest) throw new Error("Add your Lens in the clipper's options first");
const token = await settingsStore.token(dest.id);
return dest;
}

async function send(clip, destinationId, namespace) {
const settings = await settingsStore.load();
const dest = chosen(settings, destinationId);
const images = settings.sendImages && clip.images?.length ? await fetchImages(clip.images, { max: settings.maxImages }) : [];
const answer = await push({ server: dest.server, token }, clip, { images, captureUrl: settings.captureOnServer });
const answer = await withCredentials(dest, namespace, (creds) => push(creds, clip, { images, captureUrl: settings.captureOnServer }));
return { ...answer, server: dest.server, destination: dest.name };
}

async function sendLink(url, title) {
const settings = await settingsStore.load();
const dest = settingsStore.destination(settings);
if (!dest) throw new Error("Add your Lens in the clipper's options first");
const token = await settingsStore.token(dest.id);
const res = await fetch(`${dest.server}/api/v1/hooks/import`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify(title ? { url, title: title.slice(0, 200) } : { url }),
credentials: "omit",
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail ?? `Lens answered ${res.status}`);
const dest = chosen(await settingsStore.load());
await withCredentials(dest, undefined, (creds) => pushLink(creds, url, title));
}

// ---------- telling the user ----------
Expand Down Expand Up @@ -131,14 +159,12 @@ ext.contextMenus.onClicked.addListener(async (info, tab) => {
return flag(tab.id, true, "link sent; Lens captures it");
}
if (info.menuItemId === "clip-image") {
const settings = await settingsStore.load();
const dest = settingsStore.destination(settings);
if (!dest) throw new Error("Add your Lens in the clipper's options first");
const dest = chosen(await settingsStore.load());
const images = await fetchImages([info.srcUrl], { max: 1 });
if (!images.length) throw new Error("couldn't download that image");
const title = tab.title || info.srcUrl;
const markdown = `![](${info.srcUrl})\n\nFrom [${title}](${info.pageUrl})\n`;
await push({ server: dest.server, token: await settingsStore.token(dest.id) }, { title, markdown, url: info.pageUrl }, { images });
await withCredentials(dest, undefined, (creds) => push(creds, { title, markdown, url: info.pageUrl }, { images }));
return flag(tab.id, true, "image sent");
}
} catch (e) {
Expand Down Expand Up @@ -168,7 +194,7 @@ ext.runtime.onMessage.addListener((msg, sender, reply) => {
return false;
}
if (msg?.type === "send") {
send(msg.clip, msg.destination).then(
send(msg.clip, msg.destination, msg.namespace).then(
(answer) => reply({ ok: true, answer }),
(e) => reply({ ok: false, error: e.message }),
);
Expand Down
85 changes: 70 additions & 15 deletions extensions/browser/src/core/lens.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,46 @@
// Sending clips to a Lens server through its import webhooks (docs/api.md, Import webhooks): a namespace's owners make
// a hook in Lens, and its token is all the clipper needs.
// Sending clips to a Lens server's push address (docs/api.md, Import webhooks), with any of the tokens it takes: the
// person's own sign-in (account.js), an API token they made (la_...), or an import webhook's token (lih_...). With the
// person's own, the clip goes into the namespace they picked; a hook's goes into the hook's namespace.

export const PUSH_PATH = "/api/v1/hooks/import";
const MAX_FILES = 20; // the server takes at most 20 files a push
const OLD_HOOKS_ONLY = "that token isn't an import hook's"; // what a Lens from before personal tokens says

async function explain(res) {
/** What kind of token this is, by its prefix: "hook", "key" (an API token) or "" (a sign-in, or not one of Lens's). */
export const tokenKind = (token) => (/^lih_/.test(token ?? "") ? "hook" : /^la_/.test(token ?? "") ? "key" : "");

const UNKNOWN = {
hook: "Lens doesn't know this import webhook token: it was replaced or deleted. Sign in instead, or paste a new one",
key: "Lens doesn't know this API token: it was revoked, ran out, or is from another Lens. Sign in instead, or make a new one",
"": "Lens didn't recognise this sign-in: sign in again from the clipper's options",
};

async function explain(res, token) {
let detail = "";
try {
const body = await res.json();
detail = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail ?? body);
detail = body.detail == null ? "" : typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail);
} catch {
// not JSON
}
if (res.status === 401) return "Lens didn't recognise this token: make a new import webhook and paste its token";
if (res.status === 401) {
if (detail === OLD_HOOKS_ONLY && tokenKind(token) !== "hook") {
return "this Lens takes only import webhook tokens: update Lens to clip with your API token or by signing in";
}
return detail && detail !== OLD_HOOKS_ONLY ? detail : UNKNOWN[tokenKind(token)];
}
if (res.status === 403) return detail || "this import webhook is paused in Lens";
if (res.status === 413) return detail || "that's more than this Lens takes at once";
return detail || `Lens answered ${res.status} ${res.statusText}`;
}

const failed = async (res, token) => Object.assign(new Error(await explain(res, token)), { status: res.status });

/** The push address, with the namespace when the token is the person's own. */
export function pushUrl(server, namespace) {
return `${server}${PUSH_PATH}${namespace ? `?namespace=${encodeURIComponent(namespace)}` : ""}`;
}

const filenameFor = (src, i, type) => {
let name = "";
try {
Expand Down Expand Up @@ -54,8 +77,8 @@ export async function fetchImages(urls, { max = 10, fetchImpl = fetch } = {}) {
* Push a clip: its Markdown as text (Lens imports it like pasted text), plus, when asked, its images as files and its
* address for Lens to capture itself. Resolves to the server's answer: {namespace, items: [{kind, name, recording}]}.
*/
export async function push({ server, token }, { title, markdown, url }, { images = [], captureUrl = false, fetchImpl = fetch } = {}) {
if (!token) throw new Error("add this Lens's import webhook token in the clipper's options");
export async function push({ server, token, namespace }, { title, markdown, url }, { images = [], captureUrl = false, fetchImpl = fetch } = {}) {
if (!token) throw new Error("sign in to this Lens in the clipper's options");
const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };
let body;
if (!images.length && !captureUrl) {
Expand All @@ -70,30 +93,62 @@ export async function push({ server, token }, { title, markdown, url }, { images
}
let res;
try {
res = await fetchImpl(`${server}${PUSH_PATH}`, { method: "POST", headers, body, credentials: "omit" });
res = await fetchImpl(pushUrl(server, namespace), { method: "POST", headers, body, credentials: "omit" });
} catch (e) {
throw new Error(`couldn't reach ${server}: ${e.message}`);
}
if (!res.ok) throw new Error(await explain(res));
if (!res.ok) throw await failed(res, token);
return res.json();
}

/** Check an address and token without importing anything: an empty push is refused with 400 once the token is known. */
export async function check({ server, token }, { fetchImpl = fetch } = {}) {
/** Send a web address for Lens to capture itself (a PDF as it is, a page printed by Chromium on the full image). */
export async function pushLink({ server, token, namespace }, url, title, { fetchImpl = fetch } = {}) {
if (!token) throw new Error("sign in to this Lens in the clipper's options");
let res;
try {
res = await fetchImpl(`${server}${PUSH_PATH}`, {
res = await fetchImpl(pushUrl(server, namespace), {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json" },
body: "{}",
body: JSON.stringify(title ? { url, title: title.slice(0, 200) } : { url }),
credentials: "omit",
});
} catch (e) {
throw new Error(`couldn't reach ${server}: ${e.message}`);
}
if (!res.ok) throw await failed(res, token);
return res.json();
}

/**
* Check an address and token without importing anything. `target` is where clips go: {kind: "hook" | "person",
* namespace, namespaces, email, hook, write}. A Lens from before personal tokens has no GET there (405): an empty push
* is refused with 400 once its hook token is known.
*/
export async function check({ server, token }, { fetchImpl = fetch } = {}) {
const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };
let res;
try {
res = await fetchImpl(`${server}${PUSH_PATH}`, { headers, credentials: "omit" });
if (res.status === 405) {
res = await fetchImpl(`${server}${PUSH_PATH}`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: "{}",
credentials: "omit",
});
if (res.status === 400) return { ok: true, message: "Connected: clips will go into this hook's namespace", target: { kind: "hook" } };
}
} catch (e) {
return { ok: false, message: `couldn't reach ${server}: ${e.message}` };
}
if (res.status === 400) return { ok: true, message: "Connected: clips will go into this hook's namespace" };
if (res.status === 404) return { ok: false, message: "that address answers, but not as a Lens (no import webhooks there)" };
return { ok: false, message: await explain(res) };
if (!res.ok) return { ok: false, message: await explain(res, token) };
const target = await res.json();
if (target.kind === "hook") return { ok: true, message: `Connected: clips go into ${target.namespace}`, target };
if (!target.write) return { ok: false, message: "this API token is read-only: make one with write access in Lens, Settings → API keys", target };
if (!target.namespaces?.length) return { ok: false, message: "you can't add anything to a namespace in this Lens yet: ask an owner for the editor role", target };
const into = target.namespaces.length > 1 ? `${target.namespace ?? target.namespaces[0]} (or pick another)` : target.namespaces[0];
return { ok: true, message: `Connected${target.email ? ` as ${target.email}` : ""}: clips go into ${into}`, target };
}

/** Where a pushed item can be opened in Lens. */
Expand Down
Loading
Loading
0