diff --git a/CLAUDE.md b/CLAUDE.md index b0b0a753..3e2110ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ Each SDK records the SHA-256 of the OpenAPI spec it was last regenerated against | Python | `sdk/python/pyproject.toml` | `[tool.shrtnr]` `spec_hash` | | Dart | `sdk/dart/pubspec.yaml` | leading comment `# x-spec-hash:` (top-level keys would draw a pana warning) | -Spec changes (edits to `src/api/router.ts`, `src/api/schemas.ts`, or any resource sub-app affecting the generated doc) stale all three hashes. A root `package.json` version bump also drifts the hash because the spec embeds `info.version`. CI enforces via `.github/workflows/sdk-spec-drift.yml`. +Spec changes (edits to `src/api/router.ts`, `src/api/schemas.ts`, or any resource sub-app affecting the generated doc) stale all three hashes. A root `package.json` version bump also drifts the hash because the spec embeds `info.version`. CI enforces via the `sdk-spec-drift` job in `.github/workflows/ci.yml`. Workflow on API change: diff --git a/scripts/bump-sdk-version.sh b/scripts/bump-sdk-version.sh index fb1047cd..f175c3f5 100755 --- a/scripts/bump-sdk-version.sh +++ b/scripts/bump-sdk-version.sh @@ -170,8 +170,9 @@ echo " 1. edit $CHANGELOG and replace the TODO placeholder" echo " 2. git add $MANIFEST $CHANGELOG" echo " 3. git commit -m \"Release $SDK $NEW_VERSION: \"" if [ "$SDK" = "pub" ]; then - echo " 4. git tag ${TAG_PREFIX}${NEW_VERSION}" - echo " 5. git push origin main ${TAG_PREFIX}${NEW_VERSION}" + echo " 4. git tag ${TAG_PREFIX}${NEW_VERSION} (create locally, do not push yet)" + echo " pub.dev publishes on tag push, so push the tag only when you are" + echo " ready to release: git push origin ${TAG_PREFIX}${NEW_VERSION}" else echo " 4. gh pr create (or push to main — CI tags after publish)" fi diff --git a/sdk/dart/README.md b/sdk/dart/README.md index 5fb3e44e..0586d046 100644 --- a/sdk/dart/README.md +++ b/sdk/dart/README.md @@ -50,7 +50,7 @@ closed by `client.close()`. | `get(id, {range?})` | Get a link with click count | | `list({owner?, range?})` | List all links | | `create({url, label?, slugLength?, expiresAt?, allowDuplicate?})` | Create a short link | -| `update(id, {url?, label?, expiresAt?})` | Update URL, label, or expiry | +| `update(link)` | Update URL, label, or expiry (pass a `Link` from `copyWith`) | | `disable(id)` | Stop redirecting | | `enable(id)` | Resume redirecting | | `delete(id)` | Permanently delete | @@ -65,10 +65,10 @@ closed by `client.close()`. final link = await client.links.create(url: 'https://example.com', label: 'Landing page'); // Get a 7-day click count -final fresh = await client.links.get(link.id, range: '7d'); +final fresh = await client.links.get(link.id, range: TimelineRange.last7d); // Full analytics for the last 30 days -final stats = await client.links.analytics(link.id, range: '30d'); +final stats = await client.links.analytics(link.id, range: TimelineRange.last30d); print('${stats.totalClicks} clicks, ${stats.numCountries} countries'); ``` @@ -100,7 +100,7 @@ Groups of related links with combined analytics. | `get(id, {range?})` | Get a bundle with click summary | | `list({archived?, range?})` | List bundles | | `create({name, description?, icon?, accent?})` | Create a bundle | -| `update(id, {name?, description?, icon?, accent?})` | Update metadata | +| `update(bundle)` | Update metadata (pass a `Bundle` from `copyWith`) | | `delete(id)` | Permanently delete | | `archive(id)` | Hide from default listing | | `unarchive(id)` | Restore an archived bundle | @@ -112,12 +112,12 @@ Groups of related links with combined analytics. ```dart // Create a bundle and add links to it -final bundle = await client.bundles.create(name: 'Spring 2026', accent: 'green'); +final bundle = await client.bundles.create(name: 'Spring 2026', accent: BundleAccent.green); await client.bundles.addLink(bundle.id, linkA.id); await client.bundles.addLink(bundle.id, linkB.id); // Combined analytics for the last 7 days -final stats = await client.bundles.analytics(bundle.id, range: '7d'); +final stats = await client.bundles.analytics(bundle.id, range: TimelineRange.last7d); print(stats.totalClicks); ``` @@ -130,8 +130,9 @@ Key types exported from `package:shrtnr/shrtnr.dart`: - `Link`, `Slug`, `Bundle`, `BundleWithSummary`, `BundleTopLink` - `ClickStats`, `TimelineData`, `TimelineBucket`, `TimelineSummary`, `NameCount` -- `DateClickCount`, `SlugClickCount` +- `DateCount`, `SlugCount` - `DeletedResult`, `AddedResult`, `RemovedResult` +- Enums: `TimelineRange`, `BundleAccent`, `BreakdownDimension`, `BundleArchivedFilter` Timestamp fields (`createdAt`, `expiresAt`, `disabledAt`, `archivedAt`, `updatedAt`) are plain `int` Unix seconds, matching the wire format exactly. diff --git a/sdk/dart/lib/src/models.dart b/sdk/dart/lib/src/models.dart index 60b790ec..a8a5fa7d 100644 --- a/sdk/dart/lib/src/models.dart +++ b/sdk/dart/lib/src/models.dart @@ -102,14 +102,17 @@ enum TimelineRange { /// uses [trueValue] and [activeOnly]. The wire value `"1"` is omitted because /// it is a semantic alias for `"true"`; use [trueValue] for both. enum BundleArchivedFilter { - /// Include archived bundles alongside active ones (wire: `"true"`). Also - /// covers the `"1"` alias from the spec; prefer this member for both. + /// Return only archived bundles (wire: `"true"`). Also covers the `"1"` + /// alias from the spec; prefer this member for both. trueValue, - /// Return only archived bundles (wire: `"only"`). + /// Return only archived bundles (wire: `"only"`). Despite the name, the + /// server's `"only"` value returns archived bundles only, the same result + /// as [trueValue]. The name is misleading and is kept for wire + /// compatibility; a rename awaits the next major version. activeOnly, - /// Return all bundles regardless of archived status (wire: `"all"`). + /// Include archived bundles alongside active ones (wire: `"all"`). all; static const _wireValues = { diff --git a/sdk/python/CHANGELOG.md b/sdk/python/CHANGELOG.md index fc28f084..eaa1eed0 100644 --- a/sdk/python/CHANGELOG.md +++ b/sdk/python/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to the SDK are documented in this file. +## 1.1.1 + +- `links.qr()` now takes `size` as an `int` instead of a `str`, matching the API schema (which validates an integer) and the TypeScript and Dart SDKs. Callers who passed an int already got the correct behavior; the annotation was the only thing out of step. + ## 1.1.0 (2026-06-19) - Add `links.breakdown` and `bundles.breakdown` (sync and async) for paging through the countries, sources and domains analytics panels (offset/limit, returns items + total). diff --git a/sdk/python/README.md b/sdk/python/README.md index d166da7d..314b1062 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -65,7 +65,7 @@ with Shrtnr(base_url="...", api_key="sk_...") as client: | `get(id, *, range=None)` | Get a link with click count | | `list(*, owner=None, range=None)` | List all links | | `create(*, url, label=None, slug_length=None, expires_at=None, allow_duplicate=None)` | Create a short link | -| `update(id, *, url=None, label=None, expires_at=None)` | Update URL, label, or expiry | +| `update(id, *, url=None, label=UNSET, expires_at=UNSET)` | Update URL, label, or expiry. Omit a field to leave it unchanged; pass `None` to clear it | | `disable(id)` | Stop redirecting | | `enable(id)` | Resume redirecting | | `delete(id)` | Permanently delete | @@ -115,7 +115,7 @@ Groups of related links with combined analytics. | `get(id, *, range=None)` | Get a bundle with click summary | | `list(*, archived=None, range=None)` | List bundles | | `create(*, name, description=None, icon=None, accent=None)` | Create a bundle | -| `update(id, *, name=None, description=None, icon=None, accent=None)` | Update metadata | +| `update(id, *, name=None, description=UNSET, icon=UNSET, accent=None)` | Update metadata. Omit a field to leave it unchanged; pass `None` to clear `description` or `icon` | | `delete(id)` | Permanently delete | | `archive(id)` | Hide from default listing | | `unarchive(id)` | Restore an archived bundle | diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 9d9e37bd..bf892504 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "shrtnr" -version = "1.1.0" +version = "1.1.1" description = "SDK for the shrtnr URL shortener API" readme = "README.md" license = "Apache-2.0" @@ -77,6 +77,12 @@ include = [ line-length = 100 target-version = "py310" +[tool.ruff.format] +# ruff 0.16 formats Python blocks inside Markdown. README samples align their +# trailing comments to match the Dart and TypeScript READMEs, so leave them +# alone. `ruff check` still lints those blocks. +exclude = ["*.md"] + [tool.ruff.lint] select = ["E", "F", "I", "W", "UP", "B", "SIM", "RUF"] ignore = ["E501"] diff --git a/sdk/python/src/shrtnr/resources/links.py b/sdk/python/src/shrtnr/resources/links.py index 12e7d3c3..dd6eb78a 100644 --- a/sdk/python/src/shrtnr/resources/links.py +++ b/sdk/python/src/shrtnr/resources/links.py @@ -171,13 +171,13 @@ def timeline(self, id: int, *, range: TimelineRange | None = None) -> TimelineDa url = self._url(f"/_/api/links/{id}/timeline", {"range": range}) return TimelineData.from_dict(self._request("GET", url, headers=self._headers())) - def qr(self, id: int, *, slug: str | None = None, size: str | None = None) -> str: + def qr(self, id: int, *, slug: str | None = None, size: int | None = None) -> str: """Get the QR code SVG for a link. Returns the SVG string.""" query: dict[str, str | None] = {} if slug is not None: query["slug"] = slug if size is not None: - query["size"] = size + query["size"] = str(size) url = self._url(f"/_/api/links/{id}/qr", query or None) return self._request_text("GET", url, headers=self._headers()) @@ -327,13 +327,13 @@ async def timeline(self, id: int, *, range: TimelineRange | None = None) -> Time url = self._url(f"/_/api/links/{id}/timeline", {"range": range}) return TimelineData.from_dict(await self._request("GET", url, headers=self._headers())) - async def qr(self, id: int, *, slug: str | None = None, size: str | None = None) -> str: + async def qr(self, id: int, *, slug: str | None = None, size: int | None = None) -> str: """Get the QR code SVG for a link. Returns the SVG string.""" query: dict[str, str | None] = {} if slug is not None: query["slug"] = slug if size is not None: - query["size"] = size + query["size"] = str(size) url = self._url(f"/_/api/links/{id}/qr", query or None) return await self._request_text("GET", url, headers=self._headers()) diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index bbb2701f..3ebdc24d 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -341,7 +341,7 @@ def test_links_qr_with_slug_and_size(client: Shrtnr) -> None: route = respx.get(url__regex=rf"^{BASE_URL}/_/api/links/5/qr\?").mock( return_value=httpx.Response(200, text=""), ) - client.links.qr(5, slug="promo", size="200") + client.links.qr(5, slug="promo", size=200) url = str(route.calls[0].request.url) assert "slug=promo" in url assert "size=200" in url diff --git a/src/__tests__/handler/mcp.test.ts b/src/__tests__/handler/mcp.test.ts index 18fd9ffe..9bc2daa6 100644 --- a/src/__tests__/handler/mcp.test.ts +++ b/src/__tests__/handler/mcp.test.ts @@ -235,7 +235,7 @@ describe("MCP tool behavior (service layer)", () => { const result = await updateLink(env as never, created.data.id, { url: "https://example.com/updated", - }); + }, "anonymous"); expect(result.ok).toBe(true); if (result.ok) { expect(result.data.url).toBe("https://example.com/updated"); diff --git a/src/__tests__/page/keys-page.test.ts b/src/__tests__/page/keys-page.test.ts index fd076c11..c39ecf6a 100644 --- a/src/__tests__/page/keys-page.test.ts +++ b/src/__tests__/page/keys-page.test.ts @@ -113,6 +113,13 @@ describe("Keys page table", () => { it("keeps the delete action for each key", async () => { await seedApiKey(); const html = await fetchKeysHtml(); - expect(html).toContain("deleteKey("); + // The delete action rides on data-* attributes read by a delegated handler + // rather than an inline onclick, so a key title cannot break out into + // executable script. The title is carried verbatim on data-delete-key-title. + expect(html).toMatch(/data-delete-key="\d+"/); + expect(html).toContain('data-delete-key-title='); + // The vulnerable inline onclick handler must be gone from the markup. The + // client script still defines deleteKey(), so target the attribute form. + expect(html).not.toContain('onclick="deleteKey('); }); }); diff --git a/src/__tests__/page/link-detail-page.test.ts b/src/__tests__/page/link-detail-page.test.ts index 03f77515..df8b6c63 100644 --- a/src/__tests__/page/link-detail-page.test.ts +++ b/src/__tests__/page/link-detail-page.test.ts @@ -44,6 +44,27 @@ describe("Link detail page server render", () => { expect(slugCount![1].trim()).toBe("2"); }); + it("renders the duplicate action without interpolating the URL into an inline handler", async () => { + // A URL with a literal single quote must not break out of the duplicate + // button. The URL rides on a data-* attribute (HTML-escaped by JSX) and a + // delegated handler reads it, so no inline onclick carries the raw URL. + const link = await LinkRepository.create(env.DB, { + url: "https://example.com/'-alert(document.cookie)-'", + slug: "abc", + }); + + const res = await SELF.fetch(req(`/_/admin/links/${link.id}`)); + const html = await res.text(); + + expect(html).toContain("data-duplicate-url="); + // The client script still defines showDuplicateModal(); the fix is that no + // inline onclick attribute carries the raw URL. + expect(html).not.toContain('onclick="showDuplicateModal('); + // The quote survives only in escaped form, never as a raw breakout. + expect(html).not.toContain("'-alert(document.cookie)-'"); + expect(html).toContain("'-alert(document.cookie)-'"); + }); + it("hero total_clicks honors a user's default_range setting", async () => { const link = await LinkRepository.create(env.DB, { url: "https://example.com", slug: "abc" }); const slug = link.slugs[0].slug; diff --git a/src/__tests__/service/link-service.test.ts b/src/__tests__/service/link-service.test.ts index dae31540..2f7d0ce3 100644 --- a/src/__tests__/service/link-service.test.ts +++ b/src/__tests__/service/link-service.test.ts @@ -120,7 +120,7 @@ describe("link-management service", () => { expect(created.ok).toBe(true); if (!created.ok) return; - const result = await updateLink(env as any, created.data.id, { url: "javascript:alert(1)" }); + const result = await updateLink(env as any, created.data.id, { url: "javascript:alert(1)" }, "anonymous"); expect(result.ok).toBe(false); if (!result.ok) { expect(result.status).toBe(400); @@ -135,7 +135,7 @@ describe("link-management service", () => { const fetched = await getLink(env as any, created.data.id); expect(fetched.ok).toBe(true); - const updated = await updateLink(env as any, created.data.id, { label: "Updated" }); + const updated = await updateLink(env as any, created.data.id, { label: "Updated" }, "anonymous"); expect(updated.ok).toBe(true); if (updated.ok) { expect(updated.data.label).toBe("Updated"); @@ -307,7 +307,7 @@ describe("URL normalization in updateLink", () => { expect(created.ok).toBe(true); if (!created.ok) return; - const updated = await updateLink(env as any, created.data.id, { url: "https://example.com/new-path/" }); + const updated = await updateLink(env as any, created.data.id, { url: "https://example.com/new-path/" }, "anonymous"); expect(updated.ok).toBe(true); if (updated.ok) { expect(updated.data.url).toBe("https://example.com/new-path"); @@ -319,7 +319,7 @@ describe("URL normalization in updateLink", () => { expect(created.ok).toBe(true); if (!created.ok) return; - const updated = await updateLink(env as any, created.data.id, { url: "https://example.com/page?" }); + const updated = await updateLink(env as any, created.data.id, { url: "https://example.com/page?" }, "anonymous"); expect(updated.ok).toBe(true); if (updated.ok) { expect(updated.data.url).toBe("https://example.com/page"); @@ -331,7 +331,7 @@ describe("URL normalization in updateLink", () => { expect(created.ok).toBe(true); if (!created.ok) return; - const updated = await updateLink(env as any, created.data.id, { label: null }); + const updated = await updateLink(env as any, created.data.id, { label: null }, "anonymous"); expect(updated.ok).toBe(true); if (updated.ok) { expect(updated.data.label).toBeNull(); @@ -389,7 +389,7 @@ describe("field type validation in updateLink", () => { expect(created.ok).toBe(true); if (!created.ok) return; - const updated = await updateLink(env as any, created.data.id, { expires_at: "never" as any }); + const updated = await updateLink(env as any, created.data.id, { expires_at: "never" as any }, "anonymous"); expect(updated.ok).toBe(false); if (!updated.ok) expect(updated.status).toBe(400); }); @@ -399,7 +399,7 @@ describe("field type validation in updateLink", () => { expect(created.ok).toBe(true); if (!created.ok) return; - const updated = await updateLink(env as any, created.data.id, { label: 42 as any }); + const updated = await updateLink(env as any, created.data.id, { label: 42 as any }, "anonymous"); expect(updated.ok).toBe(false); if (!updated.ok) expect(updated.status).toBe(400); }); @@ -409,7 +409,7 @@ describe("field type validation in updateLink", () => { expect(created.ok).toBe(true); if (!created.ok) return; - const updated = await updateLink(env as any, created.data.id, { expires_at: null }); + const updated = await updateLink(env as any, created.data.id, { expires_at: null }, "anonymous"); expect(updated.ok).toBe(true); if (updated.ok) expect(updated.data.expires_at).toBeNull(); }); diff --git a/src/__tests__/service/ownership.test.ts b/src/__tests__/service/ownership.test.ts index 8bfeb5a2..9ffbfc3e 100644 --- a/src/__tests__/service/ownership.test.ts +++ b/src/__tests__/service/ownership.test.ts @@ -12,6 +12,8 @@ import { enableSlug, removeSlug, addCustomSlugToLink, + updateLink, + setSlugPrimary, searchLinks, listLinksByOwner, } from "../../services/link-management"; @@ -91,6 +93,48 @@ describe("Link ownership: delete", () => { }); }); +describe("Link ownership: update", () => { + it("owner can update their link", async () => { + const link = await createOwnedLink(); + const result = await updateLink(env as any, link.id, { url: "https://example.com/moved" }, OWNER); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.url).toBe("https://example.com/moved"); + } + }); + + it("non-owner cannot update another user's link", async () => { + const link = await createOwnedLink(); + const result = await updateLink(env as any, link.id, { url: "https://evil.example" }, OTHER); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(403); + } + // The destination must be untouched after a rejected update. + const after = await LinkRepository.getById(env.DB, link.id); + expect(after!.url).toBe("https://example.com"); + }); +}); + +describe("Slug ownership: set primary", () => { + it("link owner can change the primary slug", async () => { + const link = await createOwnedLink(); + await addCustomSlugToLink(env as any, link.id, { slug: "primary-pick" }); + const result = await setSlugPrimary(env as any, link.id, "primary-pick", OWNER); + expect(result.ok).toBe(true); + }); + + it("non-owner cannot change the primary slug on another user's link", async () => { + const link = await createOwnedLink(); + await addCustomSlugToLink(env as any, link.id, { slug: "primary-pick" }); + const result = await setSlugPrimary(env as any, link.id, "primary-pick", OTHER); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(403); + } + }); +}); + describe("Slug ownership: disable", () => { it("link owner can disable a custom slug", async () => { const link = await createOwnedLink(); diff --git a/src/__tests__/unit/normalize-url.test.ts b/src/__tests__/unit/normalize-url.test.ts index 2c84315d..f5417231 100644 --- a/src/__tests__/unit/normalize-url.test.ts +++ b/src/__tests__/unit/normalize-url.test.ts @@ -63,4 +63,22 @@ describe("normalizeUrl", () => { "https://example.com#section", ); }); + + it("preserves a trailing slash inside a query value", () => { + expect(normalizeUrl("https://example.com/search?q=cats/")).toBe( + "https://example.com/search?q=cats/", + ); + }); + + it("preserves a trailing slash inside an embedded URL parameter", () => { + expect(normalizeUrl("https://example.com/a?next=https://b.com/")).toBe( + "https://example.com/a?next=https://b.com/", + ); + }); + + it("preserves a hash-router route that ends in a slash", () => { + expect(normalizeUrl("https://example.com/#/spa/route/")).toBe( + "https://example.com/#/spa/route/", + ); + }); }); diff --git a/src/api/links.ts b/src/api/links.ts index 6f248ca7..b036d5fc 100644 --- a/src/api/links.ts +++ b/src/api/links.ts @@ -165,7 +165,7 @@ const updateLinkRoute = createRoute({ linksApp.openapi(updateLinkRoute, async (c) => { const { id } = c.req.valid("param") as { id: number }; const body = c.req.valid("json") as { url?: string; label?: string | null; expires_at?: number | null }; - return fromServiceResult(await updateLink(c.env, id, body)) as never; + return fromServiceResult(await updateLink(c.env, id, body, c.var.auth.identity)) as never; }, paramHook); // ---- POST /:id/disable ---- @@ -510,7 +510,7 @@ export async function handleCreateLink(request: Request, env: Env, createdVia?: return fromServiceResult(result); } -export async function handleUpdateLink(request: Request, env: Env, id: number): Promise { +export async function handleUpdateLink(request: Request, env: Env, id: number, identity: string): Promise { let body: { url?: string; label?: string | null; expires_at?: number | null }; try { @@ -519,7 +519,7 @@ export async function handleUpdateLink(request: Request, env: Env, id: number): return json({ error: "Invalid JSON body" }, 400); } - return fromServiceResult(await updateLink(env, id, body)); + return fromServiceResult(await updateLink(env, id, body, identity)); } export async function handleDisableLink(env: Env, id: number, identity: string): Promise { diff --git a/src/api/qr.ts b/src/api/qr.ts index 15af5096..ba0e955e 100644 --- a/src/api/qr.ts +++ b/src/api/qr.ts @@ -43,7 +43,11 @@ export async function handleLinkQr(request: Request, env: Env, linkId: number): return new Response(svg, { headers: { "Content-Type": "image/svg+xml", - "Cache-Control": "public, max-age=86400", + // private, not public: this endpoint sits behind a bearer token, and + // public would let a shared cache store the authenticated response and + // serve the link-id to slug mapping to unauthenticated clients. private + // keeps the same browser caching without the shared-cache waiver. + "Cache-Control": "private, max-age=86400", }, }); } diff --git a/src/api/slugs.ts b/src/api/slugs.ts index 48901669..80e7b8ee 100644 --- a/src/api/slugs.ts +++ b/src/api/slugs.ts @@ -37,6 +37,7 @@ export async function handleSetPrimarySlug( request: Request, env: Env, linkId: number, + identity: string, ): Promise { let body: { slug?: string }; try { @@ -45,7 +46,7 @@ export async function handleSetPrimarySlug( return json({ error: "Invalid JSON body" }, 400); } if (!body.slug) return json({ error: "slug is required" }, 400); - return fromServiceResult(await setSlugPrimary(env, linkId, body.slug)); + return fromServiceResult(await setSlugPrimary(env, linkId, body.slug, identity)); } export async function handleDisableSlug( diff --git a/src/client.ts b/src/client.ts index a5711c7e..f09ff9c7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -450,17 +450,23 @@ function doSetPrimary(linkId, slug) { } // ---- Duplicate link modal ---- +// Holds the URL to duplicate while the confirm modal is open. Parking it in a +// script-scope variable avoids interpolating a user-controlled URL into an +// inline onclick JS-string, which esc() cannot make safe (it does not escape '). +var pendingDuplicateUrl = null; function showDuplicateModal(linkId, url) { + pendingDuplicateUrl = url; document.getElementById('detail-menu').style.display = 'none'; openModal( '' + '

