audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add terms of service and privacy policy pages (#567)

* feat: add terms of service and privacy policy pages

- /terms: comprehensive ToS with artist/listener terms, DMCA policy (3-strike), arbitration
- /privacy: ATProto-specific privacy policy with GDPR/CCPA considerations
- DMCA agent registered: DMCA-1069186

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: require terms acceptance on first login

- add terms_accepted_at column to user_preferences
- add POST /preferences/accept-terms endpoint
- create /accept-terms page with summary + full terms links
- add layout guard to redirect authenticated users who haven't accepted

existing users will be prompted on next visit to an authenticated route.
public pages (/, /track/*, /u/*, /terms, /privacy) don't require acceptance.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: improve legal pages with configurable emails and accurate content

- add LegalSettings config with contact/privacy/dmca emails
- expose legal config via /config endpoint
- create cookie policy page (/cookies)
- fix privacy section 8 to acknowledge moderation (image blurring, DMCA)
- add links to ATProto glossary, external services (Cloudflare, Fly, Neon, Logfire)
- fix terms overlay language to be concrete ("store, stream, transcode")
- add /cookies to terms overlay path exclusions
- delete old /accept-terms route with hardcoded outdated language

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* simplify terms and privacy to match tangled.org style

- cut arbitration, class action waiver, detailed GDPR/CCPA sections
- cut separate cookie policy page
- cut AI training prohibition, listener terms, detailed DMCA procedures
- keep DMCA agent (registered), ATProto federation limitation
- add external PDS disclaimer (like tangled)
- ~475 lines โ†’ ~240 lines terms, ~530 lines โ†’ ~220 lines privacy

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: await logout and reload page to dismiss terms overlay

the overlay depends on layout data which doesn't reactively update
when auth state changes. using window.location.href forces a full
reload to clear the stale layout data.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: add legal drafts

- terms.md - simplified terms of service
- privacy.md - simplified privacy policy
- questions.md - open questions

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: clarify safe harbor question

* docs: use protonmail for legal contact emails

placeholder domain emails don't exist yet. using plyrdotfm@proton.me
until cloudflare email routing is set up.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by

nate nowack
Claude
and committed by
GitHub
f4fa9a0f 48594562

+1065 -5
+32
backend/alembic/versions/2025_12_10_184404_883e927fdd76_add_terms_accepted_at_to_user_.py
··· 1 + """add terms_accepted_at to user_preferences 2 + 3 + Revision ID: 883e927fdd76 4 + Revises: 37cc1d6980c3 5 + Create Date: 2025-12-10 18:44:04.436282 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "883e927fdd76" 17 + down_revision: str | Sequence[str] | None = "37cc1d6980c3" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Upgrade schema.""" 24 + op.add_column( 25 + "user_preferences", 26 + sa.Column("terms_accepted_at", sa.DateTime(timezone=True), nullable=True), 27 + ) 28 + 29 + 30 + def downgrade() -> None: 31 + """Downgrade schema.""" 32 + op.drop_column("user_preferences", "terms_accepted_at")
+40
backend/src/backend/api/preferences.py
··· 1 1 """user preferences api endpoints.""" 2 2 3 + from datetime import UTC, datetime 3 4 from typing import Annotated, Any 4 5 5 6 from fastapi import APIRouter, Depends ··· 33 34 support_url: str | None = None 34 35 # extensible UI settings (background_image_url, glass_effects, custom_colors, etc.) 35 36 ui_settings: dict[str, Any] = {} 37 + terms_accepted_at: datetime | None = None 36 38 37 39 38 40 class PreferencesUpdate(BaseModel): ··· 108 110 show_liked_on_profile=prefs.show_liked_on_profile, 109 111 support_url=prefs.support_url, 110 112 ui_settings=prefs.ui_settings or {}, 113 + terms_accepted_at=prefs.terms_accepted_at, 111 114 ) 112 115 113 116 ··· 191 194 show_liked_on_profile=prefs.show_liked_on_profile, 192 195 support_url=prefs.support_url, 193 196 ui_settings=prefs.ui_settings or {}, 197 + terms_accepted_at=prefs.terms_accepted_at, 194 198 ) 199 + 200 + 201 + class TermsAcceptanceResponse(BaseModel): 202 + """response after accepting terms.""" 203 + 204 + terms_accepted_at: datetime 205 + 206 + 207 + @router.post("/accept-terms") 208 + async def accept_terms( 209 + db: Annotated[AsyncSession, Depends(get_db)], 210 + session: Session = Depends(require_auth), 211 + ) -> TermsAcceptanceResponse: 212 + """accept terms of service. records timestamp of acceptance.""" 213 + result = await db.execute( 214 + select(UserPreferences).where(UserPreferences.did == session.did) 215 + ) 216 + prefs = result.scalar_one_or_none() 217 + 218 + now = datetime.now(UTC) 219 + 220 + if not prefs: 221 + # create preferences with terms accepted 222 + prefs = UserPreferences( 223 + did=session.did, 224 + accent_color="#6a9fff", 225 + hidden_tags=list(DEFAULT_HIDDEN_TAGS), 226 + terms_accepted_at=now, 227 + ) 228 + db.add(prefs) 229 + else: 230 + prefs.terms_accepted_at = now 231 + 232 + await db.commit() 233 + 234 + return TermsAcceptanceResponse(terms_accepted_at=now)
+44
backend/src/backend/config.py
··· 125 125 ) 126 126 127 127 128 + class LegalSettings(AppSettingsSection): 129 + """Legal and contact configuration.""" 130 + 131 + model_config = SettingsConfigDict( 132 + env_prefix="LEGAL_", 133 + env_file=".env", 134 + case_sensitive=False, 135 + extra="ignore", 136 + ) 137 + 138 + contact_email: str = Field( 139 + default="plyrdotfm@proton.me", 140 + description="General contact email", 141 + ) 142 + privacy_email: str | None = Field( 143 + default=None, 144 + description="Privacy-specific contact email (falls back to contact_email)", 145 + ) 146 + dmca_email: str | None = Field( 147 + default=None, 148 + description="DMCA/copyright contact email (falls back to contact_email)", 149 + ) 150 + dmca_registration_number: str = Field( 151 + default="DMCA-1069186", 152 + description="USPTO DMCA agent registration number", 153 + ) 154 + 155 + @computed_field 156 + @property 157 + def resolved_privacy_email(self) -> str: 158 + """Privacy email, falling back to contact_email.""" 159 + return self.privacy_email or self.contact_email 160 + 161 + @computed_field 162 + @property 163 + def resolved_dmca_email(self) -> str: 164 + """DMCA email, falling back to contact_email.""" 165 + return self.dmca_email or self.contact_email 166 + 167 + 128 168 class FrontendSettings(AppSettingsSection): 129 169 """Frontend-specific configuration.""" 130 170 ··· 691 731 storage: StorageSettings = Field(default_factory=StorageSettings) 692 732 atproto: AtprotoSettings = Field(default_factory=AtprotoSettings) 693 733 observability: ObservabilitySettings = Field(default_factory=ObservabilitySettings) 734 + legal: LegalSettings = Field( 735 + default_factory=LegalSettings, 736 + description="Legal and contact settings", 737 + ) 694 738 rate_limit: RateLimitSettings = Field( 695 739 default_factory=RateLimitSettings, 696 740 description="Rate limiting settings",
+5 -1
backend/src/backend/main.py
··· 226 226 227 227 228 228 @app.get("/config") 229 - async def get_public_config() -> dict[str, int | list[str]]: 229 + async def get_public_config() -> dict[str, int | str | list[str]]: 230 230 """expose public configuration to frontend.""" 231 231 from backend.utilities.tags import DEFAULT_HIDDEN_TAGS 232 232 ··· 236 236 "default_hidden_tags": DEFAULT_HIDDEN_TAGS, 237 237 "bufo_exclude_patterns": list(settings.bufo.exclude_patterns), 238 238 "bufo_include_patterns": list(settings.bufo.include_patterns), 239 + "contact_email": settings.legal.contact_email, 240 + "privacy_email": settings.legal.resolved_privacy_email, 241 + "dmca_email": settings.legal.resolved_dmca_email, 242 + "dmca_registration_number": settings.legal.dmca_registration_number, 239 243 } 240 244 241 245
+6
backend/src/backend/models/preferences.py
··· 74 74 server_default=text("'{}'::jsonb"), 75 75 ) 76 76 77 + # terms of service acceptance 78 + # null means terms not yet accepted, timestamp means when they were accepted 79 + terms_accepted_at: Mapped[datetime | None] = mapped_column( 80 + DateTime(timezone=True), nullable=True 81 + ) 82 + 77 83 # metadata 78 84 created_at: Mapped[datetime] = mapped_column( 79 85 DateTime(timezone=True),
+57
docs/legal/privacy.md
··· 1 + # privacy policy 2 + 3 + **last updated:** december 16, 2025 4 + 5 + this policy explains what data we collect, what's public by design on the AT Protocol, and your rights. 6 + 7 + ## 1. the AT Protocol 8 + 9 + plyr.fm uses the [AT Protocol](https://atproto.com) for identity and social features. this has important implications: 10 + 11 + **public by design:** your DID, handle, profile, tracks, likes, and comments are stored on the decentralized AT Protocol network. this data is visible to anyone on the network, not just plyr.fm users. 12 + 13 + **external PDS:** if your account is hosted on Bluesky's PDS or another provider (not ours), we do not control that data. their privacy policies govern it. 14 + 15 + **private data:** session tokens, preferences, and server logs are stored only on our servers. 16 + 17 + ## 2. data we collect 18 + 19 + **you provide:** your ATProto identity when you log in, audio files and metadata you upload, and preferences like accent color. 20 + 21 + **automatically:** play counts, IP addresses, browser info, and session cookies for authentication. 22 + 23 + ## 3. how we use it 24 + 25 + we use your data to provide the service, maintain your session, and improve the platform. we do not sell your data or use it for advertising. 26 + 27 + ## 4. third parties 28 + 29 + we use: 30 + 31 + - **Cloudflare** - CDN, storage (R2) 32 + - **Fly.io** - backend hosting 33 + - **Neon** - database 34 + - **Logfire** - error monitoring 35 + - **AT Protocol network** - public data federation 36 + 37 + ## 5. your rights 38 + 39 + you can access, correct, or delete your data through settings. when you delete your account, we remove your files from our storage and your data from our database. 40 + 41 + **we cannot delete:** your DID (you control it), data on other ATProto servers, or records in other users' PDSes. 42 + 43 + ## 6. security 44 + 45 + we use HTTPS, encrypt sensitive data, and use HttpOnly cookies. no system is perfectly secureโ€”report vulnerabilities to plyrdotfm@proton.me. 46 + 47 + ## 7. children 48 + 49 + plyr.fm is not for children under 13. we do not knowingly collect data from children. 50 + 51 + ## 8. changes 52 + 53 + we may update this policy. material changes will be posted with notice. 54 + 55 + ## contact 56 + 57 + questions? plyrdotfm@proton.me
+35
docs/legal/questions.md
··· 1 + plyr.fm legal questions 2 + 3 + # plyr.fm - legal questions 4 + 5 + music streaming platform on AT Protocol. users upload audio. no payments yet. 6 + 7 + ## 1. DMCA safe harbor limits 8 + 9 + we have DMCA safe harbor (registered agent, respond to takedowns, terminate repeat infringers). 10 + 11 + **question:** does safe harbor fully protect us from liability, or can rights holders still get injunctions requiring proactive filtering or licensing? what's the realistic worst-case if a major label targets us? 12 + 13 + ## 2. proactive scanning implications 14 + 15 + we run automated copyright detection (AudD) on uploads and flag matches. we don't auto-remove, just flag for review. 16 + 17 + **question:** does proactive scanning help or hurt our legal position? does knowing about potential infringement before a takedown create additional liability, or does it strengthen our "good faith" safe harbor claim? 18 + 19 + ## 3. federation and DMCA 20 + 21 + AT Protocol federates data to relay servers we don't control. when we receive a DMCA takedown, we remove content from plyr.fm but cannot remove it from third-party relays that already indexed it. 22 + 23 + **question:** does inability to fully remove content from the network affect our DMCA safe harbor status? how should our ToS/response procedures address this limitation? 24 + 25 + ## 4. future paywalled content 26 + 27 + we may add artist paywalls later (via atprotofans/graze.social as payment processor). artists would gate content to supporters. 28 + 29 + **question:** if we facilitate paid content and take a cut, does that change our liability profile from "platform" to "distributor"? what ToS changes would we need before adding payments? 30 + 31 + ## 5. user-uploaded images 32 + 33 + users upload album art and profile images. we run moderation for sensitive content but risk CSAM, copyright infringement, etc. 34 + 35 + **question:** what's our liability exposure for user-uploaded images vs audio? are there additional compliance requirements (NCMEC reporting, etc.) we should implement now?
+57
docs/legal/terms.md
··· 1 + # terms of service 2 + 3 + **last updated:** december 16, 2025 4 + 5 + these terms govern your use of plyr.fm, a music streaming platform built on the AT Protocol. by using the service, you agree to these terms. 6 + 7 + ## 1. acceptance of terms 8 + 9 + by accessing or using plyr.fm, you agree to be bound by these terms. if you disagree with any part, you may not use the service. 10 + 11 + ## 2. accounts 12 + 13 + plyr.fm uses AT Protocol for authentication. your identity is your DID (decentralized identifier), not an account we control. we do not store passwords. 14 + 15 + we may terminate or suspend your access at any time, for any reason. termination removes your content from plyr.fm but does not affect your DID or data on your PDSโ€”we don't control those. 16 + 17 + ## 3. content 18 + 19 + you retain ownership of content you upload. by uploading, you grant us a non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and distribute your content as necessary to provide the service. 20 + 21 + you represent that you own or have the necessary rights to the content you upload, and that it does not infringe any third party's rights. 22 + 23 + ## 4. acceptable use 24 + 25 + you agree not to: 26 + 27 + - violate any applicable laws 28 + - infringe on others' rights 29 + - upload content that is illegal or infringes copyright 30 + - attempt unauthorized access to the service 31 + - interfere with or disrupt the service 32 + 33 + ## 5. copyright & DMCA 34 + 35 + we respond to valid DMCA takedown notices. our designated agent is: 36 + 37 + > Nathan Nowack 38 + > DMCA Registration: DMCA-1069186 39 + > Email: plyrdotfm@proton.me 40 + 41 + we terminate accounts of repeat infringers. 42 + 43 + **AT Protocol limitation:** we can only remove content from our servers. we cannot delete content that has been published to other AT Protocol servers or your PDS. 44 + 45 + ## 6. disclaimers 46 + 47 + the service is provided "as is" and "as available" without warranties of any kind. we do not guarantee uptime or that the service will be error-free. 48 + 49 + we are not liable for any indirect, incidental, special, or consequential damages resulting from your use of the service. 50 + 51 + ## 7. changes 52 + 53 + we may update these terms. material changes will be posted with notice. continued use after changes constitutes acceptance. 54 + 55 + ## contact 56 + 57 + questions? plyrdotfm@proton.me
+249
frontend/src/lib/components/TermsOverlay.svelte
··· 1 + <script lang="ts"> 2 + import { APP_NAME } from '$lib/branding'; 3 + import { auth } from '$lib/auth.svelte'; 4 + import { preferences } from '$lib/preferences.svelte'; 5 + 6 + let accepting = $state(false); 7 + let error = $state<string | null>(null); 8 + 9 + async function handleAccept() { 10 + accepting = true; 11 + error = null; 12 + 13 + const success = await preferences.acceptTerms(); 14 + 15 + if (!success) { 16 + error = 'failed to accept terms. please try again.'; 17 + accepting = false; 18 + } 19 + } 20 + 21 + async function handleDecline() { 22 + await auth.logout(); 23 + // force page reload to clear layout data 24 + window.location.href = '/'; 25 + } 26 + </script> 27 + 28 + <div class="terms-overlay"> 29 + <div class="terms-modal"> 30 + <h1>welcome to {APP_NAME}</h1> 31 + <p class="subtitle">please review and accept our terms to continue</p> 32 + 33 + <div class="terms-summary"> 34 + <h2>key points</h2> 35 + <ul> 36 + <li> 37 + <strong>your content:</strong> you own what you upload. we store it, stream it, 38 + and transcode it. delete through {APP_NAME} and we clean up everything. 39 + </li> 40 + <li> 41 + <strong>copyright:</strong> don't upload content you don't have rights to. 42 + we follow DMCA takedown procedures. 43 + </li> 44 + <li> 45 + <strong>AT Protocol:</strong> your identity is on your 46 + <a href="https://atproto.com/guides/glossary#pds-personal-data-server" target="_blank" rel="noopener">PDS</a>. 47 + public interactions (likes, comments) are written to the network. 48 + </li> 49 + <li> 50 + <strong>privacy:</strong> we don't sell your data or use it for ads. 51 + </li> 52 + </ul> 53 + </div> 54 + 55 + <div class="full-terms-link"> 56 + <p> 57 + read the full <a href="/terms" target="_blank">terms of service</a> and 58 + <a href="/privacy" target="_blank">privacy policy</a> 59 + </p> 60 + </div> 61 + 62 + {#if error} 63 + <p class="error">{error}</p> 64 + {/if} 65 + 66 + <div class="actions"> 67 + <button class="accept-btn" onclick={handleAccept} disabled={accepting}> 68 + {accepting ? 'accepting...' : 'i accept'} 69 + </button> 70 + <button class="decline-btn" onclick={handleDecline} disabled={accepting}> 71 + decline & logout 72 + </button> 73 + </div> 74 + </div> 75 + </div> 76 + 77 + <style> 78 + .terms-overlay { 79 + position: fixed; 80 + inset: 0; 81 + background: rgba(0, 0, 0, 0.9); 82 + display: flex; 83 + align-items: center; 84 + justify-content: center; 85 + z-index: 9999; 86 + padding: 1rem; 87 + } 88 + 89 + .terms-modal { 90 + background: var(--bg-secondary); 91 + border: 1px solid var(--border-subtle); 92 + border-radius: 12px; 93 + padding: 2rem; 94 + max-width: 600px; 95 + width: 100%; 96 + max-height: 90vh; 97 + overflow-y: auto; 98 + text-align: center; 99 + } 100 + 101 + h1 { 102 + font-size: 1.75rem; 103 + margin: 0 0 0.5rem 0; 104 + color: var(--text-primary); 105 + } 106 + 107 + .subtitle { 108 + color: var(--text-tertiary); 109 + margin: 0 0 1.5rem 0; 110 + } 111 + 112 + .terms-summary { 113 + background: var(--bg-tertiary); 114 + border: 1px solid var(--border-subtle); 115 + border-radius: 8px; 116 + padding: 1.25rem; 117 + text-align: left; 118 + margin-bottom: 1.25rem; 119 + } 120 + 121 + .terms-summary h2 { 122 + font-size: 0.9rem; 123 + margin: 0 0 0.75rem 0; 124 + color: var(--text-secondary); 125 + text-transform: lowercase; 126 + } 127 + 128 + .terms-summary ul { 129 + margin: 0; 130 + padding-left: 1.25rem; 131 + } 132 + 133 + .terms-summary li { 134 + margin-bottom: 0.6rem; 135 + color: var(--text-secondary); 136 + line-height: 1.5; 137 + font-size: 0.9rem; 138 + } 139 + 140 + .terms-summary li:last-child { 141 + margin-bottom: 0; 142 + } 143 + 144 + .terms-summary strong { 145 + color: var(--text-primary); 146 + } 147 + 148 + .terms-summary a { 149 + color: var(--accent); 150 + text-decoration: none; 151 + } 152 + 153 + .terms-summary a:hover { 154 + text-decoration: underline; 155 + } 156 + 157 + .full-terms-link { 158 + margin-bottom: 1.25rem; 159 + } 160 + 161 + .full-terms-link p { 162 + margin: 0; 163 + color: var(--text-tertiary); 164 + font-size: 0.85rem; 165 + } 166 + 167 + .full-terms-link a { 168 + color: var(--accent); 169 + text-decoration: none; 170 + } 171 + 172 + .full-terms-link a:hover { 173 + text-decoration: underline; 174 + } 175 + 176 + .error { 177 + color: var(--error); 178 + margin-bottom: 1rem; 179 + font-size: 0.9rem; 180 + } 181 + 182 + .actions { 183 + display: flex; 184 + gap: 0.75rem; 185 + justify-content: center; 186 + } 187 + 188 + .accept-btn { 189 + background: var(--accent); 190 + color: white; 191 + border: none; 192 + padding: 0.7rem 1.75rem; 193 + border-radius: 6px; 194 + font-family: inherit; 195 + font-size: 0.95rem; 196 + cursor: pointer; 197 + transition: opacity 0.15s; 198 + } 199 + 200 + .accept-btn:hover:not(:disabled) { 201 + opacity: 0.9; 202 + } 203 + 204 + .accept-btn:disabled { 205 + opacity: 0.6; 206 + cursor: not-allowed; 207 + } 208 + 209 + .decline-btn { 210 + background: transparent; 211 + color: var(--text-tertiary); 212 + border: 1px solid var(--border-subtle); 213 + padding: 0.7rem 1.25rem; 214 + border-radius: 6px; 215 + font-family: inherit; 216 + font-size: 0.95rem; 217 + cursor: pointer; 218 + transition: all 0.15s; 219 + } 220 + 221 + .decline-btn:hover:not(:disabled) { 222 + border-color: var(--text-tertiary); 223 + color: var(--text-secondary); 224 + } 225 + 226 + .decline-btn:disabled { 227 + opacity: 0.6; 228 + cursor: not-allowed; 229 + } 230 + 231 + @media (max-width: 500px) { 232 + .terms-modal { 233 + padding: 1.5rem; 234 + } 235 + 236 + h1 { 237 + font-size: 1.5rem; 238 + } 239 + 240 + .actions { 241 + flex-direction: column; 242 + } 243 + 244 + .accept-btn, 245 + .decline-btn { 246 + width: 100%; 247 + } 248 + } 249 + </style>
+4
frontend/src/lib/config.ts
··· 16 16 default_hidden_tags: string[]; 17 17 bufo_exclude_patterns: string[]; 18 18 bufo_include_patterns: string[]; 19 + contact_email: string; 20 + privacy_email: string; 21 + dmca_email: string; 22 + dmca_registration_number: string; 19 23 } 20 24 21 25 let serverConfig: ServerConfig | null = null;
+35 -2
frontend/src/lib/preferences.svelte.ts
··· 24 24 support_url: string | null; 25 25 ui_settings: UiSettings; 26 26 auto_download_liked: boolean; 27 + terms_accepted_at: string | null; 27 28 } 28 29 29 30 const DEFAULT_PREFERENCES: Preferences = { ··· 38 39 show_liked_on_profile: false, 39 40 support_url: null, 40 41 ui_settings: {}, 41 - auto_download_liked: false 42 + auto_download_liked: false, 43 + terms_accepted_at: null 42 44 }; 43 45 44 46 class PreferencesManager { ··· 98 100 return this.data?.auto_download_liked ?? DEFAULT_PREFERENCES.auto_download_liked; 99 101 } 100 102 103 + get termsAcceptedAt(): string | null { 104 + return this.data?.terms_accepted_at ?? null; 105 + } 106 + 107 + get hasAcceptedTerms(): boolean { 108 + return this.data?.terms_accepted_at !== null; 109 + } 110 + 101 111 setAutoDownloadLiked(enabled: boolean): void { 102 112 if (browser) { 103 113 localStorage.setItem('autoDownloadLiked', enabled ? '1' : '0'); ··· 163 173 show_liked_on_profile: data.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile, 164 174 support_url: data.support_url ?? DEFAULT_PREFERENCES.support_url, 165 175 ui_settings: data.ui_settings ?? DEFAULT_PREFERENCES.ui_settings, 166 - auto_download_liked: storedAutoDownload 176 + auto_download_liked: storedAutoDownload, 177 + terms_accepted_at: data.terms_accepted_at ?? null 167 178 }; 168 179 } else { 169 180 const storedAutoDownload = localStorage.getItem('autoDownloadLiked') === '1'; ··· 236 247 clear(): void { 237 248 this.data = null; 238 249 this.initialized = false; 250 + } 251 + 252 + async acceptTerms(): Promise<boolean> { 253 + if (!browser || !auth.isAuthenticated) return false; 254 + 255 + try { 256 + const response = await fetch(`${API_URL}/preferences/accept-terms`, { 257 + method: 'POST', 258 + credentials: 'include' 259 + }); 260 + if (response.ok) { 261 + const data = await response.json(); 262 + if (this.data) { 263 + this.data = { ...this.data, terms_accepted_at: data.terms_accepted_at }; 264 + } 265 + return true; 266 + } 267 + return false; 268 + } catch (error) { 269 + console.error('failed to accept terms:', error); 270 + return false; 271 + } 239 272 } 240 273 } 241 274
+15
frontend/src/routes/+layout.svelte
··· 10 10 import Queue from '$lib/components/Queue.svelte'; 11 11 import SearchModal from '$lib/components/SearchModal.svelte'; 12 12 import LogoutModal from '$lib/components/LogoutModal.svelte'; 13 + import TermsOverlay from '$lib/components/TermsOverlay.svelte'; 13 14 import { onMount, onDestroy } from 'svelte'; 14 15 import { page } from '$app/stores'; 15 16 import { afterNavigate } from '$app/navigation'; ··· 37 38 ); 38 39 39 40 let isEmbed = $derived($page.url.pathname.startsWith('/embed/')); 41 + 42 + // show terms overlay if authenticated but hasn't accepted terms 43 + // exclude legal pages so users can read full terms/privacy/cookies 44 + let showTermsOverlay = $derived( 45 + data.isAuthenticated && 46 + data.preferences && 47 + !data.preferences.terms_accepted_at && 48 + !$page.url.pathname.startsWith('/terms') && 49 + !$page.url.pathname.startsWith('/privacy') && 50 + !$page.url.pathname.startsWith('/cookies') 51 + ); 40 52 41 53 // sync auth and preferences state from layout data (fetched by +layout.ts) 42 54 $effect(() => { ··· 419 431 <Toast /> 420 432 <SearchModal /> 421 433 <LogoutModal /> 434 + {#if showTermsOverlay} 435 + <TermsOverlay /> 436 + {/if} 422 437 423 438 <style> 424 439 :global(*),
+4 -2
frontend/src/routes/+layout.ts
··· 24 24 show_liked_on_profile: false, 25 25 support_url: null, 26 26 ui_settings: {}, 27 - auto_download_liked: false 27 + auto_download_liked: false, 28 + terms_accepted_at: null 28 29 }; 29 30 30 31 export async function load({ fetch, data }: LoadEvent): Promise<LayoutData> { ··· 71 72 show_liked_on_profile: prefsData.show_liked_on_profile ?? false, 72 73 support_url: prefsData.support_url ?? null, 73 74 ui_settings: prefsData.ui_settings ?? {}, 74 - auto_download_liked: storedAutoDownload 75 + auto_download_liked: storedAutoDownload, 76 + terms_accepted_at: prefsData.terms_accepted_at ?? null 75 77 }; 76 78 } 77 79 } catch (e) {
+220
frontend/src/routes/privacy/+page.svelte
··· 1 + <script lang="ts"> 2 + import { APP_NAME } from '$lib/branding'; 3 + import type { PageData } from './$types'; 4 + 5 + let { data } = $props<{ data: PageData }>(); 6 + </script> 7 + 8 + <svelte:head> 9 + <title>privacy policy - {APP_NAME}</title> 10 + <meta name="description" content="Privacy Policy for {APP_NAME}" /> 11 + </svelte:head> 12 + 13 + <div class="legal-container"> 14 + <article class="legal-content"> 15 + <h1>privacy policy</h1> 16 + <p class="last-updated">last updated: december 16, 2025</p> 17 + 18 + <p class="intro"> 19 + this policy explains what data we collect, what's public by design on the AT Protocol, 20 + and your rights. 21 + </p> 22 + 23 + <section> 24 + <h2>1. the AT Protocol</h2> 25 + <p> 26 + {APP_NAME} uses the <a href="https://atproto.com" target="_blank" rel="noopener">AT Protocol</a> 27 + for identity and social features. this has important implications: 28 + </p> 29 + <p> 30 + <strong>public by design:</strong> your DID, handle, profile, tracks, likes, and comments 31 + are stored on the decentralized AT Protocol network. this data is visible to anyone 32 + on the network, not just {APP_NAME} users. 33 + </p> 34 + <p> 35 + <strong>external PDS:</strong> if your account is hosted on Bluesky's PDS or another 36 + provider (not ours), we do not control that data. their privacy policies govern it. 37 + </p> 38 + <p> 39 + <strong>private data:</strong> session tokens, preferences, and server logs are stored 40 + only on our servers. 41 + </p> 42 + </section> 43 + 44 + <section> 45 + <h2>2. data we collect</h2> 46 + <p><strong>you provide:</strong> your ATProto identity when you log in, audio files 47 + and metadata you upload, and preferences like accent color.</p> 48 + <p><strong>automatically:</strong> play counts, IP addresses, browser info, and 49 + session cookies for authentication.</p> 50 + </section> 51 + 52 + <section> 53 + <h2>3. how we use it</h2> 54 + <p>we use your data to provide the service, maintain your session, and improve the 55 + platform. we do not sell your data or use it for advertising.</p> 56 + </section> 57 + 58 + <section> 59 + <h2>4. third parties</h2> 60 + <p>we use:</p> 61 + <ul> 62 + <li><strong>Cloudflare</strong> - CDN, storage (R2)</li> 63 + <li><strong>Fly.io</strong> - backend hosting</li> 64 + <li><strong>Neon</strong> - database</li> 65 + <li><strong>Logfire</strong> - error monitoring</li> 66 + <li><strong>AT Protocol network</strong> - public data federation</li> 67 + </ul> 68 + </section> 69 + 70 + <section> 71 + <h2>5. your rights</h2> 72 + <p>you can access, correct, or delete your data through settings. when you delete your 73 + account, we remove your files from our storage and your data from our database.</p> 74 + <p> 75 + <strong>we cannot delete:</strong> your DID (you control it), data on other ATProto 76 + servers, or records in other users' PDSes. 77 + </p> 78 + </section> 79 + 80 + <section> 81 + <h2>6. security</h2> 82 + <p>we use HTTPS, encrypt sensitive data, and use HttpOnly cookies. no system is 83 + perfectly secureโ€”report vulnerabilities to 84 + <a href="mailto:{data.contactEmail}">{data.contactEmail}</a>.</p> 85 + </section> 86 + 87 + <section> 88 + <h2>7. children</h2> 89 + <p>{APP_NAME} is not for children under 13. we do not knowingly collect data from 90 + children.</p> 91 + </section> 92 + 93 + <section> 94 + <h2>8. changes</h2> 95 + <p>we may update this policy. material changes will be posted with notice.</p> 96 + </section> 97 + 98 + <section class="contact"> 99 + <h2>contact</h2> 100 + <p> 101 + questions? <a href="mailto:{data.privacyEmail}">{data.privacyEmail}</a> 102 + </p> 103 + </section> 104 + 105 + <nav class="legal-nav"> 106 + <a href="/terms">terms of service</a> 107 + </nav> 108 + </article> 109 + </div> 110 + 111 + <style> 112 + .legal-container { 113 + max-width: 700px; 114 + margin: 0 auto; 115 + padding: 2rem 1.5rem 6rem; 116 + } 117 + 118 + .legal-content { 119 + color: var(--text-primary); 120 + line-height: 1.7; 121 + } 122 + 123 + h1 { 124 + font-size: 2rem; 125 + margin: 0 0 0.5rem 0; 126 + } 127 + 128 + .last-updated { 129 + color: var(--text-tertiary); 130 + font-size: 0.9rem; 131 + margin: 0 0 2rem 0; 132 + } 133 + 134 + .intro { 135 + font-size: 1.05rem; 136 + color: var(--text-secondary); 137 + margin-bottom: 2rem; 138 + padding-bottom: 2rem; 139 + border-bottom: 1px solid var(--border-subtle); 140 + } 141 + 142 + section { 143 + margin-bottom: 2rem; 144 + } 145 + 146 + h2 { 147 + font-size: 1.1rem; 148 + margin: 0 0 0.75rem 0; 149 + color: var(--text-primary); 150 + } 151 + 152 + p { 153 + margin: 0 0 1rem 0; 154 + color: var(--text-secondary); 155 + } 156 + 157 + ul { 158 + margin: 0 0 1rem 0; 159 + padding-left: 1.5rem; 160 + color: var(--text-secondary); 161 + } 162 + 163 + li { 164 + margin-bottom: 0.4rem; 165 + } 166 + 167 + strong { 168 + color: var(--text-primary); 169 + } 170 + 171 + a { 172 + color: var(--accent); 173 + text-decoration: none; 174 + } 175 + 176 + a:hover { 177 + text-decoration: underline; 178 + } 179 + 180 + .contact { 181 + background: var(--bg-secondary); 182 + border: 1px solid var(--border-subtle); 183 + border-radius: 8px; 184 + padding: 1.5rem; 185 + margin-top: 2rem; 186 + } 187 + 188 + .contact h2 { 189 + margin-top: 0; 190 + } 191 + 192 + .contact p { 193 + margin: 0; 194 + } 195 + 196 + .legal-nav { 197 + margin-top: 2rem; 198 + padding-top: 1.5rem; 199 + border-top: 1px solid var(--border-subtle); 200 + } 201 + 202 + .legal-nav a { 203 + color: var(--text-tertiary); 204 + font-size: 0.9rem; 205 + } 206 + 207 + .legal-nav a:hover { 208 + color: var(--accent); 209 + } 210 + 211 + @media (max-width: 600px) { 212 + .legal-container { 213 + padding: 1.5rem 1rem 6rem; 214 + } 215 + 216 + h1 { 217 + font-size: 1.5rem; 218 + } 219 + } 220 + </style>
+11
frontend/src/routes/privacy/+page.ts
··· 1 + import { getServerConfig } from '$lib/config'; 2 + 3 + export async function load() { 4 + const config = await getServerConfig(); 5 + return { 6 + contactEmail: config.contact_email, 7 + privacyEmail: config.privacy_email, 8 + dmcaEmail: config.dmca_email, 9 + dmcaRegistrationNumber: config.dmca_registration_number 10 + }; 11 + }
+241
frontend/src/routes/terms/+page.svelte
··· 1 + <script lang="ts"> 2 + import { APP_NAME } from '$lib/branding'; 3 + import type { PageData } from './$types'; 4 + 5 + let { data } = $props<{ data: PageData }>(); 6 + </script> 7 + 8 + <svelte:head> 9 + <title>terms of service - {APP_NAME}</title> 10 + <meta name="description" content="Terms of Service for {APP_NAME}" /> 11 + </svelte:head> 12 + 13 + <div class="legal-container"> 14 + <article class="legal-content"> 15 + <h1>terms of service</h1> 16 + <p class="last-updated">last updated: december 16, 2025</p> 17 + 18 + <p class="intro"> 19 + these terms govern your use of {APP_NAME}, a music streaming platform built on the 20 + AT Protocol. by using the service, you agree to these terms. 21 + </p> 22 + 23 + <section> 24 + <h2>1. acceptance of terms</h2> 25 + <p> 26 + by accessing or using {APP_NAME}, you agree to be bound by these terms. if you 27 + disagree with any part, you may not use the service. 28 + </p> 29 + </section> 30 + 31 + <section> 32 + <h2>2. accounts</h2> 33 + <p> 34 + {APP_NAME} uses AT Protocol for authentication. your identity is your DID 35 + (decentralized identifier), not an account we control. we do not store passwords. 36 + </p> 37 + <p> 38 + we may terminate or suspend your access at any time, for any reason. termination 39 + removes your content from {APP_NAME} but does not affect your DID or data on your 40 + PDSโ€”we don't control those. 41 + </p> 42 + </section> 43 + 44 + <section> 45 + <h2>3. content</h2> 46 + <p> 47 + you retain ownership of content you upload. by uploading, you grant us a 48 + non-exclusive, worldwide, royalty-free license to use, reproduce, modify, and 49 + distribute your content as necessary to provide the service. 50 + </p> 51 + <p> 52 + you represent that you own or have the necessary rights to the content you upload, 53 + and that it does not infringe any third party's rights. 54 + </p> 55 + </section> 56 + 57 + <section> 58 + <h2>4. acceptable use</h2> 59 + <p>you agree not to:</p> 60 + <ul> 61 + <li>violate any applicable laws</li> 62 + <li>infringe on others' rights</li> 63 + <li>upload content that is illegal or infringes copyright</li> 64 + <li>attempt unauthorized access to the service</li> 65 + <li>interfere with or disrupt the service</li> 66 + </ul> 67 + </section> 68 + 69 + <section> 70 + <h2>5. copyright & DMCA</h2> 71 + <p> 72 + we respond to valid DMCA takedown notices. our designated agent is: 73 + </p> 74 + <address class="dmca-agent"> 75 + Nathan Nowack<br /> 76 + DMCA Registration: {data.dmcaRegistrationNumber}<br /> 77 + Email: <a href="mailto:{data.dmcaEmail}">{data.dmcaEmail}</a> 78 + </address> 79 + <p> 80 + we terminate accounts of repeat infringers. 81 + </p> 82 + <p> 83 + <strong>AT Protocol limitation:</strong> we can only remove content from our 84 + servers. we cannot delete content that has been published to other AT Protocol 85 + servers or your PDS. 86 + </p> 87 + </section> 88 + 89 + <section> 90 + <h2>6. disclaimers</h2> 91 + <p> 92 + the service is provided "as is" and "as available" without warranties of any kind. 93 + we do not guarantee uptime or that the service will be error-free. 94 + </p> 95 + <p> 96 + we are not liable for any indirect, incidental, special, or consequential damages 97 + resulting from your use of the service. 98 + </p> 99 + </section> 100 + 101 + <section> 102 + <h2>7. changes</h2> 103 + <p> 104 + we may update these terms. material changes will be posted with notice. continued 105 + use after changes constitutes acceptance. 106 + </p> 107 + </section> 108 + 109 + <section class="contact"> 110 + <h2>contact</h2> 111 + <p> 112 + questions? <a href="mailto:{data.contactEmail}">{data.contactEmail}</a> 113 + </p> 114 + </section> 115 + 116 + <nav class="legal-nav"> 117 + <a href="/privacy">privacy policy</a> 118 + </nav> 119 + </article> 120 + </div> 121 + 122 + <style> 123 + .legal-container { 124 + max-width: 700px; 125 + margin: 0 auto; 126 + padding: 2rem 1.5rem 6rem; 127 + } 128 + 129 + .legal-content { 130 + color: var(--text-primary); 131 + line-height: 1.7; 132 + } 133 + 134 + h1 { 135 + font-size: 2rem; 136 + margin: 0 0 0.5rem 0; 137 + } 138 + 139 + .last-updated { 140 + color: var(--text-tertiary); 141 + font-size: 0.9rem; 142 + margin: 0 0 2rem 0; 143 + } 144 + 145 + .intro { 146 + font-size: 1.05rem; 147 + color: var(--text-secondary); 148 + margin-bottom: 2rem; 149 + padding-bottom: 2rem; 150 + border-bottom: 1px solid var(--border-subtle); 151 + } 152 + 153 + section { 154 + margin-bottom: 2rem; 155 + } 156 + 157 + h2 { 158 + font-size: 1.1rem; 159 + margin: 0 0 0.75rem 0; 160 + color: var(--text-primary); 161 + } 162 + 163 + p { 164 + margin: 0 0 1rem 0; 165 + color: var(--text-secondary); 166 + } 167 + 168 + ul { 169 + margin: 0 0 1rem 0; 170 + padding-left: 1.5rem; 171 + color: var(--text-secondary); 172 + } 173 + 174 + li { 175 + margin-bottom: 0.4rem; 176 + } 177 + 178 + strong { 179 + color: var(--text-primary); 180 + } 181 + 182 + a { 183 + color: var(--accent); 184 + text-decoration: none; 185 + } 186 + 187 + a:hover { 188 + text-decoration: underline; 189 + } 190 + 191 + .dmca-agent { 192 + background: var(--bg-secondary); 193 + border: 1px solid var(--border-subtle); 194 + border-radius: 4px; 195 + padding: 1rem; 196 + font-style: normal; 197 + color: var(--text-secondary); 198 + margin: 1rem 0; 199 + } 200 + 201 + .contact { 202 + background: var(--bg-secondary); 203 + border: 1px solid var(--border-subtle); 204 + border-radius: 8px; 205 + padding: 1.5rem; 206 + margin-top: 2rem; 207 + } 208 + 209 + .contact h2 { 210 + margin-top: 0; 211 + } 212 + 213 + .contact p { 214 + margin: 0; 215 + } 216 + 217 + .legal-nav { 218 + margin-top: 2rem; 219 + padding-top: 1.5rem; 220 + border-top: 1px solid var(--border-subtle); 221 + } 222 + 223 + .legal-nav a { 224 + color: var(--text-tertiary); 225 + font-size: 0.9rem; 226 + } 227 + 228 + .legal-nav a:hover { 229 + color: var(--accent); 230 + } 231 + 232 + @media (max-width: 600px) { 233 + .legal-container { 234 + padding: 1.5rem 1rem 6rem; 235 + } 236 + 237 + h1 { 238 + font-size: 1.5rem; 239 + } 240 + } 241 + </style>
+10
frontend/src/routes/terms/+page.ts
··· 1 + import { getServerConfig } from '$lib/config'; 2 + 3 + export async function load() { 4 + const config = await getServerConfig(); 5 + return { 6 + contactEmail: config.contact_email, 7 + dmcaEmail: config.dmca_email, 8 + dmcaRegistrationNumber: config.dmca_registration_number 9 + }; 10 + }