Skip to content

Commit 9c834ad

Browse files
committed
Merge branch 'preview'
2 parents 0ce3d1c + e008541 commit 9c834ad

27 files changed

Lines changed: 1052 additions & 43 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ NUXT_REDIRECT_NO_STORE=false
88
NUXT_HOME_URL="https://sink.cool"
99
NUXT_CF_ACCOUNT_ID=123456
1010
NUXT_CF_API_TOKEN=CloudflareAPIToken
11+
NUXT_CF_ACCESS_TEAM_DOMAIN=""
12+
NUXT_CF_ACCESS_AUD=""
1113
NUXT_DATASET=sink
1214
NUXT_AI_MODEL="@cf/meta/llama-3-8b-instruct"
1315
NUXT_AI_PROMPT="You are a URL shortening assistant......"
1416
NUXT_DISABLE_AUTO_BACKUP=false
1517
NUXT_NOT_FOUND_REDIRECT=/your-own-404-page
18+
NUXT_WEBHOOK_URL="https://example.com/webhooks/sink"
19+
NUXT_WEBHOOK_SECRET=""

app/composables/useAuthSession.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { AuthMethod, VerifyResponse } from '@/types'
2+
import { readonly, useState } from '#imports'
3+
4+
export function useAuthSession() {
5+
const authMethod = useState<AuthMethod | null>('auth-method', () => null)
6+
const accessEnabled = useState('access-enabled', () => false)
7+
8+
function setAuthSession(response: VerifyResponse) {
9+
authMethod.value = response.authMethod
10+
accessEnabled.value = response.accessEnabled
11+
}
12+
13+
function clearAuthSession() {
14+
authMethod.value = null
15+
accessEnabled.value = false
16+
}
17+
18+
return {
19+
authMethod: readonly(authMethod),
20+
accessEnabled: readonly(accessEnabled),
21+
setAuthSession,
22+
clearAuthSession,
23+
}
24+
}

app/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from '#layers/dashboard/shared/types'
2+
export * from '#shared/types/auth'
23
export * from '#shared/types/link'
34
export * from '#shared/types/link-check'
45
export * from '#shared/types/traffic'

app/utils/api.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack'
2-
import { navigateTo } from '#imports'
32
import { defu } from 'defu'
43
import { useAuthToken } from '@/composables/useAuthToken'
54

@@ -12,14 +11,16 @@ export function useAPI<T = unknown>(api: string, options?: APIOptions): Promise<
1211

1312
const mergedOptions = defu(options || {}, {
1413
headers: {
15-
Authorization: `Bearer ${getToken() || ''}`,
14+
'Authorization': `Bearer ${getToken() || ''}`,
15+
'X-Requested-With': 'XMLHttpRequest',
1616
},
1717
}) as NitroFetchOptions<NitroFetchRequest>
1818

1919
return $fetch<T>(api, mergedOptions).catch((error) => {
2020
if (error?.status === 401) {
2121
removeToken()
22-
navigateTo('/dashboard/login')
22+
if (import.meta.client && window.location.pathname !== '/dashboard/login')
23+
window.location.assign('/dashboard/login')
2324
}
2425
return Promise.reject(error)
2526
}) as Promise<T>

docs/api.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@ Visit your Sink instance at `https://your-domain/_docs/scalar` for interactive A
1212

1313
## Authentication
1414

15-
All API endpoints require authentication via Bearer token in the `Authorization` header:
15+
All API endpoints accept the site token in the `Authorization` header:
1616

1717
```http
1818
Authorization: Bearer YOUR_SITE_TOKEN
1919
```
2020

2121
The token is the same as `NUXT_SITE_TOKEN` configured in your environment variables.
2222

23+
When [Cloudflare Access authentication](cloudflare-access.md) is configured, browser requests from an authenticated dashboard session can use the signed Access application token instead. Sink validates the token signature, issuer, audience, and expiration. Clients must not construct or trust `Cf-Access-Jwt-Assertion` themselves; Cloudflare Access supplies the token or authorization cookie.
24+
2325
## API Endpoints
2426

2527
### Links

docs/cloudflare-access.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Cloudflare Access Authentication
2+
3+
Sink can optionally use Cloudflare Access as an alternative to the existing site token. When Access is not configured, authentication behaves exactly as before.
4+
5+
With Access configured, an API request is accepted when either condition is true:
6+
7+
- The request has a valid `NUXT_SITE_TOKEN` bearer token.
8+
- The request has a valid Cloudflare Access application JWT.
9+
10+
Sink verifies the Access JWT signature, issuer, audience, and expiration against your team's public keys. The presence of an Access header or cookie alone is never trusted.
11+
12+
## Compatibility-first setup
13+
14+
This setup protects the dashboard while keeping public short links and SiteToken API clients unchanged.
15+
16+
1. Create a Cloudflare Access self-hosted application for your Sink hostname.
17+
2. Configure its application path to cover both `/dashboard` and its child routes.
18+
3. Do not protect `/api` with Access. Sink authenticates API requests itself using SiteToken or the signed Access application cookie.
19+
4. In the Access application's advanced cookie settings:
20+
- Keep **Cookie Path** disabled so the dashboard cookie is also sent to `/api`.
21+
- Set **SameSite** to `Lax` or `Strict` when your deployment does not require cross-site requests.
22+
5. Add the following Sink environment variables and redeploy:
23+
24+
```ini
25+
NUXT_CF_ACCESS_TEAM_DOMAIN=https://your-team.cloudflareaccess.com
26+
NUXT_CF_ACCESS_AUD=your-application-aud-tag
27+
```
28+
29+
Both variables are required. The team domain should not have a path. The AUD tag is available in the Access application's additional settings.
30+
31+
Short-link paths, static assets, and API documentation remain public at the Cloudflare Access layer. API operations still require Sink authentication. Protect `/_docs` separately if the API schema should not be public.
32+
33+
## Security considerations
34+
35+
In compatibility-first mode, `/api` is not evaluated by the Cloudflare Access proxy on every request. Sink validates the signed application JWT locally. As a result, an Access session revoked by an administrator may remain usable until its JWT expires. Use an appropriately short Access policy or application session duration.
36+
37+
Access uses a browser cookie, so Sink rejects cross-site browser requests authenticated through Access and verifies the `Origin` header for state-changing methods. SiteToken requests are unchanged. Non-browser clients should continue to use `NUXT_SITE_TOKEN`.
38+
39+
Do not expose an alternative deployment hostname with a weak SiteToken. Cloudflare Access on the dashboard does not protect other hostnames that route to the same Worker or Pages project.
40+
41+
## Logout
42+
43+
When the dashboard is authenticated through Access, Sink redirects logout to `/cdn-cgi/access/logout`. Cloudflare revokes the Access session across applications and clears the application cookie.
44+
45+
## Strict setup
46+
47+
For stronger edge enforcement, you can protect both `/dashboard` and `/api` with Access. In this mode, Cloudflare blocks requests before they reach Sink, so a SiteToken-only API client cannot use the protected hostname. Such clients must also use an Access service token or a separate API hostname.
48+
49+
## References
50+
51+
- [Validate Access JWTs](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/)
52+
- [Access application token](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/application-token/)
53+
- [Access application paths](https://developers.cloudflare.com/cloudflare-one/access-controls/policies/app-paths/)
54+
- [Access session management](https://developers.cloudflare.com/cloudflare-one/access-controls/access-settings/session-management/)

docs/configuration.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ This feature requires:
9999

100100
Backups are stored in R2 with the path `backups/links-{timestamp}.json` and run daily at 00:00 UTC.
101101

102+
## `NUXT_CF_ACCESS_TEAM_DOMAIN`
103+
104+
Optional Cloudflare Access team domain, for example `https://your-team.cloudflareaccess.com`.
105+
Set this together with `NUXT_CF_ACCESS_AUD` to allow a valid Cloudflare Access session to authenticate API requests as an alternative to `NUXT_SITE_TOKEN`.
106+
107+
## `NUXT_CF_ACCESS_AUD`
108+
109+
Optional Application Audience (AUD) tag from the Cloudflare Access application that protects the dashboard.
110+
Cloudflare Access authentication is enabled only when both Access variables are configured. Refer to [Cloudflare Access Authentication](cloudflare-access.md) for the required application and cookie settings.
111+
102112
## `NUXT_SAFE_BROWSING_DOH`
103113

104114
Set to a DNS over HTTPS (DoH) endpoint URL to enable automatic unsafe link detection when creating or editing links. When enabled, Sink queries the DoH service to check if the destination domain is flagged as malicious. If the domain resolves to `0.0.0.0`, the link is automatically marked as unsafe and visitors will see a warning page before being redirected.
@@ -114,3 +124,48 @@ Default is empty (disabled). Users can still manually mark links as unsafe in th
114124

115125
Optional custom redirect target when a slug is not found.
116126
If this is not set, Sink will fall back to its default 404 page.
127+
128+
## Click Webhooks
129+
130+
Set `NUXT_WEBHOOK_URL` to send a best-effort webhook for each click included in access statistics. An empty URL disables webhooks. Bot clicks skipped by `NUXT_DISABLE_BOT_ACCESS_LOG` are also skipped by webhooks.
131+
132+
`NUXT_WEBHOOK_URL` must use HTTP or HTTPS. HTTPS is strongly recommended in production. `NUXT_WEBHOOK_SECRET` is optional. When configured, it must start with `whsec_`; the suffix is a Base64-encoded HMAC key between 24 and 64 bytes. Generate a 32-byte key with:
133+
134+
```sh
135+
printf 'whsec_%s\n' "$(openssl rand -base64 32)"
136+
```
137+
138+
Sink sends a Dub-style payload:
139+
140+
```json
141+
{
142+
"id": "evt_...",
143+
"event": "link.clicked",
144+
"createdAt": "2026-07-11T12:00:00.000Z",
145+
"data": {
146+
"click": {
147+
"id": "clk_...",
148+
"timestamp": "2026-07-11T12:00:00.000Z",
149+
"country": "US",
150+
"region": "California",
151+
"city": "San Francisco",
152+
"device": "mobile",
153+
"browser": "Mobile Safari",
154+
"os": "iOS",
155+
"referer": "example.com"
156+
},
157+
"link": {
158+
"id": "link-id",
159+
"slug": "example"
160+
}
161+
}
162+
}
163+
```
164+
165+
The click location fields contain the raw Cloudflare country code, region, and city. The device field prefers the parsed device category (such as `mobile`) and falls back to the device model.
166+
167+
Every request includes the Standard Webhooks headers `webhook-id` and `webhook-timestamp`. When a secret is configured, Sink also sends `webhook-signature`. The signature is `v1,<base64>` for HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<raw-body>`, using the decoded secret suffix as the key. An invalid non-empty secret fails delivery and never falls back to unsigned delivery.
168+
169+
Without `NUXT_WEBHOOK_SECRET`, delivery is unauthenticated and unsigned. This mode is not recommended over untrusted networks; configure a secret whenever the receiver supports signature verification.
170+
171+
Webhook payloads exclude IP addresses, coordinates, full user agents, query parameters, passwords, and destination URLs. Delivery has a 10-second timeout, accepts only 2xx responses, does not follow redirects, and is asynchronous. Failures do not affect redirects and are not retried.

docs/deployment/pages.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
- `NUXT_SITE_TOKEN`: Must be at least **8** characters long. This token grants access to your dashboard.
88
- `NUXT_CF_ACCOUNT_ID`: Find your [account ID](https://developers.cloudflare.com/fundamentals/setup/find-account-and-zone-ids/).
99
- `NUXT_CF_API_TOKEN`: Create a [Cloudflare API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) with at least `Account.Account Analytics` permission. [See reference.](https://developers.cloudflare.com/analytics/analytics-engine/sql-api/#authentication)
10+
- (_Optional_) `NUXT_WEBHOOK_URL`: The HTTPS endpoint that enables and receives click webhooks.
11+
- (_Optional_) `NUXT_WEBHOOK_SECRET`: A `whsec_`-prefixed Base64 secret. If omitted, delivery is unauthenticated and unsigned, which is not recommended over untrusted networks.
1012

1113
5. Save and deploy the project.
1214
6. Cancel the deployment, then go to **Settings** -> **Bindings** -> **Add**:
@@ -22,3 +24,7 @@
2224
- Go to **Settings** -> **Runtime** -> **Compatibility flags** and set the following flags `nodejs_compat`.
2325
8. Redeploy the project.
2426
9. To update code, refer to the official GitHub documentation [Syncing a fork branch from the web UI](https://docs.github.com/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork#syncing-a-fork-branch-from-the-web-ui 'GitHub: Syncing a fork').
27+
28+
To optionally protect the dashboard with Cloudflare Zero Trust while keeping short links public, refer to [Cloudflare Access Authentication](../cloudflare-access.md).
29+
30+
Click webhook delivery is best effort and has no retries. See [Click Webhooks](../configuration.md#click-webhooks) for payload, signature, and privacy details.

docs/deployment/workers.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@
1414
- `NUXT_SITE_TOKEN`: Must be at least **8** characters long. This token grants access to your dashboard.
1515
- `NUXT_CF_ACCOUNT_ID`: Find your [account ID](https://developers.cloudflare.com/fundamentals/setup/find-account-and-zone-ids/).
1616
- `NUXT_CF_API_TOKEN`: Create a [Cloudflare API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) with at least `Account.Account Analytics` permission. [See reference.](https://developers.cloudflare.com/analytics/analytics-engine/sql-api/#authentication)
17+
- (_Optional_) `NUXT_WEBHOOK_URL`: The HTTPS endpoint that enables and receives click webhooks.
18+
- (_Optional_) `NUXT_WEBHOOK_SECRET`: A `whsec_`-prefixed Base64 secret. Generate one with `printf 'whsec_%s\n' "$(openssl rand -base64 32)"` and store it as a secret in the dashboard, not in `wrangler.jsonc`. If omitted, delivery is unauthenticated and unsigned, which is not recommended over untrusted networks.
1719

1820
9. Enable Analytics Engine. In **Workers & Pages**, go to **Account details** in the right panel, locate **Analytics Engine**, and click **Set up** to enable the free tier. Name them `sink` and `ANALYTICS`, or else overwrite it with `NUXT_DATASET` and update your `wrangler.jsonc` accordingly
1921
10. Redeploy the project.
2022
11. To update your code, refer to the official GitHub documentation: [Syncing a fork branch from the web UI](https://docs.github.com/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork#syncing-a-fork-branch-from-the-web-ui 'GitHub: Syncing a fork').
23+
24+
To optionally protect the dashboard with Cloudflare Zero Trust while keeping short links public, refer to [Cloudflare Access Authentication](../cloudflare-access.md).
25+
26+
Click webhook delivery is best effort and has no retries. See [Click Webhooks](../configuration.md#click-webhooks) for payload, signature, and privacy details.

layers/dashboard/app/components/dashboard/sidebar/NavUser.vue

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ interface User {
99
}
1010
1111
const { isMobile } = useSidebar()
12+
const { getToken, removeToken } = useAuthToken()
13+
const { authMethod, accessEnabled, clearAuthSession } = useAuthSession()
1214
1315
const hostname = computed<string>(() => {
1416
if (import.meta.client) {
@@ -24,7 +26,16 @@ const user = computed<User>(() => ({
2426
}))
2527
2628
function logOut() {
27-
localStorage.removeItem('SinkSiteToken')
29+
const method = authMethod.value || (getToken() ? 'site-token' : 'cloudflare-access')
30+
const shouldLogoutAccess = accessEnabled.value || method === 'cloudflare-access'
31+
removeToken()
32+
clearAuthSession()
33+
34+
if (shouldLogoutAccess) {
35+
window.location.assign('/cdn-cgi/access/logout')
36+
return
37+
}
38+
2839
navigateTo('/dashboard/login')
2940
}
3041
</script>

0 commit comments

Comments
 (0)