' + esc(t('linkDetail.duplicateBody')) + '

' + '

' + esc(t('linkDetail.duplicateHelper')) + '

' + - '' + '' ); } -function doDuplicate(url) { - createDuplicate(url); +function doDuplicate() { + if (pendingDuplicateUrl != null) createDuplicate(pendingDuplicateUrl); + pendingDuplicateUrl = null; closeModal(); } @@ -1590,5 +1596,27 @@ document.addEventListener('click', function(ev) { ev.stopPropagation(); copyUrl(slug); }); + +// Open the duplicate-link modal from a data-* button. The link URL rides on a +// data attribute rather than an inline onclick argument so a URL containing a +// quote cannot break out into executable script. +document.addEventListener('click', function(ev) { + var el = ev.target && ev.target.closest ? ev.target.closest('[data-duplicate-url]') : null; + if (!el) return; + ev.preventDefault(); + var linkId = parseInt(el.getAttribute('data-duplicate-link'), 10); + showDuplicateModal(linkId, el.getAttribute('data-duplicate-url')); +}); + +// Delete an API key from a data-* button. The key title rides on a data +// attribute so a title containing a quote or backslash cannot break out of an +// inline onclick argument. +document.addEventListener('click', function(ev) { + var el = ev.target && ev.target.closest ? ev.target.closest('[data-delete-key]') : null; + if (!el) return; + ev.preventDefault(); + var keyId = parseInt(el.getAttribute('data-delete-key'), 10); + deleteKey(keyId, el.getAttribute('data-delete-key-title') || ''); +}); `; } diff --git a/src/index.tsx b/src/index.tsx index 5b6f044f..231d24b6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -389,7 +389,7 @@ app.get("/_/admin/api/links/:id", (c) => { app.put("/_/admin/api/links/:id", (c) => { const id = parseInt(c.req.param("id"), 10); if (isNaN(id)) return c.json({ error: "Not Found" }, 404); - return handleUpdateLink(c.req.raw, c.env, id); + return handleUpdateLink(c.req.raw, c.env, id, c.var.identity); }); app.get("/_/admin/api/links/:id/analytics", (c) => { const id = parseInt(c.req.param("id"), 10); @@ -434,7 +434,7 @@ app.post("/_/admin/api/links/:id/slugs", (c) => { app.put("/_/admin/api/links/:id/slugs/primary", (c) => { const id = parseInt(c.req.param("id"), 10); if (isNaN(id)) return c.json({ error: "Not Found" }, 404); - return handleSetPrimarySlug(c.req.raw, c.env, id); + return handleSetPrimarySlug(c.req.raw, c.env, id, c.var.identity); }); app.post("/_/admin/api/links/:id/slugs/:slug/disable", (c) => { const id = parseInt(c.req.param("id"), 10); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 63f78574..b944cbe1 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -268,7 +268,7 @@ export class ShrtnrMCP extends McpAgent, Props> { annotations: { title: "Update link", ...WRITE_IDEMPOTENT }, }, async ({ link_id, ...opts }) => { - const result = await updateLink(this.env, link_id, opts); + const result = await updateLink(this.env, link_id, opts, this.identity); if (!result.ok) return fail(result.error); return ok(result.data); }, diff --git a/src/normalize-url.ts b/src/normalize-url.ts index 3ee7387f..48770b84 100644 --- a/src/normalize-url.ts +++ b/src/normalize-url.ts @@ -3,8 +3,24 @@ /** * Strips trailing characters that serve no purpose from a URL: - * trailing `/`, `#` (without an anchor), and `?` (without parameters). + * trailing `/`, an empty `#` (no anchor), and an empty `?` (no parameters). + * + * The redundant characters are only stripped when they carry no content: a `/`, + * `?`, or `#` that is part of a query value or fragment is preserved. So + * `https://example.com/search?q=cats/` keeps its trailing slash, and + * `https://example.com/#/spa/route/` keeps its hash-router path intact. */ export function normalizeUrl(url: string): string { - return url.replace(/[/?#]+$/, ""); + let result = url; + // Drop an empty trailing fragment ("...#") then an empty trailing query + // ("...?"). Order matters: "path/?#" collapses to "path/" before the slash + // strip below can run. + if (result.endsWith("#")) result = result.slice(0, -1); + if (result.endsWith("?")) result = result.slice(0, -1); + // Trailing path slashes are redundant only when no query or fragment + // follows; a slash inside a query value or fragment is meaningful. + if (!result.includes("?") && !result.includes("#")) { + result = result.replace(/\/+$/, ""); + } + return result; } diff --git a/src/pages/keys.tsx b/src/pages/keys.tsx index 0174c1cf..a178447c 100644 --- a/src/pages/keys.tsx +++ b/src/pages/keys.tsx @@ -3,7 +3,6 @@ import type { FC } from "hono/jsx"; import type { TranslateFn } from "../i18n"; -import { escHtml } from "../escape"; import { SdkList } from "./sdk-list"; // Bullets that stand in for the redacted tail of a key prefix, e.g. sk_84cc••••••. @@ -153,7 +152,8 @@ export const KeysPage: FC = ({ keys, t, lang, origin }) => { diff --git a/src/pages/link-detail.tsx b/src/pages/link-detail.tsx index 1f251ec3..124ca66d 100644 --- a/src/pages/link-detail.tsx +++ b/src/pages/link-detail.tsx @@ -131,7 +131,7 @@ export const LinkDetailPage: FC = ({ link, analytics, bundles = [], t, la star {t("linkDetail.changePrimary")} )} - {isOwner && ( diff --git a/src/services/link-management.ts b/src/services/link-management.ts index 3641b5d1..08da61d7 100644 --- a/src/services/link-management.ts +++ b/src/services/link-management.ts @@ -147,7 +147,12 @@ export async function updateLink( env: Env, id: number, body: { url?: string; label?: string | null; expires_at?: number | null }, + identity: string, ): Promise> { + const existing = await LinkRepository.getById(env.DB, id); + if (!existing) return fail(404, "Link not found"); + if (existing.created_by !== identity) return fail(403, "Only the link owner can update this link"); + if (body.url !== undefined) { try { const parsed = new URL(body.url); @@ -290,9 +295,11 @@ export async function setSlugPrimary( env: Env, linkId: number, slug: string, + identity: string, ): Promise> { const link = await LinkRepository.getById(env.DB, linkId); if (!link) return fail(404, "Link not found"); + if (link.created_by !== identity) return fail(403, "Only the link owner can change the primary slug"); const slugObj = link.slugs.find((s) => s.slug === slug); if (!slugObj) return fail(404, "Slug not found on this link");