Merge pull request #95 from richardr1126/redesign/landing-privacy

Redesign public landing & privacy pages
This commit is contained in:
Richard R 2026-05-31 16:56:05 -06:00 committed by GitHub
commit e3044278b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1907 additions and 1099 deletions

View file

@ -41,7 +41,6 @@ docker run --name openreader \
-p 3003:3003 \
-p 8333:8333 \
-v openreader_docstore:/app/docstore \
-v /path/to/your/library:/app/docstore/library:ro \
-e API_BASE=http://host.docker.internal:8880/v1 \
-e API_KEY=none \
-e BASE_URL=http://localhost:3003 \
@ -55,7 +54,6 @@ What this command enables:
- `-p 3003:3003`: exposes the OpenReader web app/API.
- `-p 8333:8333`: exposes embedded SeaweedFS S3 endpoint for direct browser presigned upload/download.
- `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state.
- `-v /path/to/your/library:/app/docstore/library:ro`: mounts a read-only importable library source.
- `-e API_BASE=...` / `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel).
- `-e BASE_URL=...` and `-e AUTH_SECRET=...`: required for v4+ auth/session startup.
- `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings.

View file

@ -10,7 +10,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { GithubIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
const searchParams = useSearchParams();

View file

@ -9,7 +9,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import toast from 'react-hot-toast';
export default function SignUpPage() {

View file

@ -1,338 +1,128 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import './public.css';
export default async function PublicLayout({ children }: { children: ReactNode }) {
const runtimeConfig = await getResolvedRuntimeConfigForRsc();
const enableUserSignups = runtimeConfig.enableUserSignups;
return (
<>
<style>{`
/* ── Keyframes ───────────────────── */
@keyframes landing-drift {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(15px, -10px) scale(1.03); }
66% { transform: translate(-10px, 8px) scale(0.97); }
}
@keyframes landing-fade-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes landing-scale-in {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); }
}
<div className="public-shell">
<div className="public-aurora" aria-hidden="true" />
<div className="public-grain" aria-hidden="true" />
/* ── Root ────────────────────────── */
.landing {
--g-display: var(--font-display), system-ui, -apple-system, sans-serif;
--g-body: var(--font-display), -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
--g-system: var(--font-display), -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
--g-bg: var(--background);
--g-fg: var(--foreground);
--g-surface: var(--base);
--g-border: var(--offbase);
--g-accent: var(--accent);
--g-accent2: var(--secondary-accent);
--g-muted: var(--muted);
font-family: var(--g-body);
background: var(--g-bg);
color: var(--g-fg);
min-height: 100vh;
overflow-x: hidden;
position: relative;
}
/* ── Ambient orbs ────────────────── */
.landing-orbs {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.landing-orb {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.12;
animation: landing-drift 20s ease-in-out infinite;
}
.landing-orb-1 {
width: 500px; height: 500px;
background: var(--g-accent);
top: -10%; left: -8%;
animation-delay: 0s;
}
.landing-orb-2 {
width: 400px; height: 400px;
background: var(--g-accent2);
top: 40%; right: -12%;
animation-delay: -7s;
}
.landing-orb-3 {
width: 350px; height: 350px;
background: var(--g-accent);
bottom: -5%; left: 30%;
animation-delay: -14s;
}
/* ── Content wrapper ─────────────── */
.landing-content {
position: relative;
z-index: 1;
}
/* ── Glass panel ─────────────────── */
.landing-panel {
background: color-mix(in srgb, var(--g-surface), transparent 30%);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border: 1px solid color-mix(in srgb, var(--g-border), transparent 50%);
border-radius: 1.25rem;
}
/* ── Sticky header wrapper ───────── */
.landing-header-wrap {
position: sticky;
top: 0;
z-index: 100;
padding: 1rem 1.5rem 0;
max-width: 72rem;
margin: 0 auto;
animation: landing-fade-up 0.6s ease-out both;
}
/* ── Header ──────────────────────── */
.landing-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
flex-wrap: wrap;
gap: 0.75rem;
box-shadow: 0 2px 16px color-mix(in srgb, var(--g-bg), transparent 40%);
}
.landing-logo {
font-family: var(--g-display);
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.02em;
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--g-fg);
}
.landing-logo-icon {
width: 20px;
height: 20px;
}
.landing-header-nav {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.landing-header-nav a {
font-family: var(--g-system);
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--g-border);
background: var(--g-surface);
text-decoration: none;
color: var(--g-fg);
transform: translateZ(0);
transition: all 200ms ease-in-out;
}
.landing-header-nav a:hover {
background: var(--g-border);
transform: scale(1.09);
color: var(--g-accent);
}
.landing-header-nav a:focus {
outline: none;
box-shadow: 0 0 0 2px var(--g-accent), 0 0 0 4px var(--g-bg);
}
.landing-header-nav a.landing-primary {
background: var(--g-accent);
color: var(--g-bg);
border-color: var(--g-accent);
font-weight: 500;
}
.landing-header-nav a.landing-primary:hover {
background: var(--g-accent2);
border-color: var(--g-accent2);
color: var(--g-bg);
transform: scale(1.09);
}
/* ── Buttons ─────────────────────── */
.landing-btn {
font-family: var(--g-system);
font-size: 0.875rem;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
transform: translateZ(0);
transition: transform 200ms ease-in-out, background 200ms, box-shadow 200ms;
}
.landing-btn:hover {
transform: scale(1.02);
}
.landing-btn:focus {
outline: none;
box-shadow: 0 0 0 2px var(--g-accent), 0 0 0 4px var(--g-bg);
}
.landing-btn-accent {
background: var(--g-accent);
color: var(--g-bg);
}
.landing-btn-accent:hover {
background: var(--g-accent2);
}
.landing-btn-ghost {
color: var(--g-fg);
background: var(--g-surface);
border: 1px solid var(--g-border);
}
.landing-btn-ghost:hover {
background: var(--g-border);
color: var(--g-accent);
}
/* ── Footer ──────────────────────── */
.landing-footer {
text-align: center;
padding: 2rem 1.5rem 3rem;
font-family: var(--g-display);
font-size: 0.75rem;
font-weight: 500;
color: var(--g-muted);
letter-spacing: 0.05em;
position: relative;
z-index: 1;
}
.landing-footer-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.4rem 0;
}
.landing-footer-dot {
display: inline-block;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--g-accent);
margin: 0 0.6rem;
vertical-align: middle;
opacity: 0.6;
}
.landing-footer a {
color: var(--g-muted);
text-decoration: none;
transition: color 0.2s;
}
.landing-footer a:hover {
color: var(--g-fg);
}
.landing-footer-link-dotted {
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
}
.landing-footer-link-bold {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
.landing * {
animation: none !important;
transition: none !important;
}
}
`}</style>
<div className="landing">
{/* ── Ambient orbs ────────────── */}
<div className="landing-orbs" aria-hidden="true">
<div className="landing-orb landing-orb-1" />
<div className="landing-orb landing-orb-2" />
<div className="landing-orb landing-orb-3" />
</div>
<div className="landing-content">
{/* ── HEADER ─────────────────── */}
<div className="landing-header-wrap">
<header className="landing-header landing-panel">
<Link href="/" className="landing-logo">
<div className="public-frame">
<div className="public-topbar">
<div className="public-wrap">
<header className="public-topbar-inner public-reveal-1">
<Link href="/" className="public-brand" aria-label="OpenReader home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="landing-logo-icon" aria-hidden="true" />
OpenReader
<img src="/icon.svg" alt="" className="public-brand-mark" aria-hidden="true" />
<span className="public-brand-copy">
<span className="public-brand-text">OpenReader</span>
<span className="public-brand-tag">Read&nbsp;·&nbsp;Listen</span>
</span>
</Link>
<nav className="landing-header-nav">
<Link href="/app" className="landing-primary">Open App</Link>
<Link href="/signin">Sign In</Link>
{enableUserSignups && <Link href="/signup">Sign Up</Link>}
<Link href="https://docs.openreader.richardr.dev/">Docs</Link>
<nav className="public-nav" aria-label="Primary">
<Link
href="https://docs.openreader.richardr.dev/"
className="public-nav-link"
>
Docs
</Link>
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className="public-nav-link"
>
GitHub
</a>
<span className="public-nav-divider" aria-hidden="true" />
<Link href="/signin" className={buttonClass({ variant: 'ghost', size: 'sm' })}>
Sign in
</Link>
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'sm' })}>
Open app
</Link>
</nav>
</header>
</div>
{/* ── PAGE CONTENT ───────────── */}
{children}
{/* ── FOOTER ─────────────────── */}
<footer className="landing-footer">
<div className="landing-footer-inner">
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className="landing-footer-link-bold"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z"/></svg>
Self host
</a>
<span className="landing-footer-dot" />
<Link href="/privacy" className="landing-footer-link-bold">
Privacy
</Link>
<span className="landing-footer-dot" />
<span>
Powered by{' '}
<a
href="https://huggingface.co/hexgrad/Kokoro-82M"
target="_blank"
rel="noopener noreferrer"
className="landing-footer-link-dotted"
>
hexgrad/Kokoro-82M
</a>
{' '}and{' '}
<a
href="https://deepinfra.com/models?type=text-to-speech"
target="_blank"
rel="noopener noreferrer"
className="landing-footer-link-dotted"
>
Deepinfra
</a>
</span>
</div>
</footer>
</div>
{children}
<footer className="public-footer">
<div className="public-wrap">
<div className="public-footer-inner">
<div className="public-footer-brand">
<div className="public-footer-mark">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="public-brand-mark" aria-hidden="true" />
<span className="public-brand-text">OpenReader</span>
</div>
<p className="public-footer-label">
An open-source reading room that turns documents into
synchronized, listenable audio that&rsquo;s yours to self-host.
</p>
<div className="public-footer-cta">
{enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'sm' })}>
Create account
</Link>
) : null}
<a
href="https://github.com/richardr1126/openreader"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'ghost', size: 'sm' })}
>
Star on GitHub
</a>
</div>
</div>
<nav className="public-footer-cols" aria-label="Footer">
<div className="public-footer-col">
<p className="public-footer-col-title">Product</p>
<Link href="/app">Open app</Link>
<Link href="/signin">Sign in</Link>
<a href="https://docs.openreader.richardr.dev/" target="_blank" rel="noopener noreferrer">
Documentation
</a>
</div>
<div className="public-footer-col">
<p className="public-footer-col-title">Project</p>
<a href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer">
GitHub
</a>
<a href="https://github.com/richardr1126/openreader/discussions" target="_blank" rel="noopener noreferrer">
Discussions
</a>
<a href="https://github.com/richardr1126/openreader/issues" target="_blank" rel="noopener noreferrer">
Issues
</a>
</div>
<div className="public-footer-col">
<p className="public-footer-col-title">Legal</p>
<Link href="/privacy">Privacy &amp; data</Link>
<a href="https://github.com/richardr1126/openreader/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">
MIT license
</a>
</div>
</nav>
</div>
<div className="public-footer-base">
<div className="prism-divider" />
<p>© {new Date().getFullYear()} OpenReader · MIT licensed · Self-host friendly</p>
</div>
</div>
</footer>
</div>
</>
</div>
);
}

View file

@ -1,30 +1,32 @@
import type { CSSProperties } from 'react';
import type { Metadata } from 'next';
import Link from 'next/link';
import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc';
import { buttonClass } from '@/components/ui/buttonPrimitives';
export const metadata: Metadata = {
title: 'Read and Listen to Documents',
title: 'Open Source Read-Along Workspace',
description:
'OpenReader lets you upload EPUB, PDF, TXT, MD, and DOCX files for synchronized text-to-speech reading with multi-provider TTS support.',
'OpenReader converts EPUB, PDF, TXT, MD, and DOCX files into synchronized read-along audio with multi-provider text-to-speech support.',
keywords:
'PDF reader, EPUB reader, text to speech, tts open ai, kokoro tts, kitten tts, OpenReader, TTS PDF reader, ebook reader, epub tts, document reader',
'OpenReader, document reader, PDF read aloud, EPUB read aloud, text to speech, OpenAI compatible TTS, self-hosted reader',
alternates: {
canonical: '/',
},
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://openreader.richardr.dev',
url: process.env.BASE_URL || 'https://openreader.richardr.dev',
siteName: 'OpenReader',
title: 'OpenReader | Read and Listen to Documents',
title: 'OpenReader | Read documents with synchronized audio',
description:
'Upload EPUB, PDF, TXT, MD, and DOCX files, then listen with synchronized read-along playback using OpenAI-compatible TTS providers.',
'Upload documents and turn them into a synchronized listening experience with word-level highlighting and audiobook export.',
images: [
{
url: '/web-app-manifest-512x512.png',
width: 512,
height: 512,
alt: 'OpenReader Logo',
alt: 'OpenReader icon',
},
],
},
@ -41,422 +43,332 @@ export const metadata: Metadata = {
},
};
// Words that receive the looping read-along highlight sweep in the hero. This
// is the product's actual word-level highlighting, used as the page's signature.
const SWEEP = ['highlighted', 'word', 'by', 'word.'];
const PROVIDERS = ['Kokoro', 'KittenTTS', 'Orpheus', 'OpenAI', 'Replicate', 'DeepInfra'];
const FORMATS = ['EPUB', 'PDF', 'TXT', 'MD', 'DOCX'];
export default async function LandingPage() {
const runtimeConfig = await getResolvedRuntimeConfigForRsc();
const enableUserSignups = runtimeConfig.enableUserSignups;
const instanceBadge =
process.env.RICHARDRDEV_PRODUCTION === 'true'
? 'Official OpenReader instance'
: 'Open-source document reader';
return (
<>
<style>{`
/* ── Hero ────────────────────────── */
.landing-hero {
max-width: 72rem;
margin: 0 auto;
padding: 3rem 1.5rem 4rem;
text-align: center;
animation: landing-fade-up 0.45s ease-out both;
}
.landing-hero-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--g-display);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--g-accent);
padding: 0.4rem 1rem;
border-radius: 2rem;
border: 1px solid color-mix(in srgb, var(--g-accent), transparent 70%);
background: color-mix(in srgb, var(--g-accent), transparent 92%);
margin-bottom: 2rem;
}
.landing-hero h1 {
font-family: var(--g-display);
font-weight: 800;
font-size: clamp(2rem, 5.5vw, 4rem);
line-height: 1.1;
letter-spacing: -0.03em;
max-width: 18ch;
margin: 0 auto 1.5rem;
}
.landing-hero h1 span {
background: linear-gradient(135deg, var(--g-accent), var(--g-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.landing-hero-desc {
font-family: var(--g-body);
font-size: 1.1rem;
line-height: 1.7;
color: var(--g-muted);
max-width: 52ch;
margin: 0 auto 2.5rem;
}
.landing-hero-actions {
display: flex;
justify-content: center;
gap: 0.6rem;
flex-wrap: wrap;
}
/* ── Features ────────────────────── */
.landing-features {
max-width: 72rem;
margin: 0 auto;
padding: 0 1.5rem 5rem;
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
}
@media (min-width: 640px) {
.landing-features {
grid-template-columns: 1fr 1fr;
}
}
@media (min-width: 1024px) {
.landing-features {
grid-template-columns: 1fr 1fr 1fr;
}
}
.landing-feature-card {
padding: 2rem;
animation: landing-scale-in 0.45s ease-out both;
transition: transform 0.3s, border-color 0.3s;
}
.landing-feature-card:hover {
transform: translateY(-4px);
border-color: color-mix(in srgb, var(--g-accent), transparent 50%);
}
.landing-feature-icon {
width: 3rem; height: 3rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--g-accent), transparent 85%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
font-size: 1.25rem;
color: var(--g-accent);
font-weight: 700;
font-family: var(--g-display);
}
.landing-feature-card h3 {
font-family: var(--g-display);
font-weight: 700;
font-size: 1.15rem;
margin: 0 0 0.6rem;
letter-spacing: -0.01em;
}
.landing-feature-card p {
font-size: 0.92rem;
line-height: 1.65;
color: var(--g-muted);
}
/* ── TTS spotlight ────────────────── */
.landing-tts {
max-width: 72rem;
margin: 0 auto;
padding: 0 1.5rem 5rem;
animation: landing-fade-up 0.45s ease-out both;
}
.landing-tts-inner {
display: grid;
grid-template-columns: 1fr;
gap: 2.5rem;
padding: 2.5rem;
}
@media (min-width: 768px) {
.landing-tts-inner {
grid-template-columns: 1fr 1fr;
}
}
.landing-tts-lead h2 {
font-family: var(--g-display);
font-weight: 700;
font-size: clamp(1.4rem, 3vw, 2rem);
letter-spacing: -0.02em;
margin: 0 0 0.75rem;
line-height: 1.2;
}
.landing-tts-lead h2 span {
background: linear-gradient(135deg, var(--g-accent), var(--g-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.landing-tts-lead > p {
font-size: 0.95rem;
line-height: 1.7;
color: var(--g-muted);
}
.landing-tts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.landing-tts-list li {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.landing-tts-list-icon {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: color-mix(in srgb, var(--g-accent), transparent 85%);
display: flex;
align-items: center;
justify-content: center;
color: var(--g-accent);
font-size: 0.85rem;
font-weight: 700;
font-family: var(--g-display);
margin-top: 0.1rem;
}
.landing-tts-list h4 {
font-family: var(--g-display);
font-weight: 600;
font-size: 0.92rem;
margin: 0 0 0.2rem;
}
.landing-tts-list p {
font-size: 0.84rem;
line-height: 1.55;
color: var(--g-muted);
margin: 0;
}
/* ── Formats ribbon ──────────────── */
.landing-formats {
max-width: 72rem;
margin: 0 auto;
padding: 0 1.5rem 5rem;
text-align: center;
animation: landing-fade-up 0.45s ease-out both;
}
.landing-formats-label {
font-family: var(--g-display);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--g-muted);
margin-bottom: 1.25rem;
}
.landing-formats-row {
display: flex;
justify-content: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.landing-format-pill {
font-family: var(--g-display);
font-weight: 600;
font-size: 0.82rem;
padding: 0.5rem 1.25rem;
border-radius: 2rem;
background: color-mix(in srgb, var(--g-surface), transparent 30%);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--g-border), transparent 50%);
transition: border-color 0.25s, transform 0.2s;
cursor: default;
}
.landing-format-pill:hover {
border-color: var(--g-accent);
transform: translateY(-2px);
}
/* ── CTA section ─────────────────── */
.landing-cta {
max-width: 48rem;
margin: 0 auto;
padding: 0 1.5rem 5rem;
animation: landing-fade-up 0.45s ease-out both;
}
.landing-cta-card {
padding: 3rem 2rem;
text-align: center;
position: relative;
overflow: hidden;
}
.landing-cta-glow {
position: absolute;
width: 200px; height: 200px;
border-radius: 50%;
background: var(--g-accent);
filter: blur(80px);
opacity: 0.08;
top: -50px; right: -50px;
pointer-events: none;
}
.landing-cta-card h2 {
font-family: var(--g-display);
font-weight: 700;
font-size: clamp(1.4rem, 3vw, 2rem);
letter-spacing: -0.02em;
margin: 0 0 0.6rem;
position: relative;
}
.landing-cta-card > p {
font-size: 0.95rem;
color: var(--g-muted);
margin-bottom: 2rem;
max-width: 40ch;
margin-left: auto;
margin-right: auto;
position: relative;
}
.landing-cta-actions {
display: flex;
justify-content: center;
gap: 0.5rem;
flex-wrap: wrap;
position: relative;
}
`}</style>
{/* ── HERO ───────────────────── */}
<section className="landing-hero">
<div className="landing-hero-badge">
{process.env.RICHARDRDEV_PRODUCTION === 'true'
? "Official OpenReader Instance"
: "Open Source Document Reader"
}
</div>
<h1>
Your documents, <span>read&nbsp;aloud</span>
</h1>
<p className="landing-hero-desc">
Upload EPUB, PDF, TXT, MD, and DOCX files, then listen with your
preferred OpenAI-compatible TTS provider. Your reading progress
syncs across devices automatically.
</p>
<div className="landing-hero-actions">
<Link href="/app" className="landing-btn landing-btn-accent">
Open App
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4" /></svg>
</Link>
<Link href="/signin" className="landing-btn landing-btn-ghost">Sign In</Link>
{enableUserSignups && <Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>}
<Link href="https://docs.openreader.richardr.dev/" className="landing-btn landing-btn-ghost">Docs</Link>
</div>
</section>
{/* ── FEATURES ───────────────── */}
<section className="landing-features">
<div className="landing-feature-card landing-panel">
<div className="landing-feature-icon" aria-hidden="true">&uarr;</div>
<h3>Upload documents</h3>
<p>
Drag and drop EPUB, PDF, TXT, Markdown, or DOCX files directly
into the app. Documents process instantly for reading and
text-to-speech playback.
</p>
</div>
<div className="landing-feature-card landing-panel">
<div className="landing-feature-icon" aria-hidden="true">&para;</div>
<h3>Your library</h3>
<p>
Build a personal library with folders. Documents sync
automatically so your collection is always within
reach.
</p>
</div>
<div className="landing-feature-card landing-panel">
<div className="landing-feature-icon" aria-hidden="true">&harr;</div>
<h3>Cross-device sync</h3>
<p>
Reading progress, preferences, and library state sync across
devices. Pick up exactly where you left off on any browser.
</p>
</div>
</section>
{/* ── TTS SPOTLIGHT ──────────── */}
<section className="landing-tts">
<div className="landing-tts-inner landing-panel">
<div className="landing-tts-lead">
<h2>
<span>Text-to-speech</span> that follows along as you read
</h2>
<p>
OpenReader highlights every word as it&rsquo;s spoken, turning
any document into a synchronized read-along experience. Connect
any OpenAI-compatible TTS provider &mdash; including Kokoro,
KittenTTS, Deepinfra, or your own self-hosted endpoint.
<main className="public-main">
{/* ───────────────────────────── Hero ───────────────────────────── */}
<section className="public-hero">
<div className="public-wrap public-hero-grid">
<div className="public-hero-lede public-reveal-1">
<p className="public-eyebrow">
<span className="public-eyebrow-dot" aria-hidden="true" />
{instanceBadge}
</p>
<h1 className="public-hero-title">
Hear every document,
<br />
<span className="public-sweep" aria-hidden="true">
{SWEEP.map((word, i) => (
<span
key={word + i}
className="public-sweep-word"
style={{ '--i': i } as CSSProperties}
>
{word}
{i < SWEEP.length - 1 ? ' ' : ''}
</span>
))}
</span>
<span className="public-visually-hidden">highlighted word by word.</span>
</h1>
<p className="public-hero-copy">
OpenReader turns EPUB, PDF, TXT, Markdown, and DOCX into a
synchronized read-along surface, reading your original file in a
native viewer with genuine text-to-speech, word-level
highlighting, and audiobook export. It&rsquo;s open source, and
entirely yours to self-host.
</p>
<div className="public-actions">
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'lg' })}>
Open the reader
</Link>
{enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'lg' })}>
Create account
</Link>
) : (
<Link href="/signin" className={buttonClass({ variant: 'outline', size: 'lg' })}>
Sign in
</Link>
)}
<Link
href="https://docs.openreader.richardr.dev/"
className={buttonClass({ variant: 'ghost', size: 'lg' })}
>
Read the docs
</Link>
</div>
<div className="public-formats" aria-label="Supported formats">
<span className="public-formats-label">Reads</span>
<div className="public-formats-list">
{FORMATS.map((fmt) => (
<span key={fmt} className="public-format-chip">
{fmt}
</span>
))}
</div>
</div>
</div>
<ul className="landing-tts-list">
<li>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span>
<div>
<h4>Word-level highlighting</h4>
<p>Each word lights up in sync with the audio so you never lose your place.</p>
</div>
</li>
<li>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span>
<div>
<h4>Multiple voices &amp; providers</h4>
<p>Choose from dozens of voices across OpenAI, Kokoro, KittenTTS, Deepinfra, or any compatible endpoint.</p>
</div>
</li>
<li>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span>
<div>
<h4>Speed controls</h4>
<p>Independent model speed and playback speed sliders from 0.5x to 3x.</p>
</div>
</li>
<li>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span>
<div>
<h4>Audiobook export</h4>
<p>Convert any document to a downloadable MP3 or M4A audiobook with chapter metadata.</p>
</div>
</li>
</ul>
</div>
</section>
{/* ── FORMATS ────────────────── */}
<section className="landing-formats">
<p className="landing-formats-label">Supported formats</p>
<div className="landing-formats-row">
<span className="landing-format-pill">EPUB</span>
<span className="landing-format-pill">PDF</span>
<span className="landing-format-pill">TXT</span>
<span className="landing-format-pill">MD</span>
<span className="landing-format-pill">DOCX</span>
</div>
</section>
{/* Reader mockup: demonstrates the synchronized highlight + playback */}
<aside className="public-reader public-reveal-2" aria-hidden="true">
<div className="public-reader-glow" aria-hidden="true" />
<div className="public-reader-bar">
<span className="public-reader-dot" data-tone="a" />
<span className="public-reader-dot" data-tone="b" />
<span className="public-reader-dot" data-tone="c" />
<span className="public-reader-file">wizard-of-oz.epub</span>
<span className="public-reader-voice">Kokoro · af_sky</span>
</div>
{/* ── CTA ────────────────────── */}
<section className="landing-cta">
<div className="landing-cta-card landing-panel">
<div className="landing-cta-glow" aria-hidden="true" />
<h2>Start reading now</h2>
<p>
Open the app and upload a document to begin.
Your progress syncs across devices automatically.
</p>
<div className="landing-cta-actions">
<Link href="/app" className="landing-btn landing-btn-accent">Open App</Link>
<Link href="/signin" className="landing-btn landing-btn-ghost">Sign In</Link>
{enableUserSignups && <Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>}
<Link href="https://docs.openreader.richardr.dev/" className="landing-btn landing-btn-ghost">Docs</Link>
<div className="public-reader-body">
<p className="public-reader-chapter">Chapter 1 · The Cyclone</p>
<p className="public-reader-text">
Dorothy lived in the midst of the great Kansas prairies, with
Uncle Henry, who was a farmer, and Aunt Em, who was the
farmer&rsquo;s wife.{' '}
<span className="public-reader-sentence">
Their house was small, for the lumber to build it had to be
carried by <span className="public-reader-word">wagon</span>{' '}
many miles.
</span>{' '}
There were four walls, a floor and a roof, which made one room.
</p>
</div>
<div className="public-player">
<button type="button" className="public-player-play" aria-label="Playing" tabIndex={-1}>
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path d="M8 5v14l11-7z" fill="currentColor" />
</svg>
</button>
<div className="public-wave" aria-hidden="true">
{Array.from({ length: 28 }).map((_, i) => (
<span
key={i}
className="public-wave-bar"
style={{ '--b': i } as CSSProperties}
/>
))}
</div>
<span className="public-player-time">04:12 / 18:30</span>
<span className="public-player-speed">1.1×</span>
</div>
</aside>
</div>
<div className="public-wrap">
<div className="public-providers public-reveal-3" aria-label="Supported text-to-speech providers">
<span className="public-providers-label">Speaks through</span>
<div className="public-providers-track">
{PROVIDERS.map((p) => (
<span key={p} className="public-provider">
{p}
</span>
))}
</div>
</div>
</div>
</section>
</>
{/* ────────────────────────── How it works ───────────────────────── */}
<section className="public-section" aria-labelledby="how-heading">
<div className="public-wrap">
<div className="public-section-head">
<p className="public-kicker">The flow</p>
<h2 id="how-heading">From a raw file to a voice in three moves.</h2>
</div>
<ol className="public-steps">
<li className="public-step">
<span className="public-step-num">01</span>
<h3>Upload a document</h3>
<p>
Drop an EPUB, PDF, TXT, Markdown, or DOCX into your library, or
import one straight from the server, and it stays organized for
every session after.
</p>
</li>
<li className="public-step">
<span className="public-step-num">02</span>
<h3>Pick a voice</h3>
<p>
Choose a provider and model: hosted OpenAI, Replicate, or
DeepInfra, or your own self-hosted Kokoro, KittenTTS, or Orpheus
server. Set the speed to your pace.
</p>
</li>
<li className="public-step">
<span className="public-step-num">03</span>
<h3>Read, listen, export</h3>
<p>
Follow word-level highlighting right on the original page as it
plays, pick up where you left off on any device, and export a
chaptered m4b or mp3 audiobook for the road.
</p>
</li>
</ol>
</div>
</section>
{/* ──────────────────────────── Features ─────────────────────────── */}
<section className="public-section" aria-labelledby="features-heading">
<div className="public-wrap">
<div className="public-section-head">
<p className="public-kicker">Under the hood</p>
<h2 id="features-heading">Engineered for deep reading, not playback demos.</h2>
</div>
<div className="public-features">
<article className="public-feature public-feature-wide">
<span className="public-feature-kicker">Formats</span>
<h3>Native EPUB and PDF, kept intact</h3>
<p>
Your file renders in a built-in EPUB and PDF reader, never
flattened to plain text. Layout-aware parsing (PP-DocLayoutV3,
ONNX) maps the structure underneath, so read-along highlighting
follows the true reading order, even in dense, multi-column
PDFs.
</p>
</article>
<article className="public-feature">
<span className="public-feature-kicker">Alignment</span>
<h3>Word-by-word timing</h3>
<p>
ONNX Whisper alignment through a JetStream-backed compute worker
maps each spoken word back to the page, so the cursor tracks
speech precisely.
</p>
</article>
<article className="public-feature">
<span className="public-feature-kicker">Voices</span>
<h3>Multi-provider TTS</h3>
<p>
Mix cloud APIs with OpenAI-compatible local servers. Bring your
own keys and endpoints, with no lock-in to a single vendor.
</p>
</article>
<article className="public-feature">
<span className="public-feature-kicker">Export</span>
<h3>Audiobook output</h3>
<p>
Render chaptered m4b and mp3 files with resumable processing,
ready for any offline player you already use.
</p>
</article>
<article className="public-feature">
<span className="public-feature-kicker">Sync</span>
<h3>Progress that follows you</h3>
<p>
Reading position, queue state, and per-document settings sync
across browser sessions and devices through your account.
</p>
</article>
<article className="public-feature public-feature-wide">
<span className="public-feature-kicker">Backend</span>
<h3>A stack you actually control</h3>
<p>
Run on embedded SeaweedFS or any S3-compatible bucket, back it
with SQLite or Postgres, and ship it with Docker on amd64 or
arm64, with built-in auth and automatic startup migrations.
</p>
</article>
</div>
</div>
</section>
{/* ───────────────────────── Self-host CTA ───────────────────────── */}
<section className="public-section" aria-labelledby="selfhost-heading">
<div className="public-wrap">
<div className="public-callout">
<div className="public-callout-glow" aria-hidden="true" />
<div className="public-callout-copy">
<p className="public-kicker">Open source · MIT</p>
<h2 id="selfhost-heading">Run your own private reading stack.</h2>
<p className="public-callout-text">
Deploy OpenReader for yourself or your team in minutes. The docs
cover Docker, provider integration, object storage, and the
external compute worker. Every piece is yours to host.
</p>
<div className="public-actions">
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'primary', size: 'lg' })}
>
View the repository
</a>
<a
href="https://docs.openreader.richardr.dev/docker-quick-start"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'lg' })}
>
Deployment guides
</a>
</div>
</div>
<div className="public-terminal" aria-hidden="true">
<div className="public-terminal-bar">
<span className="public-reader-dot" data-tone="a" />
<span className="public-reader-dot" data-tone="b" />
<span className="public-reader-dot" data-tone="c" />
<span className="public-terminal-title">quick start</span>
</div>
<pre className="public-terminal-body">
<code>
<span className="public-term-comment"># pull and run</span>
{'\n'}
<span className="public-term-prompt">$</span> docker run --name openreader \{'\n'}
{' '}-p <span className="public-term-accent">3003:3003</span> -p{' '}
<span className="public-term-accent">8333:8333</span> \{'\n'}
{' '}-v <span className="public-term-accent">openreader_docstore:/app/docstore</span> \{'\n'}
{' '}-e BASE_URL=
<span className="public-term-accent">http://localhost:3003</span> \{'\n'}
{' '}-e AUTH_SECRET=
<span className="public-term-accent">$(openssl rand -hex 32)</span> \{'\n'}
{' '}-e ADMIN_EMAILS=
<span className="public-term-accent">you@example.com</span> \{'\n'}
{' '}ghcr.io/richardr1126/openreader:latest{'\n'}
{'\n'}
<span className="public-term-comment"># open the reading room</span>
{'\n'}
<span className="public-term-prompt">$</span> open{' '}
<span className="public-term-accent">localhost:3003</span>
<span className="public-term-caret" />
</code>
</pre>
</div>
</div>
</div>
</section>
</main>
);
}

View file

@ -1,11 +1,12 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { headers } from 'next/headers';
import { buttonClass } from '@/components/ui/buttonPrimitives';
export const metadata: Metadata = {
title: 'Privacy & Data Usage | OpenReader',
title: 'Privacy & Data Usage',
description:
'Learn how OpenReader handles your data, what is stored in your browser, and what is sent to the server.',
'How OpenReader collects, stores, and processes data, including usage analytics controls and your account rights.',
alternates: {
canonical: '/privacy',
},
@ -18,7 +19,7 @@ export const metadata: Metadata = {
export default async function PrivacyPage() {
const effectiveDate = 'February 17, 2026';
const isRichardrDevProductionInstance =
process.env.RICHARDRDEV_PRODUCTION === 'true';
process.env.RICHARDRDEV_PRODUCTION?.trim().toLowerCase() === 'true';
const hdrs = await headers();
const host = hdrs.get('host') ?? 'this server';
@ -26,308 +27,184 @@ export default async function PrivacyPage() {
const origin = `${proto}://${host}`;
return (
<>
<style>{`
/* ── Privacy body ───────────────── */
.privacy-body {
max-width: 42rem;
margin: 0 auto;
padding: 3rem 1.5rem 4rem;
animation: landing-fade-up 0.7s ease-out 0.15s both;
}
.privacy-body h1 {
font-family: var(--g-display);
font-weight: 800;
font-size: clamp(1.5rem, 4vw, 2.25rem);
letter-spacing: -0.03em;
margin: 0 0 0.5rem;
}
.privacy-body h1 span {
background: linear-gradient(135deg, var(--g-accent), var(--g-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.privacy-subtitle {
font-size: 0.95rem;
color: var(--g-muted);
margin: 0 0 2.5rem;
line-height: 1.6;
}
.privacy-card {
padding: 2rem;
margin-bottom: 1.25rem;
}
.privacy-card-label {
font-family: var(--g-display);
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--g-accent);
margin: 0 0 0.75rem;
}
.privacy-card p,
.privacy-card li {
font-size: 0.92rem;
line-height: 1.65;
color: var(--g-fg);
}
.privacy-card ul {
list-style: none;
margin: 0;
padding: 0;
}
.privacy-card li {
position: relative;
padding-left: 1.1rem;
margin-bottom: 0.4rem;
}
.privacy-card li::before {
content: '';
position: absolute;
left: 0;
top: 0.55em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--g-accent);
opacity: 0.5;
}
.privacy-highlight {
background: color-mix(in srgb, var(--g-accent), transparent 88%);
border: 1px solid color-mix(in srgb, var(--g-accent), transparent 70%);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
font-size: 0.88rem;
line-height: 1.6;
color: var(--g-fg);
}
.privacy-highlight strong {
color: var(--g-accent);
font-weight: 600;
}
.privacy-note {
font-size: 0.8rem;
color: var(--g-muted);
line-height: 1.6;
margin-top: 2rem;
}
.privacy-note a {
color: var(--g-accent);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
}
.privacy-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--g-system);
font-size: 0.875rem;
font-weight: 500;
color: var(--g-accent);
text-decoration: none;
margin-top: 2rem;
transition: opacity 0.2s;
}
.privacy-back:hover {
opacity: 0.75;
}
`}</style>
<div className="privacy-body">
<h1>Privacy &amp; <span>Data Usage</span></h1>
<p className="privacy-subtitle">
Effective Date: {effectiveDate}
</p>
<div className="privacy-highlight">
This OpenReader instance is hosted at <strong>{origin}</strong>.
The operator of this service is responsible for handling your information.
</div>
{isRichardrDevProductionInstance ? (
<div className="privacy-highlight">
<strong>US-only availability:</strong> This instance is intended for
users located in the United States. Requests from outside the US,
or requests without reliable country metadata, may be blocked.
<main className="public-main policy-main">
<div className="public-wrap">
<section className="public-panel policy-hero public-reveal-1">
<h1>Privacy, data flow, and account controls.</h1>
<p>
This policy explains what data OpenReader processes, how it is used, and the controls available to you.
It applies to the instance currently running at <strong>{origin}</strong>. Effective date: {effectiveDate}.
</p>
<div className="policy-badges" aria-label="Key policy facts">
<span className="policy-badge">No personal data sales</span>
<span className="policy-badge">Analytics consent controls</span>
<span className="policy-badge">Account export + deletion tools</span>
<span className="policy-badge">Encrypted document storage</span>
</div>
) : null}
<div className="privacy-highlight">
<strong>OpenReader does not sell your personal information.</strong> We do not sell data to data brokers or third parties.
We use data solely to provide and improve the reading experience.
</div>
<div className="space-y-8">
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
1. Information We Collect (CCPA Categories)
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
We collect information that identifies, relates to, describes, references, or is reasonably capable of being associated with you (&quot;<strong>Personal Information</strong>&quot;).
</p>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Categories Collected</div>
<ul className="space-y-3">
<li>
<strong className="text-foreground">Identifiers:</strong> Email address, IP address, unique personal identifier (session token), and account name.
<div className="text-xs text-muted-foreground mt-1">Source: Directly from you. Purpose: Authentication, security, providing service.</div>
</li>
<li>
<strong className="text-foreground">Customer Records:</strong> Uploaded documents (PDF, EPUB), reading progress, bookmarks, and preferences.
<div className="text-xs text-muted-foreground mt-1">Source: Directly from you. Purpose: Providing core reading functionality.</div>
</li>
<li>
<strong className="text-foreground">Internet Activity:</strong> Browsing history within the app, interaction with features (Analytics).
<div className="text-xs text-muted-foreground mt-1">Source: Automatic collection. Purpose: Debugging, performance optimization.</div>
</li>
</ul>
{isRichardrDevProductionInstance ? (
<div className="policy-highlight">
<strong>US-only availability:</strong> this official instance is intended for users located in the United States,
and requests outside the US may be blocked.
</div>
</section>
) : null}
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
2. How We Use Your Information
</h2>
<ul className="list-disc pl-5 space-y-2 text-sm text-foreground/90">
<li>To provide, support, and personalize the OpenReader application.</li>
<li>To process your uploaded documents for display and text-to-speech conversion.</li>
<li>To maintain the safety, security, and integrity of our service.</li>
<li>To debug and repair errors that impair existing intended functionality.</li>
{/* Claim audit
Code-verified: consent/GPC analytics handling, analytics toggle, export/delete controls, US geo-gate, AES256 storage headers.
Deployment-dependent: processing/storage region and exact provider infrastructure locations.
Operator-statement: OpenReader does not sell personal information.
*/}
<div className="policy-grid public-reveal-2">
<aside className="policy-nav" aria-label="Privacy sections">
<p className="policy-nav-title">On this page</p>
<ul className="policy-nav-list">
<li><a href="#info-collected">1. Information collected</a></li>
<li><a href="#usage-purpose">2. How data is used</a></li>
<li><a href="#sharing">3. Sharing and service providers</a></li>
<li><a href="#rights">4. Your rights</a></li>
<li><a href="#retention">5. Retention and security</a></li>
<li><a href="#processing-location">6. Processing location</a></li>
<li><a href="#contact">7. Contact and open source</a></li>
</ul>
</section>
</aside>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
3. Sharing &amp; Selling
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
<strong>We do not sell your personal information.</strong>
</p>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
We may &quot;share&quot; (as defined by CPRA for cross-context behavioral advertising) anonymous usage data with analytics providers solely to improve our app. You can opt-out of this sharing via the Cookie Banner, and Global Privacy Control (GPC) signals are automatically honored.
</p>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Service Providers (Sub-processors)</div>
<div className="policy-sections public-panel">
<section id="info-collected" className="policy-section">
<h2>1. Information we collect</h2>
<p>
OpenReader collects data needed to operate the service and maintain your reading state.
Categories include account identifiers, uploaded content, and product usage telemetry.
</p>
<ul className="policy-fact-list">
<li><strong>Identifiers:</strong> Email, session-related identifiers, account name, and IP metadata for authentication and security.</li>
<li><strong>Reader content:</strong> Uploaded documents, reading progress, bookmarks, and playback settings required for core functionality.</li>
<li><strong>Usage events:</strong> Feature interactions and performance analytics used to debug and improve product reliability.</li>
</ul>
</section>
<section id="usage-purpose" className="policy-section">
<h2>2. How we use your data</h2>
<ul className="policy-list">
<li>Deliver and personalize document reading and text-to-speech playback.</li>
<li>Process uploaded files for parsing, synchronization, and optional audiobook export.</li>
<li>Keep the platform secure and prevent abuse.</li>
<li>Diagnose issues and improve performance of existing functionality.</li>
</ul>
</section>
<section id="sharing" className="policy-section">
<h2>3. Service providers and analytics</h2>
<p>
OpenReader does not sell personal information. We use service providers only to run product
infrastructure and user-initiated features.
</p>
<div className="policy-highlight">
Optional analytics is controlled by your consent choice, and Global Privacy Control (GPC) opt-out
signals are honored.
</div>
<ul className="policy-fact-list">
{isRichardrDevProductionInstance ? (
<>
<li><strong>Hosting:</strong> Vercel for application hosting, edge runtime, and performance analytics.</li>
<li><strong>Database:</strong> Neon PostgreSQL for account and document metadata storage.</li>
<li><strong>File storage:</strong> Railway S3-compatible object storage for encrypted uploaded documents and audio artifacts.</li>
<li><strong>TTS processing:</strong> User-initiated TTS is handled by shared providers: Kitten TTS FastAPI (self-hosted on a local Pi cluster) and Replicate.</li>
</>
) : (
<>
<li><strong>Hosting provider:</strong> The deployment host operating this OpenReader instance.</li>
<li><strong>Database + storage:</strong> Configured SQL and object storage services chosen by the instance operator.</li>
<li><strong>TTS provider:</strong> The configured cloud or self-hosted speech provider for generated audio.</li>
</>
)}
</ul>
</section>
<section id="rights" className="policy-section">
<h2>4. Your privacy rights</h2>
<p>
Depending on your jurisdiction, rights may include access, correction, deletion, and opt-out controls.
OpenReader includes in-product controls for exporting and deleting account data.
</p>
<ul className="policy-list">
<li><strong>Right to know:</strong> request details about data categories and usage.</li>
<li><strong>Right to delete:</strong> delete your account and associated records in Settings.</li>
<li><strong>Right to correct:</strong> update account data where applicable.</li>
<li><strong>Right to opt out:</strong> disable non-essential analytics through consent controls.</li>
<li><strong>Right to non-discrimination:</strong> exercising privacy rights does not reduce service access.</li>
</ul>
<div className="policy-highlight">
<strong>In-app controls:</strong> Use <em>Export My Data</em> in Settings to download account metadata plus
object-storage-backed files, or use <em>Delete Account</em> for permanent account removal.
</div>
</section>
<section id="retention" className="policy-section">
<h2>5. Retention and security</h2>
<p>
Account data and uploaded files are retained while your account remains active. Data is encrypted at
rest in storage. OpenReader does not currently provide end-to-end encryption.
</p>
<p>
The owner of this instance may be able to access stored metadata and uploaded files needed to operate
the service.
</p>
<p>
Passwords are not stored as readable plaintext; the authentication system stores credential values as
non-plaintext verification data.
</p>
<p>
Account deletion triggers removal of active account records and associated storage artifacts as part
of the deletion flow.
</p>
</section>
<section id="processing-location" className="policy-section">
<h2>6. Processing location</h2>
{isRichardrDevProductionInstance ? (
<ul className="grid gap-2 sm:grid-cols-2 mt-2">
<li className="text-sm"><strong>Vercel:</strong> Hosting, Edge Functions &amp; Analytics</li>
<li className="text-sm"><strong>Neon (PostgreSQL):</strong> Database Storage</li>
<li className="text-sm"><strong>Railway (S3):</strong> Encrypted Object Storage (Documents)</li>
<li className="text-sm"><strong>DeepInfra:</strong> Text-to-Speech Processing (User-Initiated)</li>
</ul>
<p>
For the official instance, access is restricted to users in the United States. Processing location
depends on the configured infrastructure and providers used by this deployment.
</p>
) : (
<ul className="grid gap-2 sm:grid-cols-2 mt-2">
<li className="text-sm"><strong>Hosting Provider:</strong> Application Hosting &amp; Logs</li>
<li className="text-sm"><strong>Database Service:</strong> Relational Database Storage</li>
<li className="text-sm"><strong>Object Storage:</strong> Encrypted File Storage (Documents)</li>
<li className="text-sm"><strong>TTS Provider:</strong> Text-to-Speech Processing (Based on your configured provider)</li>
</ul>
<p>
Data processing location depends on the deployment environment and provider configuration selected by
the instance operator.
</p>
)}
</div>
</section>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
4. Your Rights (CCPA/CPRA)
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
You have the following rights regarding your personal information:
</p>
<div className="privacy-card landing-panel">
<ul className="space-y-2">
<li><strong>Right to Know:</strong> You may request details about the categories and specific pieces of personal information we have collected.</li>
<li><strong>Right to Delete:</strong> You may request deletion of your personal information (via &quot;Delete Account&quot; in Settings).</li>
<li><strong>Right to Correct:</strong> You may update your account information in Settings.</li>
<li><strong>Right to Opt-Out:</strong> We do not sell data. You may opt-out of analytics &quot;sharing&quot; via our Cookie Banner.</li>
<li><strong>Right to Non-Discrimination:</strong> We will not discriminate against you for exercising your privacy rights.</li>
</ul>
</div>
<div className="mt-4">
<p className="text-sm text-foreground/90 mb-2">
<strong>How to Exercise Your Rights:</strong>
<section id="contact" className="policy-section">
<h2>7. Contact and open source</h2>
<p>
Questions or concerns can be raised through the project repository. Self-hosting is available if you
want full infrastructure control.
</p>
<ul className="list-disc pl-5 text-sm text-foreground/90">
<li><strong>Export Data:</strong> Use the &quot;Export My Data&quot; button in Settings to download your account metadata plus object-storage-backed document and audiobook files.</li>
<li><strong>Delete Data:</strong> Use the &quot;Delete Account&quot; button in Settings.</li>
</ul>
</div>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
5. Data Retention
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
We retain your account data and uploaded files only for as long as you maintain an account.
Uploaded documents are stored <strong>encrypted at rest (AES-256)</strong>.
Upon account deletion, all data is permanently removed from our active databases and storage buckets immediately.
</p>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
6. US Processing &amp; Location
</h2>
{isRichardrDevProductionInstance ? (
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
For the official OpenReader instance, personal
information is processed and stored in the United States.
By using this service, you acknowledge that your data is handled
in the US and that the service is available only for US users.
</p>
) : (
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
Data processing location depends on your deployment and hosting
provider configuration.
</p>
)}
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
7. Contact Us
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
If you have questions or concerns about this Privacy Policy, please contact the instance administrator via the repository:
</p>
<a
href="https://github.com/richardr1126/openreader/issues"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline font-medium"
>
OpenReader Issues
</a>
</section>
<div className="policy-actions">
<a
href="https://github.com/richardr1126/openreader/issues"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'primary', size: 'md' })}
>
Open an Issue
</a>
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'md' })}
>
Self-Hosting Guide
</a>
<Link href="/?redirect=false" className={buttonClass({ variant: 'ghost', size: 'md' })}>
Back to landing
</Link>
</div>
</section>
</div>
</div>
<p className="privacy-note mt-12 pt-8 border-t border-border">
For maximum privacy, you can self-host OpenReader using the{' '}
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
>
open-source repository
</a>.
</p>
<Link href="/?redirect=false" className="privacy-back">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 8H3M7 4l-4 4 4 4" /></svg>
Back to home
</Link>
</div>
</>
</main>
);
}

