Repository navigation
FEATURE: Serve content on organization subdomains #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f21e9bb
3259630
84ecc6c
58756dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| -- migrate:up | ||
| -- Slug changes. An owner may rename the org's content host once per 30 | ||
| -- days (orgs.slug_changed_at); the previous slug is parked here so the | ||
| -- old host redirects to the new one for the same window. A parked slug | ||
| -- cannot be taken by another org while it still redirects. | ||
|
|
||
| ALTER TABLE orgs ADD COLUMN slug_changed_at timestamptz; | ||
|
|
||
| CREATE TABLE org_slug_history ( | ||
| slug text PRIMARY KEY, | ||
| org_id text NOT NULL REFERENCES orgs (id) ON DELETE CASCADE, | ||
| released_at timestamptz NOT NULL | ||
| ); | ||
|
|
||
| CREATE INDEX org_slug_history_org_idx ON org_slug_history (org_id); | ||
|
|
||
| -- migrate:down | ||
| DROP TABLE IF EXISTS org_slug_history; | ||
| ALTER TABLE orgs DROP COLUMN IF EXISTS slug_changed_at; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,51 +1,87 @@ | ||
| import type { Handle } from '@sveltejs/kit'; | ||
| import { SESSION_COOKIE, sessionCookieOptions } from '$lib/server/auth-policy'; | ||
| import { resolveContentHost } from '$lib/server/content-host'; | ||
| import { MisdirectedRequest } from '$lib/server/errors'; | ||
| import { assertHostRoute, normalizeOrigins } from '$lib/server/host-gate'; | ||
| import { resolveEventAuth } from '$lib/server/request-auth'; | ||
| import { applySecurityHeaders } from '$lib/server/security-headers'; | ||
|
|
||
| const unavailable = (cause: unknown) => { | ||
| console.error( | ||
| JSON.stringify({ | ||
| message: 'request identity could not be resolved', | ||
| cause: String(cause) | ||
| }) | ||
| ); | ||
| return new Response('Storage unavailable', { status: 502 }); | ||
| }; | ||
|
|
||
| export const handle: Handle = async ({ event, resolve }) => { | ||
| const env = event.platform?.env; | ||
| if (!env) throw new Error('Cloudflare bindings unavailable'); | ||
|
|
||
| const originConfig = { | ||
| dashboardOrigin: env.DASHBOARD_ORIGIN, | ||
| contentOrigin: env.CONTENT_ORIGIN | ||
| contentDomain: env.CONTENT_DOMAIN | ||
| }; | ||
|
|
||
| let hostRoute; | ||
| try { | ||
| assertHostRoute(event.url, originConfig); | ||
| hostRoute = assertHostRoute(event.url, originConfig); | ||
| } catch (cause) { | ||
| if (cause instanceof MisdirectedRequest) { | ||
| return new Response(cause.message, { status: 421 }); | ||
| } | ||
| throw cause; | ||
| } | ||
|
|
||
| try { | ||
| const resolved = await resolveEventAuth(env, event); | ||
| event.locals.auth = resolved.auth; | ||
| if (resolved.refreshedSession !== null) { | ||
| event.cookies.set( | ||
| SESSION_COOKIE, | ||
| resolved.refreshedSession, | ||
| sessionCookieOptions | ||
| ); | ||
| const origins = normalizeOrigins(originConfig); | ||
| event.locals.auth = null; | ||
| event.locals.content = null; | ||
| if (hostRoute.route === 'content') { | ||
| // The host names the org. A slug nobody owns, or one whose org is | ||
| // suspended, is a 404 for every path so the host leaks nothing. | ||
| let resolved; | ||
| try { | ||
| resolved = await resolveContentHost(env, hostRoute.slug); | ||
| } catch (cause) { | ||
| return unavailable(cause); | ||
| } | ||
| if (resolved._tag === 'Missing') { | ||
| return new Response('Not found', { status: 404 }); | ||
| } | ||
| if (resolved._tag === 'Moved') { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium An old slug for a renamed, suspended organization returns a 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| const location = new URL(event.url); | ||
| location.host = `${resolved.slug}.${origins.contentDomain}`; | ||
| return new Response(null, { | ||
| status: 301, | ||
| headers: { | ||
| Location: location.href, | ||
| 'Cache-Control': 'public, max-age=300' | ||
| } | ||
| }); | ||
|
Comment on lines
+56
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Use a temporary redirect for the 30-day slug window. The old address is reserved only for the redirect window. After the window ends, another organization can claim that slug. A 🔧 Proposed change return new Response(null, {
- status: 301,
+ status: 308,
headers: {
Location: location.href,
'Cache-Control': 'public, max-age=300'
}
});🤖 Prompt for AI Agents |
||
| } | ||
| event.locals.content = resolved.host; | ||
| } else { | ||
| try { | ||
| const resolved = await resolveEventAuth(env, event); | ||
| event.locals.auth = resolved.auth; | ||
| if (resolved.refreshedSession !== null) { | ||
| event.cookies.set( | ||
| SESSION_COOKIE, | ||
| resolved.refreshedSession, | ||
| sessionCookieOptions | ||
| ); | ||
| } | ||
| } catch (cause) { | ||
| return unavailable(cause); | ||
| } | ||
| } catch (cause) { | ||
| console.error( | ||
| JSON.stringify({ | ||
| message: 'request identity could not be resolved', | ||
| cause: String(cause) | ||
| }) | ||
| ); | ||
| return new Response('Storage unavailable', { status: 502 }); | ||
| } | ||
|
|
||
| return applySecurityHeaders(await resolve(event), { | ||
| pathname: event.url.pathname, | ||
| requestOrigin: event.url.origin, | ||
| contentOrigin: normalizeOrigins(originConfig).contentOrigin | ||
| contentDomain: origins.contentDomain, | ||
| contentScheme: origins.contentScheme | ||
| }); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| <script lang="ts"> | ||
| import type { OrgSettings } from '@adrive/shared'; | ||
| import { untrack } from 'svelte'; | ||
| import { invalidateAll } from '$app/navigation'; | ||
| import { changeOrgSlug } from '$lib/dashboard/api'; | ||
| import { formatDate } from '$lib/dashboard/format'; | ||
| import { getToasts } from '$lib/dashboard/toast.svelte'; | ||
| import Button from '$lib/components/ui/Button.svelte'; | ||
|
|
||
| let { | ||
| token, | ||
| org, | ||
| onchanged | ||
| }: { | ||
| token: string; | ||
| org: OrgSettings; | ||
| onchanged: (updated: OrgSettings) => void; | ||
| } = $props(); | ||
| const toasts = getToasts(); | ||
| // The settings page keys this component on the slug, so the prop only | ||
| // seeds local state; a successful change replaces it from the response. | ||
| let current = $state(untrack(() => org)); | ||
| let slug = $state(untrack(() => org.slug)); | ||
| let busy = $state(false); | ||
| let cooldownTick = $state(0); | ||
|
|
||
| const unchanged = $derived(slug.trim().toLowerCase() === current.slug); | ||
| const cooldownEndsAt = $derived( | ||
| current.nextSlugChangeAt ? Date.parse(current.nextSlugChangeAt) : 0 | ||
| ); | ||
| const locked = $derived.by(() => { | ||
| cooldownTick; | ||
| return cooldownEndsAt > Date.now(); | ||
| }); | ||
|
|
||
| $effect(() => { | ||
| const target = cooldownEndsAt; | ||
| const remaining = target - Date.now(); | ||
| if (!Number.isFinite(target) || remaining <= 0) return; | ||
| // Thirty days exceeds the browser's signed 32-bit timer limit. | ||
| const boundedDelay = (delay: number) => Math.min(delay, 2_147_000_000); | ||
| let timer = setTimeout(checkCooldown, boundedDelay(remaining)); | ||
| function checkCooldown() { | ||
| const remaining = target - Date.now(); | ||
| if (remaining <= 0) { | ||
| cooldownTick += 1; | ||
| return; | ||
| } | ||
| timer = setTimeout(checkCooldown, boundedDelay(remaining)); | ||
| } | ||
| return () => clearTimeout(timer); | ||
| }); | ||
|
|
||
| const save = async () => { | ||
| if (unchanged || locked || busy) return; | ||
| busy = true; | ||
| try { | ||
| current = await changeOrgSlug(token, slug); | ||
| slug = current.slug; | ||
| onchanged(current); | ||
| toasts.success(`Content now lives at ${current.contentOrigin}`); | ||
| await invalidateAll().catch((cause) => { | ||
| toasts.error(cause, 'Could not refresh the page'); | ||
| }); | ||
| } catch (cause) { | ||
| toasts.error(cause, 'Could not change the slug'); | ||
| } finally { | ||
| busy = false; | ||
| } | ||
| }; | ||
| </script> | ||
|
|
||
| <form | ||
| class="flex max-w-xl items-end gap-2" | ||
| onsubmit={(event) => { | ||
| event.preventDefault(); | ||
| void save(); | ||
| }} | ||
| > | ||
| <label class="min-w-0 flex-1 text-sm"> | ||
| <span class="font-medium text-zinc-700">Slug</span> | ||
| <input | ||
| bind:value={slug} | ||
| disabled={locked || busy} | ||
| autocapitalize="off" | ||
| autocorrect="off" | ||
| spellcheck="false" | ||
| class="mt-2 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm disabled:bg-zinc-50 disabled:text-zinc-500" | ||
| /> | ||
| </label> | ||
| <Button type="submit" disabled={unchanged || locked || busy}> | ||
| Change slug | ||
| </Button> | ||
| </form> | ||
| <p class="mt-2 text-xs leading-5 text-zinc-500"> | ||
| {#if locked && current.nextSlugChangeAt} | ||
| Changed recently; it can change again on {formatDate( | ||
| current.nextSlugChangeAt | ||
| )}. | ||
| {:else} | ||
| Once per 30 days. The old address redirects for 30 days. | ||
| {/if} | ||
| </p> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 High
README.md:108This allowlist trusts the public
.100.100.40.20.nip.iosuffix, so DNS rebinding can make Vite serve the dev server to an attacker-controlled host. Use a developer-controlled wildcard domain or locally controlled DNS zone for__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTSinstead.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: