From bd3de08dc26fad62a370d0528341dbd21945f96e Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:21:16 +0800 Subject: [PATCH] Import links: web form and relay documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /import and /zh-CN/import: the web form of a ThinkWatch Lite import link. The parameters stay in the fragment, are checked with the same rules as the app (src/lib/import-link.ts), rendered with textContent only, and removed from the address bar after reading. The only navigation is to thinkwatch://import?… built from the checked values. No analytics or other third-party script on this page (Base gains an `analytics` prop), noindex, left out of the sitemap, and a download button when the app is missing. - Lite docs "Import links" (en, zh-CN) for relay and vendor operators: link forms, parameters, limits, what the app does, examples for Anthropic- and OpenAI-compatible relays, what a link cannot set and why, and a client-side link builder that generates both link forms. Co-Authored-By: Claude Opus 5.5 --- astro.config.mjs | 3 +- src/components/ImportLinkBuilder.astro | 188 +++++++++++++++ src/components/pages/ImportPage.astro | 253 ++++++++++++++++++++ src/content/docs-lite/en/import-links.md | 94 ++++++++ src/content/docs-lite/zh-CN/import-links.md | 94 ++++++++ src/content/docs/_meta.ts | 10 + src/layouts/Base.astro | 12 +- src/lib/import-link.ts | 177 ++++++++++++++ src/pages/docs/_DocArticle.astro | 3 + src/pages/import.astro | 6 + src/pages/zh-CN/import.astro | 6 + 11 files changed, 842 insertions(+), 4 deletions(-) create mode 100644 src/components/ImportLinkBuilder.astro create mode 100644 src/components/pages/ImportPage.astro create mode 100644 src/content/docs-lite/en/import-links.md create mode 100644 src/content/docs-lite/zh-CN/import-links.md create mode 100644 src/lib/import-link.ts create mode 100644 src/pages/import.astro create mode 100644 src/pages/zh-CN/import.astro diff --git a/astro.config.mjs b/astro.config.mjs index f467c5b..3dc6242 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -46,7 +46,8 @@ export default defineConfig({ defaultLocale: 'en', locales: { en: 'en', 'zh-CN': 'zh-CN' }, }, - filter: (page) => !page.includes('/404'), + // The import page only reads the parameters of a link; it has nothing to index. + filter: (page) => !page.includes('/404') && !/\/(zh-CN\/)?import\/?$/.test(new URL(page).pathname), async serialize(item) { const url = new URL(item.url); const pathname = url.pathname.replace(/\/$/, '') || '/'; diff --git a/src/components/ImportLinkBuilder.astro b/src/components/ImportLinkBuilder.astro new file mode 100644 index 0000000..df35847 --- /dev/null +++ b/src/components/ImportLinkBuilder.astro @@ -0,0 +1,188 @@ +--- +// The link builder on the "Import links" page of the Lite docs. It runs in the +// browser only: the values are checked with the same rules as the app +// (src/lib/import-link.ts) and the two links are written with textContent. +// Nothing is sent anywhere. +import { getLang } from "~/i18n"; +import { PROTOCOLS } from "~/lib/import-link"; + +const lang = getLang(Astro); +const zh = lang === "zh-CN"; + +const t = zh + ? { + heading: "链接生成器", + note: "在浏览器中生成,不发送任何内容。", + name: "名称(可选)", + url: "接口地址", + protocol: "接口协议", + auto: "自动识别(不写入链接)", + key: "API 密钥(可选)", + models: "手动模型清单(可选,逗号分隔)", + appLink: "应用链接", + webLink: "网页链接", + copy: "复制", + copied: "已复制", + fill: "填写接口地址后生成链接。", + problems: { + empty: "填写接口地址后生成链接。", + tooLong: "链接过长。", + unknownParam: "包含不支持的参数。", + duplicateParam: "同一参数出现了多次。", + emptyValue: "参数的值为空。", + missingUrl: "填写接口地址后生成链接。", + name: "名称不符合要求:最多 64 个字符,首尾无空白,不含 / \\ $ { } < > \" `,不以 __ 开头。", + url: "接口地址不符合要求:须为 https 地址(http 仅限 localhost、127.0.0.1、[::1]),不含账号、查询串、片段、空白与 % $ { } 等字符。", + protocol: "接口协议不受支持。", + key: "API 密钥只能包含字母、数字与 - _ . ~ + / = :,最长 512 个字符。", + models: "模型清单不符合要求:以逗号分隔,每项只含字母、数字与 - _ . : / @ +,最多 64 项。", + }, + } + : { + heading: "Link builder", + note: "Runs in the browser; nothing is sent.", + name: "Name (optional)", + url: "Base URL", + protocol: "Protocol", + auto: "Auto-detect (left out of the link)", + key: "API key (optional)", + models: "Manual model list (optional, comma-separated)", + appLink: "App link", + webLink: "Web link", + copy: "Copy", + copied: "Copied", + fill: "Enter a base URL to generate the links.", + problems: { + empty: "Enter a base URL to generate the links.", + tooLong: "The link is too long.", + unknownParam: "The link contains an unsupported parameter.", + duplicateParam: "A parameter appears more than once.", + emptyValue: "A parameter has an empty value.", + missingUrl: "Enter a base URL to generate the links.", + name: "The name is not accepted: at most 64 characters, no leading or trailing spaces, none of / \\ $ { } < > \" `, and not starting with __.", + url: "The base URL is not accepted: it must be an https address (http only for localhost, 127.0.0.1 and [::1]) without credentials, a query, a fragment, spaces or characters such as % $ { }.", + protocol: "The protocol is not supported.", + key: "The API key may contain only letters, digits and - _ . ~ + / = :, up to 512 characters.", + models: "The model list is not accepted: comma-separated, each entry only letters, digits and - _ . : / @ +, at most 64 entries.", + }, + }; + +const input = + "w-full rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-brand-1)]"; +const labelCls = "mb-1.5 block text-sm text-[var(--color-muted)]"; +--- + +
+