1159
src/app/(public)/public.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@ import { Figtree } from "next/font/google";
import { ConsentAwareAnalytics } from "@/components/ConsentAwareAnalytics";
import { CookieConsentBanner } from "@/components/CookieConsentBanner";
import { getResolvedRuntimeConfig } from "@/lib/server/runtime-config";
import { tryGetOrigin } from "@/lib/shared/urls";
import pkg from "../../package.json";
const figtree = Figtree({
@ -34,13 +35,16 @@ export const viewport: Viewport = {
viewportFit: "cover",
};
const defaultOrigin = "https://openreader.richardr.dev";
const validatedOrigin = tryGetOrigin(process.env.BASE_URL) || defaultOrigin;
export const metadata: Metadata = {
title: {
default: "OpenReader",
template: "%s | OpenReader",
},
manifest: "/manifest.json",
metadataBase: new URL("https://openreader.richardr.dev"),
metadataBase: new URL(validatedOrigin),
verification: {
google: "MJXyTudn1kgQF8EtGD-tsnAWev7Iawso9hEvqeGHB3U",
},

View file

@ -2,7 +2,7 @@
import { useState } from 'react';
import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
export function CodeBlock({ children }: { children: string }) {
const [copied, setCopied] = useState(false);

View file

@ -1,6 +1,6 @@
import { Fragment, KeyboardEvent } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
interface ConfirmDialogProps {
isOpen: boolean;

View file

@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { getConsentState, setConsentState } from '@/lib/client/analytics';
import { Transition } from '@headlessui/react';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
export function CookieConsentBanner() {
const [show, setShow] = useState(false);
@ -49,8 +49,8 @@ export function CookieConsentBanner() {
🍪 We use cookies
</h3>
<p className="text-sm leading-relaxed text-foreground/90">
We use strictly necessary cookies for authentication and optional cookies for anonymous analytics
to improve the app. See our <Link href="/privacy" className="font-medium text-accent hover:underline">Privacy Policy</Link> for details.
We use strictly necessary cookies for authentication. Optional analytics is enabled only when you consent.
See our <Link href="/privacy" className="font-medium text-accent hover:underline">Privacy Policy</Link> for details.
</p>
</div>

View file

@ -29,7 +29,20 @@ function PrivacyModalBody({ origin }: { origin: string }) {
<p className="leading-relaxed">
We value your privacy. This application uses strictly necessary cookies for authentication
and anonymous analytics to improve performance. Your documents are stored securely and encrypted at rest.
and optional analytics only when consent allows it. Your documents are stored encrypted at rest.
</p>
<p className="leading-relaxed">
OpenReader does not currently provide end-to-end encryption.
</p>
<p className="leading-relaxed">
The owner of this instance may be able to access stored metadata and uploaded files needed to operate the
service.
</p>
<p className="leading-relaxed">
Passwords are not stored as readable plaintext.
</p>
<p className="leading-relaxed">

View file

@ -1,4 +1,4 @@
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
interface ProgressCardProps {
progress: number;

View file

@ -52,7 +52,6 @@ import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders';
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import {
buttonClass,
inputClass,
listboxButtonClass,
listboxOptionClass,
@ -60,6 +59,7 @@ import {
segmentedButtonClass,
segmentedGroupClass,
} from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog';

View file

@ -9,12 +9,12 @@ import {
Badge,
Section,
ToggleRow,
buttonClass,
inputClass,
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
} from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';

View file

@ -13,12 +13,12 @@ import {
Field,
Section,
ToggleRow,
buttonClass,
inputClass,
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
} from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
type ProviderType = TtsProviderId;

View file

@ -4,13 +4,6 @@ export {
Field,
Section,
ToggleRow,
buttonClass,
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
inputClass,
listboxButtonClass,
listboxOptionClass,
@ -18,3 +11,13 @@ export {
segmentedButtonClass,
segmentedGroupClass,
} from '@/components/formPrimitives';
export {
buttonClass,
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
} from '@/components/ui/buttonPrimitives';

View file

@ -7,7 +7,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/client/auth-client';
import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));

View file

@ -7,7 +7,7 @@ import type { DocumentListDocument } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { formatDocumentSize } from '@/components/doclist/formatSize';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';

View file

@ -3,7 +3,7 @@
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { Fragment, useEffect, useState } from 'react';
import { BaseDocument } from '@/types/documents';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
interface DocumentSelectionModalProps {
isOpen: boolean;

View file

@ -15,7 +15,8 @@ import {
clampSegmentPreloadSentenceLookahead,
clampTtsSegmentMaxBlockLength,
} from '@/types/config';
import { Section, ToggleRow, CheckItem, buttonClass, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives';
import { Section, ToggleRow, CheckItem, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';

View file

@ -1,4 +1,4 @@
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
export function ZoomControl({
value,

View file

@ -1,51 +1,35 @@
'use client';
import { useId, type ReactNode } from 'react';
import {
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
buttonClass,
type ButtonSize,
type ButtonVariant,
} from '@/components/ui/buttonPrimitives';
/**
* Shared compact form primitives used by settings-like surfaces across
* the app (settings modal, document settings, and admin panels).
*/
export const btnBase =
'inline-flex items-center justify-center rounded-md text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transition-colors transition-transform duration-200 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100';
export const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.03]`;
export const btnSecondary = `${btnBase} bg-base text-foreground border border-offbase hover:bg-offbase hover:scale-[1.03]`;
export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-base hover:text-accent hover:scale-[1.02]`;
export const btnDanger = `${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02]`;
export const btnGhost = `${btnBase} bg-transparent text-foreground hover:bg-base hover:text-accent`;
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
const BUTTON_VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: btnPrimary,
secondary: btnSecondary,
outline: btnOutline,
danger: btnDanger,
ghost: btnGhost,
export {
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
buttonClass,
type ButtonSize,
type ButtonVariant,
};
const BUTTON_SIZE_CLASS: Record<ButtonSize, string> = {
xs: 'h-6 px-2 text-xs rounded-md',
sm: 'h-7 px-2.5 text-xs rounded-md',
md: 'h-8 px-3 text-sm rounded-md',
lg: 'h-10 px-4 text-base rounded-lg',
icon: 'h-8 w-8 rounded-md p-0',
};
export function buttonClass({
variant = 'secondary',
size = 'md',
className = '',
}: {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
} = {}) {
return [BUTTON_VARIANT_CLASS[variant], BUTTON_SIZE_CLASS[size], className].filter(Boolean).join(' ');
}
// Inputs use `bg-base` so they remain visible regardless of whether the
// surrounding container is `bg-background` (Card) or `bg-base` (Section).
// Using the same `bg-background` as the Card would make the input blend in.

View file

@ -4,7 +4,7 @@ import { Fragment, type ReactNode } from 'react';
import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@/components/icons/Icons';
import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
interface ReaderSidebarShellProps {
isOpen: boolean;

View file

@ -8,7 +8,7 @@ import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';

View file

@ -0,0 +1,38 @@
export const btnBase =
'inline-flex items-center justify-center rounded-md text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transition-colors transition-transform duration-200 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100';
export const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.03]`;
export const btnSecondary = `${btnBase} bg-base text-foreground border border-offbase hover:bg-offbase hover:scale-[1.03]`;
export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-base hover:text-accent hover:scale-[1.02]`;
export const btnDanger = `${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02]`;
export const btnGhost = `${btnBase} bg-transparent text-foreground hover:bg-base hover:text-accent`;
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
const BUTTON_VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: btnPrimary,
secondary: btnSecondary,
outline: btnOutline,
danger: btnDanger,
ghost: btnGhost,
};
const BUTTON_SIZE_CLASS: Record<ButtonSize, string> = {
xs: 'h-6 px-2 text-xs rounded-md',
sm: 'h-7 px-2.5 text-xs rounded-md',
md: 'h-8 px-3 text-sm rounded-md',
lg: 'h-10 px-4 text-base rounded-lg',
icon: 'h-8 w-8 rounded-md p-0',
};
export function buttonClass({
variant = 'secondary',
size = 'md',
className = '',
}: {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
} = {}) {
return [BUTTON_VARIANT_CLASS[variant], BUTTON_SIZE_CLASS[size], className].filter(Boolean).join(' ');
}

View file

@ -13,6 +13,7 @@ import * as authSchemaSqlite from "@/db/schema_auth_sqlite";
import * as authSchemaPostgres from "@/db/schema_auth_postgres";
import { hashForLog, serverLogger } from '@/lib/server/logger';
import { logDegraded, logServerError } from '@/lib/server/errors/logging';
import { tryGetOrigin } from "@/lib/shared/urls";
// Heavy modules (S3 SDK, blobstore, rate-limiter, claim-data) are loaded
// lazily via dynamic import() inside the beforeDelete / onLinkAccount
@ -20,14 +21,6 @@ import { logDegraded, logServerError } from '@/lib/server/errors/logging';
// ...
function tryGetOrigin(url: string | undefined): string | null {
if (!url) return null;
try {
return new URL(url).origin;
} catch {
return null;
}
}
function getTrustedOrigins(): string[] {
const origins = new Set<string>();

9
src/lib/shared/urls.ts Normal file
View file

@ -0,0 +1,9 @@
export function tryGetOrigin(url: string | undefined): string | null {
if (!url) return null;
try {
const origin = new URL(url).origin;
return origin === 'null' ? null : origin;
} catch {
return null;
}
}

View file

@ -0,0 +1,27 @@
import { describe, expect, test } from 'vitest';
import { tryGetOrigin } from '../../src/lib/shared/urls';
describe('tryGetOrigin utility function', () => {
test('returns null for undefined, null, or empty string', () => {
expect(tryGetOrigin(undefined)).toBeNull();
expect(tryGetOrigin('')).toBeNull();
});
test('extracts origin from valid HTTP/HTTPS URLs', () => {
expect(tryGetOrigin('http://localhost:3000')).toBe('http://localhost:3000');
expect(tryGetOrigin('https://openreader.richardr.dev')).toBe('https://openreader.richardr.dev');
expect(tryGetOrigin('https://my-custom-domain.com/some/path?query=1')).toBe('https://my-custom-domain.com');
});
test('returns null for malformed URLs/strings without protocols', () => {
expect(tryGetOrigin('malformed-url')).toBeNull();
expect(tryGetOrigin('localhost:3000')).toBeNull();
expect(tryGetOrigin('my-custom-domain.com')).toBeNull();
});
test('returns null for invalid protocols/schemes', () => {
expect(tryGetOrigin('ftp://some-server.com')).toBe('ftp://some-server.com'); // standard URL supports ftp
expect(tryGetOrigin('invalid://foo')).toBeNull(); // non-standard/unsupported origins return null
expect(tryGetOrigin('http://[invalid-ipv6]')).toBeNull();
});
});