{t.heading}

+

{t.note}

+ +
+ +
+ + +
+ + +
+ +

{t.fill}

+ + +
+ + diff --git a/src/components/pages/ImportPage.astro b/src/components/pages/ImportPage.astro new file mode 100644 index 0000000..84f1086 --- /dev/null +++ b/src/components/pages/ImportPage.astro @@ -0,0 +1,253 @@ +--- +// /import: the web form of a ThinkWatch Lite import link. +// +// A relay links to https://thinkwat.ch/import#name=…&url=…&key=…. The +// parameters stay in the fragment, which browsers never send to a server; the +// script below reads them, checks them with the same rules as the app +// (src/lib/import-link.ts), shows what would be imported and offers a button +// that opens thinkwatch://import?… built from the checked values only. +// +// The page loads no third-party script (no analytics: the fragment may hold an +// API key), is kept out of search results, renders every value with +// textContent, and navigates nowhere but to that one app link. +import Base from "~/layouts/Base.astro"; +import SiteHeader from "~/components/SiteHeader.astro"; +import SiteFooter from "~/components/SiteFooter.astro"; +import LiteDownload from "~/components/LiteDownload.astro"; +import { getLang, localePath } from "~/i18n"; + +const lang = getLang(Astro); +const zh = lang === "zh-CN"; +const docs = localePath(lang, "/docs/lite/import-links"); + +const t = zh + ? { + title: "导入上游 · ThinkWatch Lite", + description: "把中转站或服务商提供的上游设置导入 ThinkWatch Lite。", + heading: "导入上游到 ThinkWatch Lite", + intro: "以下设置来自一条导入链接。ThinkWatch Lite 打开后会先请求确认,确认之前不保存任何设置。", + sendsTo: "请求内容与 API 密钥将发送至", + name: "名称", + nameAuto: "由应用按地址生成", + url: "接口地址", + protocol: "接口协议", + auto: "自动识别", + key: "API 密钥", + noKey: "未提供", + show: "显示", + hide: "隐藏", + models: "手动模型清单", + open: "在 ThinkWatch Lite 中打开", + notInstalled: "尚未安装 ThinkWatch Lite 时,先下载并安装,再回到此页面。", + invalidTitle: "此导入链接无效", + invalidBody: "链接的参数不完整或不符合要求,未显示任何内容。请联系提供链接的服务商。", + noscript: "此页面需要启用 JavaScript 读取链接中的参数。", + docs: "导入链接说明", + problems: { + empty: "链接中没有参数。", + tooLong: "链接过长。", + unknownParam: "链接包含不支持的参数", + duplicateParam: "同一参数出现了多次", + emptyValue: "参数的值为空", + missingUrl: "缺少接口地址(url)。", + name: "名称不符合要求。", + url: "接口地址不符合要求:须为 https 地址(http 仅限本机),且不含账号、查询串或片段。", + protocol: "接口协议不受支持。", + key: "API 密钥包含不支持的字符。", + models: "模型清单不符合要求。", + }, + } + : { + title: "Import an upstream · ThinkWatch Lite", + description: "Import an upstream provided by a relay or vendor into ThinkWatch Lite.", + heading: "Import an upstream into ThinkWatch Lite", + intro: + "The settings below come from an import link. ThinkWatch Lite asks for confirmation when it opens, and saves nothing before that.", + sendsTo: "Request content and the API key will be sent to", + name: "Name", + nameAuto: "Chosen by the app from the address", + url: "Base URL", + protocol: "Protocol", + auto: "Auto-detect", + key: "API key", + noKey: "Not provided", + show: "Show", + hide: "Hide", + models: "Manual model list", + open: "Open in ThinkWatch Lite", + notInstalled: "If ThinkWatch Lite is not installed yet, download and install it, then return to this page.", + invalidTitle: "This import link is not valid", + invalidBody: + "The link's parameters are incomplete or not accepted, so nothing is shown. Contact the provider of the link.", + noscript: "This page needs JavaScript to read the parameters of the link.", + docs: "About import links", + problems: { + empty: "The link has no parameters.", + tooLong: "The link is too long.", + unknownParam: "The link contains an unsupported parameter", + duplicateParam: "A parameter appears more than once", + emptyValue: "A parameter has an empty value", + missingUrl: "The base URL (url) is missing.", + name: "The name is not accepted.", + url: "The base URL is not accepted: it must be an https address (http only for this computer) without credentials, a query or a fragment.", + protocol: "The protocol is not supported.", + key: "The API key contains unsupported characters.", + models: "The model list is not accepted.", + }, + }; + +const row = "grid gap-1 border-b border-[var(--color-border)] py-3 sm:grid-cols-[10rem_1fr] sm:gap-4"; +const label = "text-sm text-[var(--color-muted)]"; +const value = "min-w-0 break-all font-mono text-[15px] text-[var(--color-text)]"; +--- + + + +
+
+

{t.heading}

+ + + + + + + +

+ {t.docs} +

+
+
+ + + + diff --git a/src/content/docs-lite/en/import-links.md b/src/content/docs-lite/en/import-links.md new file mode 100644 index 0000000..1e9ded5 --- /dev/null +++ b/src/content/docs-lite/en/import-links.md @@ -0,0 +1,94 @@ +# Import links + +ThinkWatch Lite accepts links that pre-fill a new upstream. A relay or a model vendor can place such a link in its dashboard or in a welcome message. A user who has the app installed opens the link, reviews the settings in a confirmation dialog, and creates the upstream in one step. + +This page is written for relay and vendor operators. It describes the two forms of the link, the parameters, what the app checks, and what a link intentionally cannot do. A link builder is at the end of the page. + +## Link forms + +Both forms carry the same parameters. + +| Form | Description | +|---|---| +| `thinkwatch://import?…` | Opens the app directly. The browser asks for permission before it hands the link to the app. | +| `https://thinkwat.ch/import#…` | A web page that shows the settings, opens the app with a button, and offers the download when the app is not installed. The parameters are in the fragment after `#`, which browsers do not send to any server. The page loads no analytics or third-party scripts and removes the fragment from the address bar after reading it. | + +The web form suits emails and dashboards whose users may not have the app yet. The page checks the parameters with the same rules as the app and shows nothing for a link that fails them. + +## Parameters + +| Parameter | Required | Value | +|---|---|---| +| `url` | Yes | The base URL of the service, without endpoint paths such as `/chat/completions` or `/messages`. `https://` only; `http://` is accepted only for `localhost`, `127.0.0.1` and `[::1]`. No user name or password, query string or fragment. | +| `name` | No | The upstream's name in the app. When omitted, the app derives one from the address. | +| `protocol` | No | `anthropic`, `openai-chat`, `openai-responses` or `gemini`. When omitted, the app detects the protocol from the address. | +| `key` | No | The API key, stored as given. Letters, digits and `- _ . ~ + / = :` only. | +| `models` | No | Comma-separated model IDs. The app uses this list when the service does not list its models itself. | + +Encoding rules: + +- Each value is percent-encoded, for example with `encodeURIComponent`. A literal `+` means a space, so a key that contains `+` is written as `%2B`. +- Each parameter appears at most once. +- A link with any other parameter, an empty value, or a value that fails its rule is rejected as a whole. The app ignores such a link without opening a window. + +Limits: + +| Item | Limit | +|---|---| +| Whole link | 8192 characters | +| `name` | 64 characters; no leading or trailing spaces; none of `/`, `\`, `$`, `{`, `}`, `<`, `>`, `"` or the backtick; not starting with `__`; no control or invisible characters | +| `url` | 2048 characters | +| `key` | 512 characters | +| `models` | 64 entries, 128 characters each; letters, digits and `- _ . : / @ +` | + +## What the app does with a link + +1. **Checks the link.** Every rule above is applied in the app, whatever the page that produced the link has checked. +2. **Shows a confirmation dialog.** The dialog states the host that will receive request content and the API key, in ASCII: an internationalized domain name is shown as punycode (`xn--…`), so a look-alike domain cannot pass for a familiar one. It also shows the base URL, the protocol, the key (hidden until revealed) and the model list. Only the name can be changed. Values are displayed as plain text. +3. **Saves nothing before confirmation.** Until **Create** is chosen, the configuration is not written and the app makes no network request to the address: no connection test and no model listing. +4. **Creates one new upstream.** An import never changes, replaces or deletes an existing upstream. When the name is already in use, a different name must be entered; there is no option to overwrite. The new upstream is not made the default and is not added to any route. After creation it behaves like an upstream added by hand, including fetching its model list. +5. **Handles one link at a time.** Links that arrive while the dialog is open, or within a few seconds after it closes, are ignored and do not bring the window to the front. + +## Examples + +An Anthropic-compatible relay: + +```text +thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE +``` + +```text +https://thinkwat.ch/import#name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE +``` + +An OpenAI-compatible relay that does not list its models: + +```text +thinkwatch://import?name=example-openai&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&models=gpt-5%2Cgpt-5-mini +``` + +```text +https://thinkwat.ch/import#name=example-openai&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&models=gpt-5%2Cgpt-5-mini +``` + +## What a link cannot set + +Any web page can open a link, so a link is limited to what a user can judge in one dialog. The following are intentionally not supported: + +| Not supported | Reason | +|---|---| +| Environment variable references in the key (`${NAME}`) | The app reads `${NAME}` from the user's environment. A link with `key=${OPENAI_API_KEY}` would send the user's own key to the relay's host. Keys containing `$`, `{` or `}` are rejected. | +| Request headers | Header values can reference environment variables and credentials, and can change how requests are authenticated in ways the dialog cannot show clearly. | +| Outbound proxy | A link must not route traffic through a third party. | +| Pricing and billing | How cost is counted is the user's decision. | +| Routing rules and the default upstream | A link must not redirect the traffic of clients that are already configured. | +| MCP servers and client configuration | They run commands or change the settings of other applications. | +| Icons and other presentation | Not needed to connect, and a way to imitate a familiar service. | +| Plain `http://` to other hosts | The key would travel unencrypted. | +| ChatGPT account sign-in and Amazon Bedrock | They need an interactive sign-in or AWS credentials, which a link cannot carry. | + +These settings can be changed in the app after the upstream is created. + +## Keys in links + +A link that contains a key is a credential. Generate a separate link for each customer and deliver it through a private channel, such as the customer's own dashboard. A link without `key` creates the upstream without a key; the key is then added by editing the upstream in the app. diff --git a/src/content/docs-lite/zh-CN/import-links.md b/src/content/docs-lite/zh-CN/import-links.md new file mode 100644 index 0000000..6e11898 --- /dev/null +++ b/src/content/docs-lite/zh-CN/import-links.md @@ -0,0 +1,94 @@ +# 导入链接 + +ThinkWatch Lite 支持通过链接预填一个新的上游。中转站或模型服务商可以把这样的链接放在控制台或开通通知中;已安装应用的用户打开链接后,在确认对话框中核对设置,一步即可创建上游。 + +本页面向中转站与服务商的运营人员,说明链接的两种形式、参数、应用所做的校验,以及链接刻意不支持的内容。页面末尾附有链接生成器。 + +## 链接形式 + +两种形式携带相同的参数。 + +| 形式 | 说明 | +|---|---| +| `thinkwatch://import?…` | 直接打开应用。浏览器在把链接交给应用之前会先请求许可。 | +| `https://thinkwat.ch/import#…` | 一个网页:显示将要导入的设置,通过按钮打开应用,未安装应用时提供下载。参数位于 `#` 之后的片段中,浏览器不会把片段发送给任何服务器。该页面不加载统计或第三方脚本,读取参数后会从地址栏中移除片段。 | + +网页形式适合用于邮件和控制台,因为收件人可能尚未安装应用。网页按与应用相同的规则校验参数,校验不通过的链接不显示任何内容。 + +## 参数 + +| 参数 | 必填 | 取值 | +|---|---|---| +| `url` | 是 | 服务的 Base URL,不含 `/chat/completions`、`/messages` 等接口路径。只接受 `https://`;`http://` 仅限 `localhost`、`127.0.0.1` 与 `[::1]`。不得包含用户名或密码、查询串或片段。 | +| `name` | 否 | 上游在应用中的名称。省略时由应用按地址生成。 | +| `protocol` | 否 | `anthropic`、`openai-chat`、`openai-responses` 或 `gemini`。省略时由应用按地址识别。 | +| `key` | 否 | API 密钥,按原样保存。只能包含字母、数字与 `- _ . ~ + / = :`。 | +| `models` | 否 | 以逗号分隔的模型 ID。服务本身不提供模型列表时,应用使用这份清单。 | + +编码规则: + +- 每个值都要经过百分号编码,例如使用 `encodeURIComponent`。未编码的 `+` 表示空格,因此含 `+` 的密钥应写作 `%2B`。 +- 每个参数最多出现一次。 +- 链接中出现其他参数、空值,或任一值不符合规则时,整条链接作废。应用会忽略这样的链接,不打开窗口。 + +长度与字符限制: + +| 项目 | 限制 | +|---|---| +| 整条链接 | 8192 个字符 | +| `name` | 64 个字符;首尾无空白;不含 `/`、`\`、`$`、`{`、`}`、`<`、`>`、`"` 与反引号;不以 `__` 开头;不含控制字符或不可见字符 | +| `url` | 2048 个字符 | +| `key` | 512 个字符 | +| `models` | 最多 64 项,每项最多 128 个字符;只含字母、数字与 `- _ . : / @ +` | + +## 应用如何处理链接 + +1. **校验链接。**上述规则全部在应用中执行,与生成链接的页面做过哪些校验无关。 +2. **显示确认对话框。**对话框写明接收请求内容与 API 密钥的主机,以 ASCII 显示:国际化域名显示为 punycode(`xn--…`),外形相近的域名无法冒充熟悉的域名。对话框同时显示 Base URL、接口协议、密钥(默认隐藏,可切换显示)与模型清单,其中只有名称可以修改。所有值都按纯文本显示。 +3. **确认之前不保存任何内容。**选择「创建」之前,应用不写入配置,也不向该地址发出任何网络请求:不检测连接,不获取模型列表。 +4. **只创建一个新的上游。**导入不会修改、替换或删除已有的上游。名称已被使用时须改用其他名称,不提供覆盖选项。新上游不会被设为默认,也不会加入任何路由。创建之后,它与手动添加的上游相同,包括获取模型列表。 +5. **一次只处理一条链接。**对话框打开期间,以及关闭后的几秒内到达的链接会被忽略,也不会把窗口调到前台。 + +## 示例 + +兼容 Anthropic 接口的中转站: + +```text +thinkwatch://import?name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE +``` + +```text +https://thinkwat.ch/import#name=example-relay&url=https%3A%2F%2Fapi.relay.example&protocol=anthropic&key=sk-relay-EXAMPLE +``` + +兼容 OpenAI 接口、不提供模型列表的中转站: + +```text +thinkwatch://import?name=example-openai&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&models=gpt-5%2Cgpt-5-mini +``` + +```text +https://thinkwat.ch/import#name=example-openai&url=https%3A%2F%2Fapi.relay.example%2Fv1&protocol=openai-chat&key=sk-relay-EXAMPLE&models=gpt-5%2Cgpt-5-mini +``` + +## 链接不能设置的内容 + +任何网页都能打开链接,因此链接只包含用户在一个对话框中能够判断的内容。以下内容刻意不支持: + +| 不支持 | 原因 | +|---|---| +| 密钥中的环境变量引用(`${NAME}`) | 应用会从用户的环境变量中读取 `${NAME}`。一条 `key=${OPENAI_API_KEY}` 的链接会把用户自己的密钥发送到中转站的主机。含 `$`、`{` 或 `}` 的密钥一律拒绝。 | +| 请求头 | 请求头的值可以引用环境变量与凭据,并可改变请求的鉴权方式,这些无法在对话框中清楚呈现。 | +| 出站代理 | 链接不得让流量经由第三方转发。 | +| 价目表与计费方式 | 费用如何计算由用户决定。 | +| 路由规则与默认上游 | 链接不得改变已配置客户端的流量去向。 | +| MCP 服务与客户端配置 | 它们会执行命令或修改其他应用的设置。 | +| 图标等展示内容 | 连接不需要它们,且可被用于仿冒熟悉的服务。 | +| 指向其他主机的 `http://` | 密钥将以明文传输。 | +| ChatGPT 账号登录与 Amazon Bedrock | 需要交互式登录或 AWS 凭据,无法通过链接携带。 | + +这些设置可以在上游创建之后在应用中修改。 + +## 链接中的密钥 + +含有密钥的链接本身就是凭据。应为每位客户单独生成链接,并通过私密渠道提供,例如客户自己的控制台。不含 `key` 的链接会创建一个没有密钥的上游,之后在应用中编辑该上游补充密钥。 diff --git a/src/content/docs/_meta.ts b/src/content/docs/_meta.ts index a253750..641383d 100644 --- a/src/content/docs/_meta.ts +++ b/src/content/docs/_meta.ts @@ -180,6 +180,16 @@ export const products: Product[] = [ "zh-CN": "Tauri 2 外壳与 React 19 前端,负责托管 Core,在 macOS 与 Linux 上通过 unix socket、在 Windows 上通过回环端口、连接服务器时通过 TCP 端口控制它,每种通道都经过加密握手。", }, }, + { + slug: "import-links", + label: { en: "Import links", "zh-CN": "导入链接" }, + locales: both, + group: "reference", + summary: { + en: "For relays and vendors: links that pre-fill a new upstream in the app, their parameters, what the app checks, and a link builder.", + "zh-CN": "面向中转站与服务商:在应用中预填新上游的链接、参数、应用所做的校验,以及链接生成器。", + }, + }, { slug: "contributing", label: { en: "Contributing", "zh-CN": "贡献指南" }, diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index b5f2f63..0fded76 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -23,6 +23,11 @@ interface Props { noindex?: boolean; /** JSON-LD items describing the page (a product, an article, breadcrumbs). The Organization is added on every page. */ jsonLd?: JsonLd[]; + /** + * Load Google Analytics. Off for a page whose URL carries data that must stay + * in the browser (the import page keeps an API key in its fragment). + */ + analytics?: boolean; } const { @@ -34,6 +39,7 @@ const { modified, noindex = false, jsonLd = [], + analytics = true, } = Astro.props; const canonical = noindex ? undefined : new URL(Astro.url.pathname, Astro.site).toString(); @@ -97,8 +103,8 @@ const GA_ID = "G-R8LX8NCHCJ"; never blocks first paint. No personal data is sent until the library has loaded. --> - - } + {analytics && + } CONTROL.test(s) || INVISIBLE.test(s); + +function checkName(n: string): boolean { + return ( + [...n].length <= MAX_NAME && + n.trim() === n && + n !== "." && + n !== ".." && + !n.startsWith("__") && + !unsafe(n) && + !/[/\\${}<>"`]/.test(n) + ); +} + +function checkUrl(raw: string): { baseUrl: string; host: string } | null { + if (raw.length > MAX_URL || unsafe(raw) || /[\s\\${}%"<>`^|@#?]/.test(raw)) return null; + const lower = raw.slice(0, 8).toLowerCase(); + if (!lower.startsWith("https://") && !lower.startsWith("http://")) return null; + let u: URL; + try { + u = new URL(raw); + } catch { + return null; + } + if (u.username || u.password || u.search || u.hash || !u.hostname) return null; + const loopback = u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]"; + if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) return null; + // `URL` gives the host in ASCII: an internationalized name comes out as punycode. + if (!/^[\x21-\x7E]+$/.test(u.host)) return null; + const baseUrl = u.href.replace(/\/+$/, ""); + if (baseUrl.length > MAX_URL) return null; + return { baseUrl, host: u.host }; +} + +/** Only characters that keys use. No `$`, `{` or `}`: the app would read `${NAME}` from the environment. */ +function checkKey(k: string): boolean { + return k.length <= MAX_KEY && /^[A-Za-z0-9\-_.~+/=:]+$/.test(k); +} + +function checkModels(m: string): string[] | null { + const out: string[] = []; + for (const id of m.split(",")) { + if (!id || id.length > MAX_MODEL || !/^[A-Za-z0-9\-_.:/@+]+$/.test(id)) return null; + if (!out.includes(id)) out.push(id); + } + return out.length > MAX_MODELS ? null : out; +} + +/** Validates the parameters of a link: the fragment of the web form, or the query of the app link. */ +export function parseParams(query: string): Result { + if (query.length > MAX_LINK) return { ok: false, problem: "tooLong" }; + if (!query) return { ok: false, problem: "empty" }; + const params = new URLSearchParams(query); + const seen = new Set(); + for (const [k, v] of params) { + if (!(PARAMS as readonly string[]).includes(k)) return { ok: false, problem: "unknownParam", param: k }; + if (seen.has(k)) return { ok: false, problem: "duplicateParam", param: k }; + seen.add(k); + if (v === "") return { ok: false, problem: "emptyValue", param: k }; + } + return validate({ + name: params.get("name") ?? undefined, + url: params.get("url") ?? "", + protocol: params.get("protocol") ?? undefined, + key: params.get("key") ?? undefined, + models: params.get("models") ?? undefined, + }); +} + +/** Validates values as they are entered in the link builder. Empty optional values are left out. */ +export function validate(v: { name?: string; url: string; protocol?: string; key?: string; models?: string }): Result { + if (!v.url) return { ok: false, problem: "missingUrl" }; + const url = checkUrl(v.url); + if (!url) return { ok: false, problem: "url" }; + if (v.name !== undefined && !checkName(v.name)) return { ok: false, problem: "name" }; + if (v.protocol !== undefined && !(PROTOCOLS as readonly string[]).includes(v.protocol)) + return { ok: false, problem: "protocol" }; + if (v.key !== undefined && !checkKey(v.key)) return { ok: false, problem: "key" }; + let models: string[] = []; + if (v.models !== undefined) { + const m = checkModels(v.models); + if (!m) return { ok: false, problem: "models" }; + models = m; + } + return { + ok: true, + link: { + name: v.name, + baseUrl: url.baseUrl, + host: url.host, + protocol: v.protocol as Protocol | undefined, + key: v.key, + models, + }, + }; +} + +/** The parameters of a validated import, encoded, in a fixed order. */ +export function encode(l: ImportLink): string { + const parts: string[] = []; + const add = (k: Param, v: string | undefined) => { + if (v) parts.push(`${k}=${encodeURIComponent(v)}`); + }; + add("name", l.name); + add("url", l.baseUrl); + add("protocol", l.protocol); + add("key", l.key); + add("models", l.models.join(",")); + return parts.join("&"); +} + +/** The link that opens the app. Built only from validated values. */ +export function appLink(l: ImportLink): string { + return `thinkwatch://import?${encode(l)}`; +} + +/** The web form: the parameters stay in the fragment, which browsers do not send to the server. */ +export function webLink(l: ImportLink, origin = "https://thinkwat.ch"): string { + return `${origin}/import#${encode(l)}`; +} diff --git a/src/pages/docs/_DocArticle.astro b/src/pages/docs/_DocArticle.astro index 9784736..c4d32e1 100644 --- a/src/pages/docs/_DocArticle.astro +++ b/src/pages/docs/_DocArticle.astro @@ -4,6 +4,7 @@ import { productName } from "~/content/docs/_meta"; // with localized title, BreadcrumbList and TechArticle JSON-LD. import { render } from "astro:content"; import DocsLayout from "~/layouts/DocsLayout.astro"; +import ImportLinkBuilder from "~/components/ImportLinkBuilder.astro"; import { docHref, getNeighbours, getProduct, productHomeHref, type ProductId } from "~/content/docs/_meta"; import { localePath, type Lang } from "~/i18n"; import { CORE_REPO } from "~/lib/core-docs.mjs"; @@ -100,6 +101,8 @@ const homeCards = slug ? [] : p.docs.filter((d) => d.slug);
+ {/* The "Import links" page ends with its link builder, which a markdown file cannot hold */} + {product === "lite" && slug === "import-links" && } {synced && (

{zh ? "本页取自 ThinkWatch Core 仓库 " : "This page is published from "} diff --git a/src/pages/import.astro b/src/pages/import.astro new file mode 100644 index 0000000..7f67b64 --- /dev/null +++ b/src/pages/import.astro @@ -0,0 +1,6 @@ +--- +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import ImportPage from "~/components/pages/ImportPage.astro"; +--- + + diff --git a/src/pages/zh-CN/import.astro b/src/pages/zh-CN/import.astro new file mode 100644 index 0000000..7f67b64 --- /dev/null +++ b/src/pages/zh-CN/import.astro @@ -0,0 +1,6 @@ +--- +// Thin route wrapper; Astro i18n routing sets the locale from the path. +import ImportPage from "~/components/pages/ImportPage.astro"; +--- + +