From b1c4fe4899e73b00ce3049d6bb6940b5f353371d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9onard?= Date: Sat, 31 Oct 2026 08:50:30 +0100 Subject: [PATCH 01/11] feat(home): what changed, in a sentence, once after an update: a quote line under the version heading in CHANGELOG.md, embedded by the server (GET /api/release-note), said by the web and TV homes until closed; a first visit only remembers the version, the Docker build copies CHANGELOG.md --- AGENTS.md | 7 ++ CHANGELOG.md | 6 ++ Dockerfile | 2 +- .../java/studio/kahn/iris/tv/data/IrisApi.kt | 4 ++ .../studio/kahn/iris/tv/data/PrefsStore.kt | 8 +++ .../kahn/iris/tv/ui/screens/HomeScreen.kt | 14 +++- .../iris/tv/ui/screens/home/HomeViewModel.kt | 25 +++++++ .../iris/tv/ui/screens/home/HomeWordsTest.kt | 10 +++ crates/iris-api/src/app.rs | 1 + crates/iris-api/src/openapi.rs | 2 + crates/iris-api/src/routes/mod.rs | 1 + crates/iris-api/src/routes/release_note.rs | 69 +++++++++++++++++++ packages/iris-api/src/client.ts | 3 + web/openapi.json | 39 +++++++++++ web/src/lib/home/ReleaseNote.svelte | 53 ++++++++++++++ web/src/lib/home/data.test.ts | 10 ++- web/src/lib/home/data.ts | 9 ++- web/src/lib/queries.ts | 3 + web/src/lib/storage.ts | 2 + web/src/routes/+page.svelte | 2 + web/src/routes/home.svelte.test.ts | 13 ++++ 21 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 crates/iris-api/src/routes/release_note.rs create mode 100644 web/src/lib/home/ReleaseNote.svelte diff --git a/AGENTS.md b/AGENTS.md index 44c2a602..949a788e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,6 +232,13 @@ it), the worst case is the user gets a "Up to date" message and needs to manually hit Download. `just apk-push` is that script; don't upload the files by hand. +### The update sentence + +A release that users should hear about gets one `> sentence` right under its +`## [x.y.z]` heading in `CHANGELOG.md`, in English. The server embeds it at +build time (`routes/release_note.rs`) and the web and TV homes say it once. No +quote line, nothing said: fine for a quiet patch. + ### Versioning & client gate **All three components** carry the same semver-aligned version diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b81836c..786d34fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **What changed, in a sentence**: after an update the home says it once, on + the web and the TV. The sentence is a `> quote` line right under the version + heading here; a release without one says nothing. + ## [1.6.4] - 2026-10-31 ### Added diff --git a/Dockerfile b/Dockerfile index 72de3a76..b0be27c3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -123,7 +123,7 @@ COPY --from=planner /app/recipe.json ./recipe.json RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ cargo chef cook --release --bin iris --recipe-path recipe.json -COPY Cargo.toml Cargo.lock* ./ +COPY Cargo.toml Cargo.lock* CHANGELOG.md ./ COPY crates ./crates COPY migrations ./migrations RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/data/IrisApi.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/data/IrisApi.kt index aa033463..43341281 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/data/IrisApi.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/data/IrisApi.kt @@ -66,6 +66,10 @@ interface IrisApi { @PUT("api/me/preferences") suspend fun savePreferences(@Body body: UpdatePreferencesRequest): PreferencesResponse + /** The running server's version and its update sentence. */ + @GET("api/release-note") + suspend fun releaseNote(): ReleaseNote + /** Merged movie + TV genre taxonomy for the onboarding picker. Anime * is NOT in here — it's a distinct category driven by * [UpdatePreferencesRequest.includeAnime]. */ diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/data/PrefsStore.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/data/PrefsStore.kt index 8a12fcda..d72a8ac8 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/data/PrefsStore.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/data/PrefsStore.kt @@ -37,6 +37,7 @@ private val Context.prefsDataStore by preferencesDataStore("iris_prefs") private val KEY_SEARCH_VIEW_MODE = stringPreferencesKey("search_view_mode") private val KEY_LIBRARY_VIEW = stringPreferencesKey("library_view") private val KEY_INTERFACE_SIZE = stringPreferencesKey("interface_size") +private val KEY_RELEASE_SEEN = stringPreferencesKey("release_seen") /** * Small client-side UI preferences — NOT session / auth state (that's @@ -65,6 +66,13 @@ class PrefsStore(private val context: Context) { context.prefsDataStore.edit { it[KEY_LIBRARY_VIEW] = name } } + /** The last version whose update sentence this TV has had (web `STORAGE.releaseSeen`). */ + val releaseSeen: Flow = context.prefsDataStore.data.map { it[KEY_RELEASE_SEEN] } + + suspend fun setReleaseSeen(version: String) { + context.prefsDataStore.edit { it[KEY_RELEASE_SEEN] = version } + } + val interfaceSize: Flow = context.prefsDataStore.data.map { InterfaceSize.named(it[KEY_INTERFACE_SIZE]) } suspend fun setInterfaceSize(size: InterfaceSize) { diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HomeScreen.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HomeScreen.kt index be54a179..a5544959 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HomeScreen.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/HomeScreen.kt @@ -136,6 +136,7 @@ fun HomeScreen( state = state, update = updateNotice(updates), updateActions = updateActions, + onCloseReleaseNote = vm::closeReleaseNote, onHeroAction = vm::onHeroAction, onCardAction = vm::onCardAction, onRetry = vm::retry, @@ -160,6 +161,7 @@ fun HomeContent( onOpenSearch: () -> Unit, update: UpdateNotice? = null, updateActions: UpdateActions = UpdateActions(), + onCloseReleaseNote: () -> Unit = {}, ) { val layout = IrisLayout.current val header = LocalShellHeader.current @@ -170,7 +172,7 @@ fun HomeContent( // A card focused during this visit before the hero came (the platform's default focus lands // on the first focusable) is not a card left: only a return goes back to one. val returning = remember { focus.returns.last != null } - val heroIndex by rememberUpdatedState(if (update != null) 1 else 0) + val heroIndex by rememberUpdatedState(listOfNotNull(update, state.releaseNote).size) // The first focus: back on this screen the card left, else the hero's main action, else the // menu once the home knows it has no hero. The onboarding sheet closing puts it back too. @@ -236,6 +238,16 @@ fun HomeContent( UpdateBanner(update, updateActions, Modifier.padding(horizontal = layout.safeHorizontal)) } } + state.releaseNote?.let { note -> + item(key = "release-note", contentType = "update") { + NoticeLine( + note, + Modifier.padding(horizontal = layout.safeHorizontal), + onClose = onCloseReleaseNote, + takeFocus = false, + ) + } + } if (hero != null) { item(key = HERO_KEY, contentType = "hero") { Hero(hero, state.busy, heroFocus, onHeroAction, Modifier.padding(horizontal = layout.safeHorizontal)) diff --git a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HomeViewModel.kt b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HomeViewModel.kt index 5d9595ce..25659870 100644 --- a/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HomeViewModel.kt +++ b/android-tv/app/src/main/java/studio/kahn/iris/tv/ui/screens/home/HomeViewModel.kt @@ -40,6 +40,7 @@ import studio.kahn.iris.tv.data.MediaKind import studio.kahn.iris.tv.data.MediaMetadata import studio.kahn.iris.tv.data.PreferencesResponse import studio.kahn.iris.tv.data.ProgressUpdate +import studio.kahn.iris.tv.data.ReleaseNote import studio.kahn.iris.tv.data.RemoveWatchlistRequest import studio.kahn.iris.tv.data.TorrentView import studio.kahn.iris.tv.data.WatchlistItem @@ -77,6 +78,8 @@ data class HomeUiState( /** The action in flight (`get:`, `remove:`…): one at a time. */ val busy: String? = null, val notice: Notice? = null, + /** What changed, once after an update. */ + val releaseNote: Notice? = null, /** Non-null while the first-run preferences sheet is due. */ val onboarding: PreferencesResponse? = null, ) @@ -100,6 +103,7 @@ internal data class HomeData( val onboardingClosed: Boolean = false, val busy: String? = null, val notice: Notice? = null, + val release: ReleaseNote? = null, ) private const val LIBRARY_ROW = 12 @@ -108,6 +112,10 @@ internal fun ContinueWatchingItem.metaKey(): MetaKey? = tmdbId?.takeIf { tmdbVer internal fun CollectionListItem.metaKey(): MetaKey? = tmdbId?.let { MetaKey(it, kind) } private fun FeaturedResponse.pick() = movies.firstOrNull() ?: series.firstOrNull() +/** The update sentence still to say to a TV that last saw [seen] (web `noteDue`): none on a first launch, nor once this version was seen. */ +internal fun noteDue(release: ReleaseNote, seen: String?): String? = + release.note.takeIf { seen != null && seen != release.version } + /** The home's words from what it read. Pure: the unit tests feed it. */ internal fun homeUi(d: HomeData): HomeUiState { val downloads = downloadsByCollection(d.torrents) @@ -142,6 +150,7 @@ internal fun homeUi(d: HomeData): HomeUiState { libraryCount = collections.size, busy = d.busy, notice = d.notice, + releaseNote = d.release?.note?.let { Notice("Updated to ${d.release.version} — $it") }, onboarding = d.preferences?.takeIf { !it.onboardingCompleted && !d.onboardingClosed }, ) } @@ -172,6 +181,7 @@ class HomeViewModel( private var forYouReadAt: Long? = null private var collectionsReadAt: Long? = null private val actions = BusyActions(viewModelScope, oneAtATime = true) + private var releaseRead = false init { viewModelScope.launch { @@ -201,9 +211,24 @@ class HomeViewModel( launch { readWatchlist() } launch { readCollections() } launch { readPreferences() } + launch { readReleaseNote() } if (force || olderThan(forYouReadAt, FOR_YOU_STALE_MS)) launch { readForYou() } } + private suspend fun readReleaseNote() { + if (releaseRead) return + releaseRead = true + val release = bestEffort { container.api().releaseNote() } ?: return + val due = noteDue(release, container.prefsStore.releaseSeen.first()) != null + if (due) data.update { it.copy(release = release) } else container.prefsStore.setReleaseSeen(release.version) + } + + fun closeReleaseNote() { + val release = data.value.release ?: return + data.update { it.copy(release = null) } + viewModelScope.launch { container.prefsStore.setReleaseSeen(release.version) } + } + private suspend fun refreshLive() = coroutineScope { val summary = async { load(data.value.summary) { absentAs(null) { container.api().homeSummary() } } } val torrents = async { bestEffort { container.api().libraryTorrents().items } } diff --git a/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/screens/home/HomeWordsTest.kt b/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/screens/home/HomeWordsTest.kt index 50fa6b3e..0f8d0f07 100644 --- a/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/screens/home/HomeWordsTest.kt +++ b/android-tv/app/src/test/java/studio/kahn/iris/tv/ui/screens/home/HomeWordsTest.kt @@ -9,6 +9,7 @@ import studio.kahn.iris.tv.data.ContinueWatchingItem import studio.kahn.iris.tv.data.DiskSpace import studio.kahn.iris.tv.data.HomeSummary import studio.kahn.iris.tv.data.MediaKind +import studio.kahn.iris.tv.data.ReleaseNote import studio.kahn.iris.tv.data.TorrentState import studio.kahn.iris.tv.data.TorrentView @@ -25,6 +26,15 @@ class HomeWordsTest { assertEquals(emptyList(), rightNow(HomeSummary(0, 0.0, 0, 0))) } + @Test + fun updateSentenceAfterAnUpdateOnly() { + val release = ReleaseNote(version = "2.0.0", note = "Two more trackers.") + assertEquals("Two more trackers.", noteDue(release, "1.9.0")) + assertNull(noteDue(release, "2.0.0")) + assertNull(noteDue(release, null)) + assertNull(noteDue(ReleaseNote(version = "2.0.0"), "1.9.0")) + } + @Test fun downloadsPerCollectionByBytes() { val a = UUID.randomUUID() diff --git a/crates/iris-api/src/app.rs b/crates/iris-api/src/app.rs index b904468a..8b88e53e 100644 --- a/crates/iris-api/src/app.rs +++ b/crates/iris-api/src/app.rs @@ -97,6 +97,7 @@ pub fn build_router(state: AppState) -> Router { .nest("/torrents", torrents) .nest("/providers", routes::providers::router()) .nest("/metadata", routes::metadata::router()) + .route("/release-note", get(routes::release_note::get)) .layer(axum::middleware::from_fn(client_version_layer)); let api = Router::new() .route("/health", get(routes::health::get)) diff --git a/crates/iris-api/src/openapi.rs b/crates/iris-api/src/openapi.rs index f491f5bd..0e4f91c1 100644 --- a/crates/iris-api/src/openapi.rs +++ b/crates/iris-api/src/openapi.rs @@ -153,11 +153,13 @@ use utoipa::OpenApi; crate::routes::seleo::share, crate::routes::seleo::unshare, crate::routes::health::get, + crate::routes::release_note::get, ), components(schemas( crate::routes::auth::RegisterRequest, crate::routes::auth::LoginRequest, crate::routes::auth::UserResponse, + crate::routes::release_note::ReleaseNote, crate::routes::devices::CreateCodeRequest, crate::routes::devices::CreateCodeResponse, crate::routes::devices::LinkRequest, diff --git a/crates/iris-api/src/routes/mod.rs b/crates/iris-api/src/routes/mod.rs index 245cbffb..12fd2dac 100644 --- a/crates/iris-api/src/routes/mod.rs +++ b/crates/iris-api/src/routes/mod.rs @@ -15,6 +15,7 @@ pub mod passkeys; pub mod playback_preferences; pub mod preferences; pub mod providers; +pub mod release_note; pub mod search; pub mod seleo; pub mod torrents; diff --git a/crates/iris-api/src/routes/release_note.rs b/crates/iris-api/src/routes/release_note.rs new file mode 100644 index 00000000..c9539b1c --- /dev/null +++ b/crates/iris-api/src/routes/release_note.rs @@ -0,0 +1,69 @@ +//! The one sentence said after an update, read from `CHANGELOG.md` at build +//! time: the quote line right under the running version's heading. + +use axum::Json; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::routes::extract::AuthUser; + +const CHANGELOG: &str = include_str!("../../../../CHANGELOG.md"); + +#[derive(Debug, Serialize, ToSchema)] +pub struct ReleaseNote { + /// The version this server runs. + pub version: String, + /// What changed, in a sentence. Absent when the release has none to say. + pub note: Option, +} + +#[utoipa::path( + get, + path = "/api/release-note", + operation_id = "releaseNote", + responses((status = 200, description = "The running version and its update sentence", body = ReleaseNote)), + tag = "health", +)] +pub async fn get(_user: AuthUser) -> Json { + let version = env!("CARGO_PKG_VERSION"); + Json(ReleaseNote { + version: version.to_owned(), + note: sentence_of(CHANGELOG, version).map(str::to_owned), + }) +} + +fn sentence_of<'a>(changelog: &'a str, version: &str) -> Option<&'a str> { + let heading = format!("## [{version}]"); + let mut lines = changelog.lines().skip_while(|l| !l.starts_with(&heading)); + lines.next()?; + let first = lines.find(|l| !l.trim().is_empty())?; + let sentence = first.strip_prefix('>')?.trim(); + (!sentence.is_empty()).then_some(sentence) +} + +#[cfg(test)] +mod tests { + use super::{CHANGELOG, sentence_of}; + + const LOG: &str = "## [Unreleased]\n\n## [2.0.0] - 2026-11-02\n\n> Two more trackers.\n\n### Added\n\n- x\n\n## [1.9.0] - 2026-10-01\n\n### Fixed\n\n> not a note\n"; + + #[test] + fn reads_the_quote_right_under_the_heading() { + assert_eq!(sentence_of(LOG, "2.0.0"), Some("Two more trackers.")); + } + + #[test] + fn a_release_without_a_quote_says_nothing() { + assert_eq!(sentence_of(LOG, "1.9.0"), None); + } + + #[test] + fn an_unknown_version_says_nothing() { + assert_eq!(sentence_of(LOG, "3.0.0"), None); + } + + #[test] + fn the_changelog_is_embedded() { + assert!(CHANGELOG.contains("## [1.6.4]")); + } +} diff --git a/packages/iris-api/src/client.ts b/packages/iris-api/src/client.ts index dedb178f..2f395303 100644 --- a/packages/iris-api/src/client.ts +++ b/packages/iris-api/src/client.ts @@ -9,6 +9,7 @@ declare const __IRIS_WEB_VERSION__: string; * under the existing names so call sites don't change; the rest stays * hand-written until its endpoints are annotated in later waves. */ export type User = components['schemas']['UserResponse']; +export type ReleaseNote = components['schemas']['ReleaseNote']; /** The longest name the server accepts (display names, passkey names), in bytes. */ export const MAX_NAME = 64; @@ -482,6 +483,8 @@ export type ForYouResponse = components['schemas']['ForYou']; export type MoodTile = components['schemas']['MoodTile']; export type MoodBoard = components['schemas']['MoodBoard']; export type MoodResults = components['schemas']['MoodResults']; +export const releaseNote = () => api.get('/release-note'); + export const me = { /** `include_grabbable` opts into synthesised "next episode isn't on * disk yet" tiles (`grabbable: true`, empty infohash) — the web bundle diff --git a/web/openapi.json b/web/openapi.json index 311a8a07..f17eadcb 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -3020,6 +3020,26 @@ } } }, + "/api/release-note": { + "get": { + "tags": [ + "health" + ], + "operationId": "releaseNote", + "responses": { + "200": { + "description": "The running version and its update sentence", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReleaseNote" + } + } + } + } + } + } + }, "/api/search": { "get": { "tags": [ @@ -8022,6 +8042,25 @@ ], "description": "A release page: the tracker's own details plus what Iris knows about the\nrelease, so the page stands alone (no search results needed). `flatten`\nkeeps the wire shape of `TorrentDetails` for shipped clients." }, + "ReleaseNote": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "note": { + "type": [ + "string", + "null" + ], + "description": "What changed, in a sentence. Absent when the release has none to say." + }, + "version": { + "type": "string", + "description": "The version this server runs." + } + } + }, "RemoveWatchlistRequest": { "type": "object", "required": [ diff --git a/web/src/lib/home/ReleaseNote.svelte b/web/src/lib/home/ReleaseNote.svelte new file mode 100644 index 00000000..74fc46f7 --- /dev/null +++ b/web/src/lib/home/ReleaseNote.svelte @@ -0,0 +1,53 @@ + + +{#if note && release.data} +

+ Updated to {release.data.version} — {note} + +

+{/if} + + diff --git a/web/src/lib/home/data.test.ts b/web/src/lib/home/data.test.ts index 3e6729fe..a5909a4e 100644 --- a/web/src/lib/home/data.test.ts +++ b/web/src/lib/home/data.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { TorrentView } from '@iris/api/client'; -import { downloadsByCollection, rightNow, secondsLeft } from './data.ts'; +import { downloadsByCollection, noteDue, rightNow, secondsLeft } from './data.ts'; describe('home words', () => { it('"Right now": only what is downloading, in words', () => { @@ -18,6 +18,14 @@ describe('home words', () => { expect(rightNow({ downloading: 0, downloading_pct: 0, new_episodes: 0, seeding: 0 })).toEqual([]); }); + it('the update sentence: after an update only, and only when the release has one', () => { + const release = { version: '2.0.0', note: 'Two more trackers.' }; + expect(noteDue(release, '1.9.0')).toBe('Two more trackers.'); + expect(noteDue(release, '2.0.0')).toBeUndefined(); + expect(noteDue(release, undefined)).toBeUndefined(); + expect(noteDue({ version: '2.0.0', note: null }, '1.9.0')).toBeUndefined(); + }); + it('downloads per collection, by bytes; finished ones and loose torrents left out', () => { const t = (collection_id: string | null, done: number, total: number, finished = false) => ({ collection_id, progress_bytes: done, total_size_bytes: total, finished }) as TorrentView; diff --git a/web/src/lib/home/data.ts b/web/src/lib/home/data.ts index 9c167fd3..d7214ada 100644 --- a/web/src/lib/home/data.ts +++ b/web/src/lib/home/data.ts @@ -1,7 +1,7 @@ // The words the home and discover screens say about what they read: the "Right now" facts, // the downloads per collection, a title's kind, said once. -import type { ContinueWatchingItem, HomeSummary, TorrentView } from '@iris/api/client'; +import type { ContinueWatchingItem, HomeSummary, ReleaseNote, TorrentView } from '@iris/api/client'; import { percent, plural, timeLeft } from '@iris/api/format'; import { isFetching } from '#lib/torrent.ts'; @@ -17,6 +17,13 @@ export function rightNow(s: HomeSummary): string[] { return facts; } +/** The update sentence still to say to a browser that last saw `seen`: none on a first visit, + * nor once this version was seen. */ +export function noteDue(release: ReleaseNote, seen: string | undefined): string | undefined { + if (seen === undefined || seen === release.version) return undefined; + return release.note ?? undefined; +} + /** What is still downloading in each collection, as a share of its bytes (0 to 100). */ export function downloadsByCollection(list: readonly TorrentView[]): Map { const sums = new Map(); diff --git a/web/src/lib/queries.ts b/web/src/lib/queries.ts index e14b755a..217769ec 100644 --- a/web/src/lib/queries.ts +++ b/web/src/lib/queries.ts @@ -11,6 +11,7 @@ import { me as meApi, metadata, progress, + releaseNote, torrents, type CollectionListItem, type LibraryResponse, @@ -49,6 +50,7 @@ export const KEYS = { preferences: ['preferences'], genres: ['genres'], languages: ['languages'], + releaseNote: ['release-note'], playbackPrefsAll: ['playback-prefs'], playbackPrefs: (collectionId: string | null) => ['playback-prefs', collectionId] as const, tmdb: (id: number | null, kind: MediaKind | null) => ['tmdb', id, kind] as const, @@ -152,6 +154,7 @@ export const read = { preferences: () => ({ queryKey: KEYS.preferences, queryFn: meApi.preferences, staleTime: 5 * 60_000 }), genres: () => ({ queryKey: KEYS.genres, queryFn: discover.genres, staleTime: DAY }), languages: () => ({ queryKey: KEYS.languages, queryFn: discover.languages, staleTime: DAY }), + releaseNote: () => ({ queryKey: KEYS.releaseNote, queryFn: releaseNote, staleTime: DAY }), /** The account's audio and subtitle choice, or a series' own when it has one. */ playbackPrefs: (collectionId: string | null) => ({ queryKey: KEYS.playbackPrefs(collectionId), diff --git a/web/src/lib/storage.ts b/web/src/lib/storage.ts index 89cd240d..082c274b 100644 --- a/web/src/lib/storage.ts +++ b/web/src/lib/storage.ts @@ -6,6 +6,8 @@ export const STORAGE = { librarySort: 'iris-library-sort', searchView: 'iris-search-view', adminView: 'iris-admin-view', + /** The last version whose update sentence this browser has had. */ + releaseSeen: 'iris-release-seen', /** The Live TV countries last picked, most recent first. */ liveCountries: 'iris-live-countries', /** Kept under the React app's names, so a browser keeps its volume and theater across the switch. */ diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 4655209a..29394691 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -10,6 +10,7 @@ import Icon from '#lib/components/Icon.svelte'; import { loadable } from '#lib/query.ts'; import Row from '#lib/home/Row.svelte'; + import ReleaseNote from '#lib/home/ReleaseNote.svelte'; import RightNow from '#lib/home/RightNow.svelte'; import Onboarding from '#lib/home/Onboarding.svelte'; import ResumeHero from '#lib/home/ResumeHero.svelte'; @@ -66,6 +67,7 @@ {/if}
+ = {}) { '/me/for-you': { shelves: [] }, '/library?view=collections': { view: 'collections', items: [] }, '/library?view=torrents': { view: 'torrents', items: [] }, + '/release-note': { version: '1.0.0' }, '/me/summary': { downloading: 0, downloading_pct: 0, new_episodes: 0, seeding: 0 }, '/me/preferences': { languages: [], genres: [], include_anime: false, onboarding_completed: true }, '/discover/featured': { movies: [], series: [] }, @@ -84,6 +86,17 @@ const shelf = (name: string) => page.getByRole('region', { name, exact: true }); describe('home', () => { beforeEach(() => nav.goto.mockClear()); + it('says what changed once, after an update, until closed', async () => { + localStorage.setItem(STORAGE.releaseSeen, '"1.9.0"'); + home({ '/release-note': { version: '2.0.0', note: 'Two more trackers.' } }); + await show(); + await expect.element(page.getByText('Updated to 2.0.0 — Two more trackers.')).toBeVisible(); + await page.getByRole('button', { name: 'Close' }).click(); + await expect.element(page.getByText('Updated to 2.0.0 — Two more trackers.')).not.toBeInTheDocument(); + expect(localStorage.getItem(STORAGE.releaseSeen)).toBe('"2.0.0"'); + localStorage.removeItem(STORAGE.releaseSeen); + }); + it('resumes the last thing: where it stopped, what is left, the languages it will play with', async () => { home({ '/me/continue-watching?include_grabbable=true': [tile()] }); await show(); From 3264d03c5ada7b9811fcb080e5f372d38a6d9219 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9onard?= Date: Sat, 31 Oct 2026 08:52:16 +0100 Subject: [PATCH 02/11] deploy: drop the Cloudflare tunnel, Iris sits behind a reverse proxy on the host HTTP is published on 127.0.0.1:8450 (IRIS_HTTP_PORT); the proxy sets CF-Connecting-IP, which the rate limits key on. --- .env.example | 20 ++++----------- README.md | 8 +++--- docker-compose.yml | 43 ++++++-------------------------- docs/DEPLOYMENT.md | 62 +++++++++++++++++++++------------------------- justfile | 12 +++------ 5 files changed, 48 insertions(+), 97 deletions(-) diff --git a/.env.example b/.env.example index 964b9bd0..d2bf5ce9 100644 --- a/.env.example +++ b/.env.example @@ -12,14 +12,14 @@ IRIS_AUTH__BOOTSTRAP_ADMIN__EMAIL=admin@iris.local IRIS_AUTH__BOOTSTRAP_ADMIN__PASSWORD=change-me-immediately # ===================================================================== -# Public URL — used as the JWT issuer claim. Should match what Cloudflare -# (or whatever proxy you use) exposes externally so refresh-tokens issued +# Public URL — used as the JWT issuer claim. Should match what your proxy +# exposes externally so refresh-tokens issued # behind one front-door verify correctly behind another. # ===================================================================== IRIS_SERVER__PUBLIC_URL=http://localhost:8080 # ===================================================================== -# Local-port options (irrelevant when fronting via Cloudflare tunnel). +# Local-port options. # ===================================================================== # Host port of `just dev` only (docker-compose.dev.yml, bound to # 127.0.0.1). `just deploy` publishes no HTTP port at all. @@ -27,22 +27,12 @@ IRIS_BIND_PORT=8080 # BitTorrent listen port (TCP + UDP). Forward this in your home router / # server firewall for inbound peer connections — major impact on download # speed and seeding ratio on private trackers. +# Loopback port the reverse proxy on the host reaches Iris on. +IRIS_HTTP_PORT=8450 IRIS_TORRENT_PORT=45100 CADDY_HTTP_PORT=80 CADDY_HTTPS_PORT=443 -# ===================================================================== -# Cloudflare named tunnel — gives Iris a stable public URL on your own -# domain without opening any inbound ports. Workflow: -# 1. https://one.dash.cloudflare.com/ → Networks → Tunnels → Create -# 2. Copy the tunnel TOKEN here -# 3. In the tunnel's "Public Hostname" tab, point your hostname at -# "http://iris:8080" (the compose service name resolves on the -# cloudflared <-> iris network). -# 4. docker compose --profile cloudflared up -d -# ===================================================================== -CLOUDFLARE_TUNNEL_TOKEN= - # ===================================================================== # Optional: TMDB API key for poster + metadata enrichment in search and # library views. Get a free key at https://www.themoviedb.org/settings/api diff --git a/README.md b/README.md index f5700bf0..60aba408 100644 --- a/README.md +++ b/README.md @@ -44,19 +44,19 @@ phones and tablets, touch included. ## Serve it -Docker on any always-on box. The bundled compose file publishes no HTTP -port: a Cloudflare tunnel (or the Caddy profile) reaches the server. +Docker on any always-on box. The bundled compose file publishes HTTP on +loopback only: a reverse proxy on the host reaches the server. ```console $ cp .env.example .env # JWT secret, public URL, tracker keys, TMDB key $ cp config/config.toml.example config/config.toml $ cp config/providers.toml.example config/providers.toml -$ just deploy # docker compose --profile cloudflared up --build -d +$ just deploy # docker compose up --build -d ``` Sign in with the bootstrap admin from `.env`, make an invitation in Admin, open it to create your account. The full walkthrough (rootless -Docker, the BitTorrent port, the tunnel, storage limits, upgrades) is +Docker, the BitTorrent port, the proxy, storage limits, upgrades) is in [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). ## From source diff --git a/docker-compose.yml b/docker-compose.yml index ee2c18c5..d8f46775 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,7 @@ services: # Serve the bundled React build alongside the API. IRIS_SERVER__WEB_DIST: "/srv/iris/web" # Public URL — used as the JWT issuer claim. Should be the URL your - # users actually hit (e.g. your Cloudflare-tunneled hostname). Pulled + # users actually hit (your reverse proxy's hostname). Pulled # from .env so changes don't require touching this file. IRIS_SERVER__PUBLIC_URL: ${IRIS_SERVER__PUBLIC_URL:-http://localhost:8080} # Persist all data into the volume mounted at /data, regardless of the @@ -34,12 +34,13 @@ services: volumes: - ./config:/srv/iris/config:ro - iris-data:/data - # The HTTP port is NOT published: cloudflared (and caddy) reach - # `iris:8080` over the compose network, and a published port is open to - # the internet (Docker bypasses the host firewall) where anyone could - # forge `CF-Connecting-IP` past the auth rate limits. Local access goes - # through the dev override (`just dev`, docker-compose.dev.yml). ports: + # HTTP, on loopback ONLY: the reverse proxy on the host reaches it there. + # Published on every interface it would be open to the internet (Docker + # bypasses the host firewall), where anyone could forge + # `CF-Connecting-IP` past the auth rate limits. The proxy must set that + # header to the client's address (docs/DEPLOYMENT.md §4). + - "127.0.0.1:${IRIS_HTTP_PORT:-8450}:8080" # Pinned BitTorrent listen port — forwarded so inbound peer connections # actually reach librqbit (matters for download speed AND ratio on # private trackers). @@ -65,36 +66,6 @@ services: - iris profiles: ["proxy"] - # Named Cloudflare tunnel — gives Iris a stable public URL on a domain you - # control, no port-forwarding required. Set up: - # 1. https://one.dash.cloudflare.com/ → Networks → Tunnels → "Create" - # 2. Copy the tunnel TOKEN into .env as CLOUDFLARE_TUNNEL_TOKEN - # 3. In the tunnel's "Public Hostname" tab, add your hostname pointing at - # service "http://iris:8080" (Cloudflare will resolve `iris` over the - # compose network). - # 4. Run with: `just deploy` (docker compose --profile cloudflared up -d) - # - # The `cloudflared` profile keeps this OUT of a plain `docker compose up` / - # `just dev`, so local dev never dials the tunnel. The token deliberately - # uses `:-` (not `:?`): Compose interpolates the WHOLE file even for - # profile-disabled services, so a hard `:?` would break every compose - # command on machines without the token in .env. The fail-loudly check - # lives in the `just deploy` recipe instead — the only path that enables - # the profile. - cloudflared: - image: cloudflare/cloudflared:latest - restart: unless-stopped - # `--protocol http2` forces HTTP/2 instead of QUIC. QUIC uses UDP/7844 - # outbound which is often dropped by ISPs, NAT or Docker Desktop on - # macOS. Symptom: repeated `failed to dial to edge with quic: timeout`. - command: tunnel --no-autoupdate --protocol http2 run - environment: - TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:-} - TUNNEL_TRANSPORT_PROTOCOL: http2 - depends_on: - - iris - profiles: ["cloudflared"] - volumes: iris-data: caddy-data: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 7beb66f6..a35e1d1d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -2,8 +2,8 @@ Target: a Debian 13 (Trixie) box running rootless Docker, e.g. an OVH KS-5 (Xeon E3-1270v6, 32 GB, 2x450 GB NVMe in software RAID — `/dev/md3` mounted -at `/`, ≈ 820 GB usable). This guide assumes that machine and a -Cloudflare-tunnelled public URL. +at `/`, ≈ 820 GB usable). This guide assumes that machine and a reverse +proxy on the host for the public URL. If you're on a different host: skip the OVH-specific bits, but the rootless Docker / systemd-user / data-root sections still apply unchanged. @@ -56,13 +56,10 @@ OVH's per-IP "Network Firewall" (Manager → Bare Metal → IP → Firewall) is the anti-DDoS that's always on doesn't block ordinary inbound traffic. If you have enabled it, add `45100/tcp` and `45100/udp` accept rules. -The HTTP port stays unexposed — Cloudflare tunnel handles ingress, no -inbound 80/443 needed. `docker-compose.yml` publishes no HTTP port at all: -cloudflared reaches `iris:8080` over the compose network. Don't add one — +The HTTP port stays off the internet: `docker-compose.yml` publishes it on +`127.0.0.1` only, for the reverse proxy of §4. Don't widen that bind — Docker-published ports bypass the host firewall, and a request reaching the origin directly could forge `CF-Connecting-IP` past the auth rate limits. -To reach the stack from the server itself, `just dev` loads -`docker-compose.dev.yml`, which binds the port on `127.0.0.1` only. --- @@ -85,7 +82,7 @@ IRIS_JWT_SECRET=<48-byte base64> IRIS_AUTH__BOOTSTRAP_ADMIN__EMAIL=you@example.com IRIS_AUTH__BOOTSTRAP_ADMIN__PASSWORD= -# Public URL — must match the Cloudflare tunnel hostname exactly. JWT issuer +# Public URL — must match the reverse proxy's hostname exactly. JWT issuer # claims and the device pairing verification URL are derived from this. IRIS_SERVER__PUBLIC_URL=https://iris.example.com @@ -94,9 +91,6 @@ IRIS_SERVER__PUBLIC_URL=https://iris.example.com # you opened above. IRIS_TORRENT_PORT=45100 -# Cloudflare tunnel token — see §4. -CLOUDFLARE_TUNNEL_TOKEN= - # TMDB API key — free, used for poster/backdrop/title metadata. Without it # the search and library work but show no posters. IRIS_TMDB__API_KEY= @@ -164,30 +158,32 @@ docker run --rm -v iris-data:/data alpine tar -czC / data > iris-data.tgz --- -## 4. Cloudflare tunnel +## 4. Reverse proxy + +Iris listens on `127.0.0.1:8450` (`IRIS_HTTP_PORT`). Run a proxy on the host +that terminates TLS for your hostname, sets `CF-Connecting-IP` to the client's +address and buffers nothing. With Caddy: -1. → **Networks → Tunnels → Create - tunnel** → "Cloudflared" → name it `iris`. -2. Skip the "install on this machine" step; copy just the **tunnel token** - into `.env` as `CLOUDFLARE_TUNNEL_TOKEN`. -3. **Public Hostname** tab → add an entry pointing your hostname - (e.g. `iris.example.com`) at **Service: `http://iris:8080`**. - `iris` here is the compose service name, resolved over the - compose-internal network — *not* `localhost` (a frequent mistake that - surfaces as a 502 from Cloudflare). -4. Cloudflare will create the DNS CNAME for you. +```caddyfile +iris.example.com { + encode zstd gzip + reverse_proxy 127.0.0.1:8450 { + header_up CF-Connecting-IP {remote_host} + flush_interval -1 + } +} +``` -The compose file forces HTTP/2 transport (`--protocol http2`): -QUIC over UDP/7844 outbound is silently dropped by some ISPs and was the -cause of `failed to dial to edge with quic: timeout` loops in development. +That header is what the auth rate limits key on, whatever the proxy: without +it every client shares one bucket. Setting it also replaces any value a client +sent, so it cannot be forged. --- ## 5. First boot ```bash -# Compose v2 with both profiles: API + tunnel. -docker compose --profile cloudflared up -d --build +docker compose up -d --build # Watch the bootstrap migration apply (0001..0005) and the bootstrap # admin get created. @@ -226,8 +222,8 @@ Requires=docker.service Type=oneshot RemainAfterExit=yes WorkingDirectory=/srv/iris -ExecStart=/usr/bin/docker compose --profile cloudflared up -d -ExecStop=/usr/bin/docker compose --profile cloudflared down +ExecStart=/usr/bin/docker compose up -d +ExecStop=/usr/bin/docker compose down [Install] WantedBy=default.target @@ -249,7 +245,7 @@ Reboot once to verify. The unit comes up before any login because ```bash cd /srv/iris git pull -make deploy ARGS="--profile cloudflared" # rebuilds iris:dev, stamps the build with the commit +make deploy # rebuilds iris:dev, stamps the build with the commit ``` `make deploy` injects `IRIS_WEB_BUILD_ID=$(git rev-parse --short HEAD)` as a @@ -262,7 +258,7 @@ can't read the sha itself — it has to come from the host like this. No `make`? The raw equivalent is: ```bash -IRIS_WEB_BUILD_ID=$(git rev-parse --short HEAD) docker compose --profile cloudflared up -d --build +IRIS_WEB_BUILD_ID=$(git rev-parse --short HEAD) docker compose up -d --build ``` Plain `docker compose up -d --build` (no build arg) still works — Vite then @@ -369,12 +365,10 @@ database file. | Symptom | Where to look | |---|---| -| 502 from Cloudflare | The tunnel's "Public Hostname" service must be `http://iris:8080`, not `localhost:8080`. | -| `failed to dial to edge with quic: timeout` | Confirm `--protocol http2` is in the compose file. (Some hosts still QUIC-handshake even with the flag — `TUNNEL_TRANSPORT_PROTOCOL=http2` env var is the belt for the suspenders.) | | Torrents never connect to peers | First verify reachability from outside: `nmap -Pn -p 45100 `. If the port is filtered, check (in this order): the iris container is publishing it (`docker compose ps`), no host-level firewall is blocking (`sudo nft list ruleset`, `sudo ufw status` if installed), and finally OVH's per-IP Network Firewall in the manager (off by default — only relevant if you opted in). | | `admin` login fails on first boot | The bootstrap block is a no-op if any user exists. Check `docker compose exec iris sqlite3 /data/iris.db 'select email from users;'` — you may have an orphaned account from a prior install. | | `Field 'X' is required …` JSON errors | Wire format drift. Pin the iris image and the Android APK to the same git SHA. | -| Downloads slow despite a 1 Gbps line | rootless slirp4netns caps around 600 Mbps. If you genuinely saturate that, switch the iris service to `network_mode: host` (you'll then need to point Cloudflare's "Public Hostname" service at `http://localhost:8080` instead of `http://iris:8080`). | +| Downloads slow despite a 1 Gbps line | rootless slirp4netns caps around 600 Mbps. If you genuinely saturate that, switch the iris service to `network_mode: host`. | --- diff --git a/justfile b/justfile index d081e6d5..7608667f 100644 --- a/justfile +++ b/justfile @@ -14,18 +14,14 @@ export IRIS_WEB_BUILD_ID := `git rev-parse --short HEAD 2>/dev/null || true` default: @just --list -# Rebuild + restart (cloudflared profile, build-id stamped); extra flags pass -# through. The token guard lives HERE (not as `:?` in docker-compose.yml) -# because Compose interpolates profile-disabled services too — a hard `:?` -# would break `just dev` on machines without the tunnel token. +# Rebuild + restart (build-id stamped); extra flags pass through. deploy *ARGS: - @[ -n "${CLOUDFLARE_TUNNEL_TOKEN:-}" ] || grep -q '^CLOUDFLARE_TUNNEL_TOKEN=..*' .env 2>/dev/null || { echo "deploy needs CLOUDFLARE_TUNNEL_TOKEN in .env (the cloudflared profile dials the tunnel with it)"; exit 1; } - docker compose {{ ARGS }} --profile cloudflared up -d --build + docker compose {{ ARGS }} up -d --build -# Local docker deploy (no tunnel/proxy profile), build-id stamped. +# Local docker deploy (no proxy profile), build-id stamped. # `just dev` serves on 127.0.0.1:8080; `just dev 18080` picks another host # port (container keeps 8080 internally — only the mapping changes). The -# port mapping lives in docker-compose.dev.yml only: production publishes none. +# port mapping lives in docker-compose.dev.yml. dev port="8080": IRIS_BIND_PORT={{ port }} docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build From c4f1ccd97654a27871a27123bff9cee4b74306a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9onard?= Date: Sat, 31 Oct 2026 08:55:36 +0100 Subject: [PATCH 03/11] feat(profiles): a shared account holds up to six profiles, each a users row of its own, so progress, follows and preferences need no change; POST /auth/switch-profile starts a session on the other row and keeps a paired device's label, sign-in and the admin list see accounts only, a password change cuts every profile, removal hands the grabs to the account, passwords and passkeys stay on the main profile --- crates/iris-api/src/openapi.rs | 7 + crates/iris-api/src/routes/admin.rs | 4 +- crates/iris-api/src/routes/auth.rs | 4 + crates/iris-api/src/routes/me.rs | 4 +- crates/iris-api/src/routes/mod.rs | 1 + crates/iris-api/src/routes/passkeys.rs | 1 + crates/iris-api/src/routes/profiles.rs | 343 +++++++++++++++++++++++++ crates/iris-api/src/session_cut.rs | 11 +- crates/iris-core/src/user.rs | 10 + crates/iris-db/src/refresh_tokens.rs | 24 +- crates/iris-db/src/users.rs | 187 +++++++++++++- migrations/0053_profiles.sql | 8 + web/openapi.json | 182 +++++++++++++ 13 files changed, 762 insertions(+), 24 deletions(-) create mode 100644 crates/iris-api/src/routes/profiles.rs create mode 100644 migrations/0053_profiles.sql diff --git a/crates/iris-api/src/openapi.rs b/crates/iris-api/src/openapi.rs index 0e4f91c1..e730c2c9 100644 --- a/crates/iris-api/src/openapi.rs +++ b/crates/iris-api/src/openapi.rs @@ -154,12 +154,19 @@ use utoipa::OpenApi; crate::routes::seleo::unshare, crate::routes::health::get, crate::routes::release_note::get, + crate::routes::profiles::list, + crate::routes::profiles::create, + crate::routes::profiles::remove, + crate::routes::profiles::switch, ), components(schemas( crate::routes::auth::RegisterRequest, crate::routes::auth::LoginRequest, crate::routes::auth::UserResponse, crate::routes::release_note::ReleaseNote, + crate::routes::profiles::ProfileView, + crate::routes::profiles::CreateProfileRequest, + crate::routes::profiles::SwitchProfileRequest, crate::routes::devices::CreateCodeRequest, crate::routes::devices::CreateCodeResponse, crate::routes::devices::LinkRequest, diff --git a/crates/iris-api/src/routes/admin.rs b/crates/iris-api/src/routes/admin.rs index 2ce1dcdf..769ecc39 100644 --- a/crates/iris-api/src/routes/admin.rs +++ b/crates/iris-api/src/routes/admin.rs @@ -526,7 +526,7 @@ pub(crate) async fn delete_user( if !iris_db::users::delete(state.db(), user_id, admin.0.id).await? { return Err(ApiError::NotFound); } - state.session_cuts().forget(user_id); + state.session_cuts().forget(); super::audit( &state, admin.0.id, @@ -573,7 +573,7 @@ pub(crate) async fn reset_user_password( let passkeys = iris_db::users::set_password(state.db(), user_id, &hash) .await? .ok_or(ApiError::NotFound)?; - state.session_cuts().forget(user_id); + state.session_cuts().forget(); super::audit( &state, admin.0.id, diff --git a/crates/iris-api/src/routes/auth.rs b/crates/iris-api/src/routes/auth.rs index d6a5d4ad..6e76b488 100644 --- a/crates/iris-api/src/routes/auth.rs +++ b/crates/iris-api/src/routes/auth.rs @@ -49,6 +49,7 @@ pub fn session_router() -> Router { Router::new() .route("/refresh", post(refresh)) .route("/logout", post(logout)) + .route("/switch-profile", post(crate::routes::profiles::switch)) } #[derive(Debug, Deserialize, ToSchema)] @@ -64,6 +65,8 @@ pub struct UserResponse { pub email: String, pub display_name: String, pub is_admin: bool, + /// The account this profile hangs from; absent on the account itself. + pub account_id: Option, } impl From for UserResponse { @@ -73,6 +76,7 @@ impl From for UserResponse { email: user.email, display_name: user.display_name, is_admin: user.is_admin, + account_id: user.account_id.map(Uuid::from), } } } diff --git a/crates/iris-api/src/routes/me.rs b/crates/iris-api/src/routes/me.rs index 512bd831..469cabc4 100644 --- a/crates/iris-api/src/routes/me.rs +++ b/crates/iris-api/src/routes/me.rs @@ -17,6 +17,7 @@ use crate::state::AppState; pub fn router() -> Router { Router::new() .route("/", get(me)) + .nest("/profiles", crate::routes::profiles::router()) .route("/continue-watching", get(continue_watching)) .route( "/continue-watching/dismiss", @@ -65,6 +66,7 @@ pub(crate) async fn change_password( user: AuthUser, Json(body): Json, ) -> ApiResult<(axum_extra::extract::CookieJar, axum::http::StatusCode)> { + crate::routes::profiles::the_account(&state, &user).await?; crate::passwords::check_policy(&body.new_password)?; let current = iris_db::users::get_password_hash(state.db(), user.id) .await? @@ -83,7 +85,7 @@ pub(crate) async fn change_password( let passkeys = iris_db::users::set_password(state.db(), user.id, &new_hash) .await? .ok_or(ApiError::Unauthorized)?; - state.session_cuts().forget(user.id); + state.session_cuts().forget(); let id = Uuid::from(user.id).to_string(); super::audit( &state, diff --git a/crates/iris-api/src/routes/mod.rs b/crates/iris-api/src/routes/mod.rs index 12fd2dac..f5340f9f 100644 --- a/crates/iris-api/src/routes/mod.rs +++ b/crates/iris-api/src/routes/mod.rs @@ -14,6 +14,7 @@ pub mod moods; pub mod passkeys; pub mod playback_preferences; pub mod preferences; +pub mod profiles; pub mod providers; pub mod release_note; pub mod search; diff --git a/crates/iris-api/src/routes/passkeys.rs b/crates/iris-api/src/routes/passkeys.rs index 3603ebe5..9bb35289 100644 --- a/crates/iris-api/src/routes/passkeys.rs +++ b/crates/iris-api/src/routes/passkeys.rs @@ -172,6 +172,7 @@ pub(crate) async fn register_start( ClientIp(ip): ClientIp, user: AuthUser, ) -> ApiResult> { + crate::routes::profiles::the_account(&state, &user).await?; let me = iris_db::users::find_by_id(state.db(), user.id) .await? .ok_or(ApiError::Unauthorized)?; diff --git a/crates/iris-api/src/routes/profiles.rs b/crates/iris-api/src/routes/profiles.rs new file mode 100644 index 00000000..9cbdbfd5 --- /dev/null +++ b/crates/iris-api/src/routes/profiles.rs @@ -0,0 +1,343 @@ +//! Profiles of one account ("Vincent", "Muriel"): each is a `users` row of its +//! own, so everything personal is already theirs. Changing profile is a new +//! session on the other row; the account's credentials stay on the account. + +use axum::extract::{Path, State}; +use axum::routing::{delete, get}; +use axum::{Json, Router}; +use axum_extra::extract::CookieJar; +use iris_core::ids::UserId; +use iris_core::user::User; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::routes::auth::UserResponse; +use crate::routes::extract::{AuthUser, REFRESH_COOKIE}; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/", get(list).post(create)) + .route("/{id}", delete(remove)) +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ProfileView { + pub id: Uuid, + pub name: String, + /// The account itself: it cannot be removed. + pub main: bool, + /// The profile this session is on. + pub current: bool, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateProfileRequest { + pub name: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SwitchProfileRequest { + pub profile_id: Uuid, +} + +async fn current(state: &AppState, user: &AuthUser) -> ApiResult { + iris_db::users::find_by_id(state.db(), user.id) + .await? + .ok_or(ApiError::Unauthorized) +} + +/// For what only the account does (its password, its passkeys). +pub(crate) async fn the_account(state: &AppState, user: &AuthUser) -> ApiResult<()> { + if current(state, user).await?.account_id.is_some() { + return Err(ApiError::Refused { + code: "account_only", + message: "Only the main profile can do this.".into(), + }); + } + Ok(()) +} + +async fn views(state: &AppState, me: &User) -> ApiResult> { + let profiles = iris_db::users::profiles_of(state.db(), me.account()).await?; + Ok(profiles + .into_iter() + .map(|p| ProfileView { + id: p.id.into(), + main: p.account_id.is_none(), + current: p.id == me.id, + name: p.display_name, + }) + .collect()) +} + +#[utoipa::path( + get, + path = "/api/me/profiles", + operation_id = "list_profiles", + responses((status = 200, description = "The account's profiles, itself first", body = [ProfileView])), + tag = "me", +)] +pub(crate) async fn list( + State(state): State, + user: AuthUser, +) -> ApiResult>> { + let me = current(&state, &user).await?; + Ok(Json(views(&state, &me).await?)) +} + +#[utoipa::path( + post, + path = "/api/me/profiles", + operation_id = "create_profile", + request_body = CreateProfileRequest, + responses( + (status = 200, description = "The account's profiles, the new one included", body = [ProfileView]), + (status = 400, description = "Empty or too long a name"), + (status = 409, description = "The account holds as many profiles as it can"), + ), + tag = "me", +)] +pub(crate) async fn create( + State(state): State, + user: AuthUser, + Json(body): Json, +) -> ApiResult>> { + let name = crate::routes::me::checked_display_name(&body.name)?; + let me = current(&state, &user).await?; + if iris_db::users::create_profile(state.db(), me.account(), name) + .await? + .is_none() + { + return Err(ApiError::Refused { + code: "profiles_full", + message: format!( + "An account holds {} profiles at most.", + iris_db::users::MAX_PROFILES + ), + }); + } + Ok(Json(views(&state, &me).await?)) +} + +#[utoipa::path( + delete, + path = "/api/me/profiles/{id}", + operation_id = "remove_profile", + params(("id" = Uuid, Path, description = "The profile to remove")), + responses( + (status = 204, description = "Profile removed; what it grabbed stays in the library"), + (status = 404, description = "No such profile on this account"), + (status = 409, description = "The main profile, or the one in use"), + ), + tag = "me", +)] +pub(crate) async fn remove( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult { + let me = current(&state, &user).await?; + let target = sibling(&state, &me, UserId::from(id)).await?; + if target.account_id.is_none() || target.id == me.id { + return Err(ApiError::Refused { + code: "profile_kept", + message: "The main profile and the one in use stay.".into(), + }); + } + iris_db::users::delete(state.db(), target.id, me.account()).await?; + state.session_cuts().forget(); + Ok(axum::http::StatusCode::NO_CONTENT) +} + +/// `id` when it is a profile of the caller's account. +async fn sibling(state: &AppState, me: &User, id: UserId) -> ApiResult { + iris_db::users::find_by_id(state.db(), id) + .await? + .filter(|u| u.account() == me.account()) + .ok_or(ApiError::NotFound) +} + +/// Under `/api/auth`, the only path the refresh cookie is sent to: the session +/// being left is ended, and a paired device keeps its label on the new one. +#[utoipa::path( + post, + path = "/api/auth/switch-profile", + request_body = SwitchProfileRequest, + responses( + (status = 200, description = "On the other profile; new session cookies set", body = UserResponse), + (status = 404, description = "No such profile on this account"), + ), + tag = "auth", +)] +pub(crate) async fn switch( + State(state): State, + jar: CookieJar, + user: AuthUser, + Json(body): Json, +) -> ApiResult<(CookieJar, Json)> { + let me = current(&state, &user).await?; + let target = sibling(&state, &me, UserId::from(body.profile_id)).await?; + + let leaving = jar + .get(REFRESH_COOKIE) + .and_then(|c| state.jwt().verify_refresh(c.value()).ok()) + .map(|claims| claims.jti); + let device = match leaving { + Some(jti) => iris_db::refresh_tokens::device_of(state.db(), jti).await?, + None => None, + }; + let (label, kind) = device.unwrap_or_default(); + + let new = + crate::routes::auth::new_session(&state, target.id, label.as_deref(), kind.as_deref()); + iris_db::refresh_tokens::insert_session(state.db(), &new).await?; + if let Some(jti) = leaving { + iris_db::refresh_tokens::revoke_for_user(state.db(), me.id, jti).await?; + } + let jar = crate::routes::auth::session_cookies( + &state, + &jar, + target.id, + target.is_admin, + &crate::routes::auth::stored(&new), + )?; + Ok((jar, Json(target.into()))) +} + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + use serde_json::json; + + use crate::routes::auth::tests::{PASSWORD, app_with_member, call, login}; + + #[tokio::test] + async fn a_profile_is_its_own_person_on_the_same_account() { + let (state, app, account, email) = app_with_member().await; + let signed_in = login(&app, &email, PASSWORD).await; + let access = signed_in.cookie("iris_access").unwrap(); + let refresh = signed_in.cookie("iris_refresh").unwrap(); + let both = format!("{access}; {refresh}"); + + let made = call( + &app, + "POST", + "/api/me/profiles", + None, + Some(&both), + Some(json!({ "name": "Muriel" })), + ) + .await; + assert_eq!(made.status, StatusCode::OK); + let muriel = made.json[1]["id"].as_str().unwrap().to_owned(); + assert_eq!(made.json[0]["main"], true); + assert_eq!(made.json[1]["current"], false); + + let switched = call( + &app, + "POST", + "/api/auth/switch-profile", + None, + Some(&both), + Some(json!({ "profile_id": muriel })), + ) + .await; + assert_eq!(switched.status, StatusCode::OK); + assert_eq!(switched.json["display_name"], "Muriel"); + assert_eq!(switched.json["is_admin"], false); + assert_eq!( + call( + &app, + "POST", + "/api/auth/refresh", + None, + Some(&refresh), + None + ) + .await + .status, + StatusCode::UNAUTHORIZED, + "the session left is over" + ); + + let as_muriel = switched.cookie("iris_access").unwrap(); + let on_muriel = call( + &app, + "GET", + "/api/me/profiles", + None, + Some(&as_muriel), + None, + ) + .await; + assert_eq!(on_muriel.json[1]["current"], true); + assert_eq!( + call( + &app, + "POST", + "/api/me/password", + None, + Some(&as_muriel), + Some(json!({ "old_password": "x", "new_password": "a brand new secret" })), + ) + .await + .status, + StatusCode::CONFLICT, + "the password is the account's" + ); + + let back = state.jwt().issue_access(account, false).unwrap(); + let path = format!("/api/me/profiles/{muriel}"); + assert_eq!( + call(&app, "DELETE", &path, None, Some(&as_muriel), None) + .await + .status, + StatusCode::CONFLICT, + "not the one in use" + ); + assert_eq!( + call(&app, "DELETE", &path, Some(&back), None, None) + .await + .status, + StatusCode::NO_CONTENT + ); + assert_eq!( + call(&app, "GET", "/api/me", None, Some(&as_muriel), None) + .await + .status, + StatusCode::UNAUTHORIZED, + "a removed profile's session is over" + ); + } + + #[tokio::test] + async fn a_stranger_is_not_a_profile() { + let (state, app, _, email) = app_with_member().await; + let other = iris_db::test_support::make_user(state.db()).await; + let access = login(&app, &email, PASSWORD) + .await + .cookie("iris_access") + .unwrap(); + let id = uuid::Uuid::from(other); + let tried = call( + &app, + "POST", + "/api/auth/switch-profile", + None, + Some(&access), + Some(json!({ "profile_id": id })), + ) + .await; + assert_eq!(tried.status, StatusCode::NOT_FOUND); + let path = format!("/api/me/profiles/{id}"); + assert_eq!( + call(&app, "DELETE", &path, None, Some(&access), None) + .await + .status, + StatusCode::NOT_FOUND + ); + } +} diff --git a/crates/iris-api/src/session_cut.rs b/crates/iris-api/src/session_cut.rs index bed6bbcf..8b5b7aea 100644 --- a/crates/iris-api/src/session_cut.rs +++ b/crates/iris-api/src/session_cut.rs @@ -55,11 +55,12 @@ impl SessionCuts { Ok(()) } - /// After a committed write to the user's row (cut stamped, account - /// deleted): the next request reads it again. - pub fn forget(&self, user: UserId) { + /// After a committed write to users' rows (cut stamped, account deleted, + /// and an account's profiles move with it): the next requests read them + /// again. + pub fn forget(&self) { let mut cuts = self.inner.write().unwrap_or_else(PoisonError::into_inner); - cuts.by_user.remove(&user); + cuts.by_user.clear(); cuts.generation += 1; } } @@ -80,7 +81,7 @@ mod tests { iris_db::users::set_password(&pool, user, "h") .await .unwrap(); - cuts.forget(user); + cuts.forget(); assert!(matches!( cuts.check(&pool, user, before).await, Err(ApiError::Unauthorized) diff --git a/crates/iris-core/src/user.rs b/crates/iris-core/src/user.rs index 172020cd..6f56fae4 100644 --- a/crates/iris-core/src/user.rs +++ b/crates/iris-core/src/user.rs @@ -13,6 +13,16 @@ pub struct User { pub display_name: String, pub is_admin: bool, pub created_at: DateTime, + /// The account this profile hangs from; `None` for the account itself. + #[serde(default)] + pub account_id: Option, +} + +impl User { + /// The account behind this user, itself when it is the account. + pub fn account(&self) -> UserId { + self.account_id.unwrap_or(self.id) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/iris-db/src/refresh_tokens.rs b/crates/iris-db/src/refresh_tokens.rs index f1b3f9b9..3efec146 100644 --- a/crates/iris-db/src/refresh_tokens.rs +++ b/crates/iris-db/src/refresh_tokens.rs @@ -126,7 +126,9 @@ pub async fn list_devices_for_user( sqlx::query_as::<_, RefreshToken>( "SELECT jti, user_id, issued_at, expires_at, revoked_at, device_label, device_kind \ FROM refresh_tokens \ - WHERE user_id = ?1 AND revoked_at IS NULL AND device_kind IS NOT NULL \ + WHERE user_id IN (SELECT id FROM users WHERE COALESCE(account_id, id) = \ + (SELECT COALESCE(account_id, id) FROM users WHERE id = ?1)) \ + AND revoked_at IS NULL AND device_kind IS NOT NULL \ AND expires_at > ?2 \ ORDER BY issued_at DESC", ) @@ -136,8 +138,20 @@ pub async fn list_devices_for_user( .await } +/// A session's device label and kind, to carry over when the session changes +/// profile. +pub async fn device_of( + pool: &SqlitePool, + jti: Uuid, +) -> Result, Option)>, sqlx::Error> { + sqlx::query_as("SELECT device_label, device_kind FROM refresh_tokens WHERE jti = ?1") + .bind(jti) + .fetch_optional(pool) + .await +} + /// End the session `jti` belongs to, every token of its family, when it is -/// one of `user_id`'s. `false`: no such session for this user. +/// held on `user_id`'s account, whichever profile. `false`: no such session. pub async fn revoke_for_user( pool: &SqlitePool, user_id: UserId, @@ -146,9 +160,11 @@ pub async fn revoke_for_user( let user: Uuid = user_id.into(); let res = sqlx::query( "UPDATE refresh_tokens SET revoked_at = COALESCE(revoked_at, ?1), rotated_at = NULL \ - WHERE user_id = ?3 AND family_id = \ + WHERE family_id = \ (SELECT family_id FROM refresh_tokens \ - WHERE jti = ?2 AND user_id = ?3 AND revoked_at IS NULL)", + WHERE jti = ?2 AND revoked_at IS NULL AND user_id IN \ + (SELECT id FROM users WHERE COALESCE(account_id, id) = \ + (SELECT COALESCE(account_id, id) FROM users WHERE id = ?3)))", ) .bind(Utc::now()) .bind(jti) diff --git a/crates/iris-db/src/users.rs b/crates/iris-db/src/users.rs index bdafd915..4d8390ac 100644 --- a/crates/iris-db/src/users.rs +++ b/crates/iris-db/src/users.rs @@ -12,6 +12,7 @@ struct UserRow { display_name: String, is_admin: bool, created_at: DateTime, + account_id: Option, } impl UserRow { @@ -23,6 +24,7 @@ impl UserRow { display_name: self.display_name, is_admin: self.is_admin, created_at: self.created_at, + account_id: self.account_id.map(UserId::from), }, self.password_hash, ) @@ -78,9 +80,56 @@ where display_name, is_admin: new.is_admin, created_at: now, + account_id: None, }) } +/// How many profiles an account holds, itself included. +pub const MAX_PROFILES: usize = 6; + +/// A new profile under `account`, `None` once it holds [`MAX_PROFILES`]. Its +/// email only fills the unique column and its hash verifies nothing: a profile +/// is entered from its account, never signed in to. +pub async fn create_profile( + pool: &SqlitePool, + account: UserId, + name: &str, +) -> Result, sqlx::Error> { + let owner: Uuid = account.into(); + let id = Uuid::new_v4(); + let now = Utc::now(); + let email = format!("{id}@profile.invalid"); + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + let (held,): (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM users WHERE id = ?1 OR account_id = ?1") + .bind(owner) + .fetch_one(&mut *tx) + .await?; + if usize::try_from(held).unwrap_or(usize::MAX) >= MAX_PROFILES { + return Ok(None); + } + sqlx::query( + "INSERT INTO users (id, email, password_hash, display_name, is_admin, created_at, account_id) \ + VALUES (?1, ?2, '!', ?3, 0, ?4, ?5)", + ) + .bind(id) + .bind(&email) + .bind(name) + .bind(now) + .bind(owner) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some(User { + id: UserId::from(id), + email, + display_name: name.to_owned(), + is_admin: false, + created_at: now, + account_id: Some(account), + })) +} + /// Column list for `SELECT`ing a [`UserRow`]. A macro rather than a /// `const &str` so it expands to a string literal usable inside /// `concat!`, keeping every read query a compile-time `&'static str` — @@ -90,10 +139,25 @@ where /// construction. macro_rules! user_columns { () => { - "id, email, password_hash, display_name, is_admin, created_at" + "id, email, password_hash, display_name, is_admin, created_at, account_id" }; } +/// The account's profiles, itself first, then by age. +pub async fn profiles_of(pool: &SqlitePool, account: UserId) -> Result, sqlx::Error> { + let owner: Uuid = account.into(); + let rows: Vec = sqlx::query_as(concat!( + "SELECT ", + user_columns!(), + " FROM users WHERE id = ?1 OR account_id = ?1 \ + ORDER BY account_id IS NOT NULL, created_at ASC" + )) + .bind(owner) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|r| r.into_domain().0).collect()) +} + pub async fn find_by_email<'e, E>( executor: E, email: &str, @@ -104,7 +168,7 @@ where let row: Option = sqlx::query_as(concat!( "SELECT ", user_columns!(), - " FROM users WHERE email = ?1" + " FROM users WHERE email = ?1 AND account_id IS NULL" )) .bind(email) .fetch_optional(executor) @@ -126,7 +190,7 @@ pub async fn find_by_id(pool: &SqlitePool, id: UserId) -> Result, s } pub async fn count(pool: &SqlitePool) -> Result { - let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") + let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users WHERE account_id IS NULL") .fetch_one(pool) .await?; Ok(n) @@ -136,7 +200,7 @@ pub async fn list(pool: &SqlitePool) -> Result, sqlx::Error> { let rows: Vec = sqlx::query_as(concat!( "SELECT ", user_columns!(), - " FROM users ORDER BY created_at ASC" + " FROM users WHERE account_id IS NULL ORDER BY created_at ASC" )) .fetch_all(pool) .await?; @@ -159,7 +223,8 @@ pub async fn get_password_hash( /// under the old one, in one transaction: every session (a just-rotated /// one loses its grace window too, and `sessions_valid_after` kills the /// access tokens already out) and every passkey, since a stolen session -/// could have registered its own. +/// could have registered its own. The account's profiles go through the same +/// door, so their sessions end with it. /// /// Returns `None` for an unknown user, else how many passkeys went. pub async fn set_password( @@ -180,9 +245,14 @@ pub async fn set_password( if res.rows_affected() != 1 { return Ok(None); } + sqlx::query("UPDATE users SET sessions_valid_after = ?1 WHERE account_id = ?2") + .bind(now) + .bind(uuid) + .execute(&mut *tx) + .await?; sqlx::query( "UPDATE refresh_tokens SET revoked_at = COALESCE(revoked_at, ?1), rotated_at = NULL \ - WHERE user_id = ?2", + WHERE user_id IN (SELECT id FROM users WHERE id = ?2 OR account_id = ?2)", ) .bind(now) .bind(uuid) @@ -202,7 +272,8 @@ pub async fn set_password( /// and the library is household-shared, so letting the cascade run would /// silently evict every release the deleted user ever grabbed. Everything /// personal (sessions, watch progress, follows, preferences, dismissals) -/// cascades away as intended. +/// cascades away as intended. An account's profiles leave with it, and their +/// grabs are inherited the same way. pub async fn delete( pool: &SqlitePool, id: UserId, @@ -211,11 +282,14 @@ pub async fn delete( let target: Uuid = id.into(); let heir: Uuid = reassign_torrents_to.into(); let mut tx = pool.begin().await?; - sqlx::query("UPDATE torrents SET added_by = ?1 WHERE added_by = ?2") - .bind(heir) - .bind(target) - .execute(&mut *tx) - .await?; + sqlx::query( + "UPDATE torrents SET added_by = ?1 \ + WHERE added_by IN (SELECT id FROM users WHERE id = ?2 OR account_id = ?2)", + ) + .bind(heir) + .bind(target) + .execute(&mut *tx) + .await?; let res = sqlx::query("DELETE FROM users WHERE id = ?1") .bind(target) .execute(&mut *tx) @@ -393,6 +467,95 @@ mod tests { assert_eq!(rows[0].actor_display_name, "deleted user"); } + #[tokio::test] + async fn a_profile_cannot_be_signed_in_to_and_stays_out_of_the_accounts() { + let pool = migrated_pool().await; + let account = make_user(&pool, "shared@t.test").await; + let profile = create_profile(&pool, account, "Muriel") + .await + .unwrap() + .unwrap(); + assert_eq!(profile.account(), account); + + assert!( + find_by_email(&pool, &profile.email) + .await + .unwrap() + .is_none() + ); + assert_eq!(count(&pool).await.unwrap(), 1); + assert_eq!(list(&pool).await.unwrap().len(), 1); + let names: Vec<_> = profiles_of(&pool, account) + .await + .unwrap() + .into_iter() + .map(|u| u.display_name) + .collect(); + assert_eq!(names, ["shared", "Muriel"]); + } + + #[tokio::test] + async fn an_account_holds_a_bounded_number_of_profiles() { + let pool = migrated_pool().await; + let account = make_user(&pool, "shared@t.test").await; + for _ in 1..MAX_PROFILES { + assert!(create_profile(&pool, account, "p").await.unwrap().is_some()); + } + assert!(create_profile(&pool, account, "p").await.unwrap().is_none()); + } + + #[tokio::test] + async fn a_password_change_ends_the_sessions_of_every_profile() { + let pool = migrated_pool().await; + let account = make_user(&pool, "shared@t.test").await; + let profile = create_profile(&pool, account, "Muriel") + .await + .unwrap() + .unwrap(); + let jti = Uuid::new_v4(); + crate::refresh_tokens::insert( + &pool, + jti, + profile.id, + Utc::now() + chrono::Duration::days(1), + ) + .await + .unwrap(); + + set_password(&pool, account, "new-hash").await.unwrap(); + + assert!(!crate::refresh_tokens::is_active(&pool, jti).await.unwrap()); + assert!( + sessions_valid_after(&pool, profile.id) + .await + .unwrap() + .flatten() + .is_some() + ); + } + + #[tokio::test] + async fn deleting_an_account_keeps_the_grabs_of_its_profiles() { + let pool = migrated_pool().await; + let admin = make_user(&pool, "admin@t.test").await; + let account = make_user(&pool, "shared@t.test").await; + let profile = create_profile(&pool, account, "Muriel") + .await + .unwrap() + .unwrap(); + insert_torrent(&pool, "bbbb", profile.id).await; + + assert!(delete(&pool, account, admin).await.unwrap()); + + assert!(find_by_id(&pool, profile.id).await.unwrap().is_none()); + let (owner,): (Uuid,) = + sqlx::query_as("SELECT added_by FROM torrents WHERE infohash = 'bbbb'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(UserId::from(owner), admin); + } + #[tokio::test] async fn delete_missing_user_is_false() { let pool = migrated_pool().await; diff --git a/migrations/0053_profiles.sql b/migrations/0053_profiles.sql new file mode 100644 index 00000000..3c3aeac1 --- /dev/null +++ b/migrations/0053_profiles.sql @@ -0,0 +1,8 @@ +-- A profile is a users row of its own, so everything personal (progress, +-- follows, preferences) stays keyed on users(id). `account_id` names the +-- account it hangs from; NULL is the account itself. Such a row has no way in +-- of its own: sign-in looks up accounts only. ADD COLUMN, never a users +-- rebuild: DROP TABLE would fire every ON DELETE CASCADE pointing here. +ALTER TABLE users ADD COLUMN account_id BLOB REFERENCES users(id) ON DELETE CASCADE; + +CREATE INDEX users_account_idx ON users(account_id) WHERE account_id IS NOT NULL; diff --git a/web/openapi.json b/web/openapi.json index f17eadcb..9317f7bf 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -1058,6 +1058,40 @@ } } }, + "/api/auth/switch-profile": { + "post": { + "tags": [ + "auth" + ], + "summary": "Under `/api/auth`, the only path the refresh cookie is sent to: the session\nbeing left is ended, and a paired device keeps its label on the new one.", + "operationId": "switch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SwitchProfileRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "On the other profile; new session cookies set", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserResponse" + } + } + } + }, + "404": { + "description": "No such profile on this account" + } + } + } + }, "/api/discover/featured": { "get": { "tags": [ @@ -2726,6 +2760,97 @@ } } }, + "/api/me/profiles": { + "get": { + "tags": [ + "me" + ], + "operationId": "list_profiles", + "responses": { + "200": { + "description": "The account's profiles, itself first", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfileView" + } + } + } + } + } + } + }, + "post": { + "tags": [ + "me" + ], + "operationId": "create_profile", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProfileRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The account's profiles, the new one included", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProfileView" + } + } + } + } + }, + "400": { + "description": "Empty or too long a name" + }, + "409": { + "description": "The account holds as many profiles as it can" + } + } + } + }, + "/api/me/profiles/{id}": { + "delete": { + "tags": [ + "me" + ], + "operationId": "remove_profile", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The profile to remove", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Profile removed; what it grabbed stays in the library" + }, + "404": { + "description": "No such profile on this account" + }, + "409": { + "description": "The main profile, or the one in use" + } + } + } + }, "/api/me/recent-searches": { "get": { "tags": [ @@ -5166,6 +5291,17 @@ } } }, + "CreateProfileRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, "CreatedInvitation": { "type": "object", "required": [ @@ -7700,6 +7836,32 @@ } } }, + "ProfileView": { + "type": "object", + "required": [ + "id", + "name", + "main", + "current" + ], + "properties": { + "current": { + "type": "boolean", + "description": "The profile this session is on." + }, + "id": { + "type": "string", + "format": "uuid" + }, + "main": { + "type": "boolean", + "description": "The account itself: it cannot be removed." + }, + "name": { + "type": "string" + } + } + }, "ProgressUpdate": { "type": "object", "required": [ @@ -8830,6 +8992,18 @@ } } }, + "SwitchProfileRequest": { + "type": "object", + "required": [ + "profile_id" + ], + "properties": { + "profile_id": { + "type": "string", + "format": "uuid" + } + } + }, "TitleCard": { "type": "object", "description": "One title for the Titles view: what the query could mean on TMDB, and\nwhether the library already holds it.", @@ -9789,6 +9963,14 @@ "is_admin" ], "properties": { + "account_id": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "The account this profile hangs from; absent on the account itself." + }, "display_name": { "type": "string" }, From 890f7f8c85c54724beb479a974caea890e86bb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9onard?= Date: Sat, 31 Oct 2026 08:57:54 +0100 Subject: [PATCH 04/11] feat(web): profiles: who's watching once per tab when an account has several, a Profiles section in Account (add, switch, remove after asking), Switch profile in the header's panel; a profile shows no email, password or passkeys --- packages/iris-api/src/client.ts | 8 ++ web/src/lib/account/AccountPage.svelte | 8 +- .../lib/account/AccountPage.svelte.test.ts | 36 ++++++ web/src/lib/account/Identity.svelte | 7 +- web/src/lib/account/Profiles.svelte | 117 ++++++++++++++++++ web/src/lib/components/Header.svelte | 17 +++ web/src/lib/profiles/ProfileGate.svelte | 58 +++++++++ .../lib/profiles/ProfileGate.svelte.test.ts | 56 +++++++++ web/src/lib/profiles/chosen.svelte.ts | 28 +++++ web/src/lib/profiles/enter.ts | 12 ++ web/src/lib/queries.ts | 3 + web/src/lib/session.svelte.ts | 2 + web/src/lib/storage.ts | 2 + web/src/routes/+layout.svelte | 5 +- 14 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/account/Profiles.svelte create mode 100644 web/src/lib/profiles/ProfileGate.svelte create mode 100644 web/src/lib/profiles/ProfileGate.svelte.test.ts create mode 100644 web/src/lib/profiles/chosen.svelte.ts create mode 100644 web/src/lib/profiles/enter.ts diff --git a/packages/iris-api/src/client.ts b/packages/iris-api/src/client.ts index 2f395303..7ca33646 100644 --- a/packages/iris-api/src/client.ts +++ b/packages/iris-api/src/client.ts @@ -10,6 +10,7 @@ declare const __IRIS_WEB_VERSION__: string; * hand-written until its endpoints are annotated in later waves. */ export type User = components['schemas']['UserResponse']; export type ReleaseNote = components['schemas']['ReleaseNote']; +export type ProfileView = components['schemas']['ProfileView']; /** The longest name the server accepts (display names, passkey names), in bytes. */ export const MAX_NAME = 64; @@ -483,6 +484,13 @@ export type ForYouResponse = components['schemas']['ForYou']; export type MoodTile = components['schemas']['MoodTile']; export type MoodBoard = components['schemas']['MoodBoard']; export type MoodResults = components['schemas']['MoodResults']; +export const profiles = { + list: () => api.get('/me/profiles'), + create: (name: string) => api.post('/me/profiles', { name }), + remove: (id: string) => api.delete(`/me/profiles/${seg(id)}`), + switchTo: (profile_id: string) => api.post('/auth/switch-profile', { profile_id }) +}; + export const releaseNote = () => api.get('/release-note'); export const me = { diff --git a/web/src/lib/account/AccountPage.svelte b/web/src/lib/account/AccountPage.svelte index ea6d9f15..60a7d3f4 100644 --- a/web/src/lib/account/AccountPage.svelte +++ b/web/src/lib/account/AccountPage.svelte @@ -15,6 +15,7 @@ import PasskeyList from './PasskeyList.svelte'; import PasswordForm from './PasswordForm.svelte'; import Playback from './Playback.svelte'; + import Profiles from './Profiles.svelte'; import Recommendations from './Recommendations.svelte'; const THEMES: { value: Theme; label: string }[] = [ @@ -35,8 +36,11 @@
- - + + {#if !session.user?.account_id} + + + {/if} diff --git a/web/src/lib/account/AccountPage.svelte.test.ts b/web/src/lib/account/AccountPage.svelte.test.ts index 39069c99..2df62a65 100644 --- a/web/src/lib/account/AccountPage.svelte.test.ts +++ b/web/src/lib/account/AccountPage.svelte.test.ts @@ -33,6 +33,7 @@ function site(more: Record = {}) { return stubApi({ 'GET /me/passkeys': [iphone, mac], 'GET /me/devices': [], + 'GET /me/profiles': [{ id: 'u1', name: 'Léonard', main: true, current: true }], 'GET /me/playback-preferences': { audio_language: 'fre', subtitle_language: 'off' }, 'GET /me/preferences': { languages: ['french'], genres: [], include_anime: false, onboarding_completed: true }, 'GET /genres': { genres: [{ id: 18, name: 'Drama' }] }, @@ -43,6 +44,41 @@ function site(more: Record = {}) { const region = (name: string) => page.getByRole('region', { name }); const keyRows = () => region('Passkeys').getByRole('listitem'); +describe('profiles', () => { + beforeEach(() => { + session.signedIn(leonard); + route.url = new URL('http://iris.test/account'); + }); + + it('adds one, says who is in use, and asks before removing', async () => { + const muriel = { id: 'u9', name: 'Muriel', main: false, current: false }; + const api = site({ + 'GET /me/profiles': [{ id: 'u1', name: 'Léonard', main: true, current: true }, muriel], + 'POST /me/profiles': [{ id: 'u1', name: 'Léonard', main: true, current: true }, muriel], + 'DELETE /me/profiles/u9': noContent + }); + render(AccountPage); + const section = region('Profiles'); + await expect.element(section.getByText('Main profile · In use')).toBeVisible(); + await section.getByLabelText('New profile').fill('Muriel'); + await section.getByRole('button', { name: 'Add' }).click(); + await expect.poll(() => api.sent('POST', '/me/profiles')[0]?.body).toEqual({ name: 'Muriel' }); + await section.getByRole('button', { name: 'Remove the profile Muriel' }).click(); + await page.getByRole('button', { name: 'Remove the profile', exact: true }).click(); + await expect.poll(() => api.sent('DELETE', '/me/profiles/u9')).toHaveLength(1); + }); + + it('a profile has no password or passkeys of its own', async () => { + site(); + session.signedIn({ ...leonard, id: 'u9', account_id: 'u1' }); + render(AccountPage); + await expect.element(region('Profiles')).toBeVisible(); + await expect.element(region('Passkeys')).not.toBeInTheDocument(); + await expect.element(region('Password')).not.toBeInTheDocument(); + await expect.element(page.getByText('leonard.k@example.com')).not.toBeInTheDocument(); + }); +}); + describe('account page', () => { beforeEach(() => { session.signedIn(leonard); diff --git a/web/src/lib/account/Identity.svelte b/web/src/lib/account/Identity.svelte index 095be719..f51a83b6 100644 --- a/web/src/lib/account/Identity.svelte +++ b/web/src/lib/account/Identity.svelte @@ -12,7 +12,8 @@ const g = new Gesture(); const user = $derived(session.user); - const fromEmail = $derived(user?.email.split('@')[0]?.split('.')[0] ?? ''); + const main = $derived(!user?.account_id); + const fromEmail = $derived(main ? (user?.email.split('@')[0]?.split('.')[0] ?? '') : ''); /** Saved, then read back: the header and every « added by » show the server's name. */ async function rename(name: string) { @@ -31,14 +32,14 @@ {#if user} `Display name changed to ${name}.`} /> -

Shown to the household, as in “added by”. Your email stays private.

+

Shown to the household, as in “added by”.{main ? ' Your email stays private.' : ''}

{#if fromEmail && fromEmail !== user.display_name}
{/if}
- + {#if main}{/if}
diff --git a/web/src/lib/account/Profiles.svelte b/web/src/lib/account/Profiles.svelte new file mode 100644 index 00000000..9ad74aac --- /dev/null +++ b/web/src/lib/account/Profiles.svelte @@ -0,0 +1,117 @@ + + + +

For people sharing this account: each profile keeps its own history, watchlist and preferences.

+ +
    + {#each list.data ?? [] as p (p.id)} + + {p.name} + {#snippet end()} + {#if !p.current} + + {/if} + {#if !p.main && !p.current} + remove(p)} + /> + {/if} + {/snippet} + + {/each} +
+
+
+
+ + +

{g.error}

+
+ +
+
+ + diff --git a/web/src/lib/components/Header.svelte b/web/src/lib/components/Header.svelte index a2d5f90d..d51bbce2 100644 --- a/web/src/lib/components/Header.svelte +++ b/web/src/lib/components/Header.svelte @@ -3,9 +3,13 @@ // destinations move to a bottom bar (at most five). Session, display settings and the // less frequent places (history, admin) live in one panel, from the person's name. import { Popover } from 'bits-ui'; + import { createQuery } from '@tanstack/svelte-query'; import { page } from '$app/state'; import { session } from '#lib/session.svelte.ts'; import { pending } from '#lib/gesture.svelte.ts'; + import { read } from '#lib/queries.ts'; + import { queryClient } from '#lib/query.ts'; + import { chosen } from '#lib/profiles/chosen.svelte.ts'; import { ui, type Theme } from '#lib/ui.svelte.ts'; import Brand from './Brand.svelte'; import Icon, { type IconName } from './Icon.svelte'; @@ -25,6 +29,10 @@ ]; const name = $derived(session.user?.display_name ?? ''); let open = $state(false); + const profiles = createQuery( + () => read.profiles(), + () => queryClient + ); {#snippet nav(cls: string)} @@ -52,6 +60,15 @@

{name}

ui.setTheme(t)} /> + {#if (profiles.data?.length ?? 0) > 1} + + {/if}
(open = false)}>History {#if session.user?.is_admin} diff --git a/web/src/lib/profiles/ProfileGate.svelte b/web/src/lib/profiles/ProfileGate.svelte new file mode 100644 index 00000000..ee275561 --- /dev/null +++ b/web/src/lib/profiles/ProfileGate.svelte @@ -0,0 +1,58 @@ + + +{#if asking} +
+

Who’s watching?

+
    + {#each list.data ?? [] as p (p.id)} +
  • + +
  • + {/each} +
+ {#if g.error}{/if} +
+{:else if !waiting} + {@render children()} +{/if} + + diff --git a/web/src/lib/profiles/ProfileGate.svelte.test.ts b/web/src/lib/profiles/ProfileGate.svelte.test.ts new file mode 100644 index 00000000..601aae0c --- /dev/null +++ b/web/src/lib/profiles/ProfileGate.svelte.test.ts @@ -0,0 +1,56 @@ +import { createRawSnippet } from 'svelte'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import { queryClient } from '#lib/query.ts'; +import { session } from '#lib/session.svelte.ts'; +import { stubApi } from '#lib/test/api.ts'; +import { chosen } from './chosen.svelte.ts'; +import ProfileGate from './ProfileGate.svelte'; + +const user = { id: 'u1', email: 'a@example.com', display_name: 'Vincent', is_admin: false }; +const children = createRawSnippet(() => ({ render: () => '

The app

' })); +const vincent = { id: 'u1', name: 'Vincent', main: true, current: true }; +const muriel = { id: 'u2', name: 'Muriel', main: false, current: false }; + +describe('who is watching', () => { + beforeEach(() => { + queryClient.clear(); + chosen.set(false); + session.signedIn(user); + }); + + it('one profile goes straight in', async () => { + stubApi({ 'GET /me/profiles': [vincent] }); + render(ProfileGate, { children }); + await expect.element(page.getByText('The app')).toBeVisible(); + }); + + it('a failed read never locks anyone out', async () => { + stubApi({ 'GET /me/profiles': () => new Response(null, { status: 500 }) }); + render(ProfileGate, { children }); + await expect.element(page.getByText('The app')).toBeVisible(); + }); + + it('several ask once, and going onto another one changes the session', async () => { + const api = stubApi({ + 'GET /me/profiles': [vincent, muriel], + 'POST /auth/switch-profile': { ...user, id: 'u2', display_name: 'Muriel', account_id: 'u1' } + }); + render(ProfileGate, { children }); + await expect.element(page.getByRole('heading', { name: 'Who’s watching?' })).toBeVisible(); + await expect.element(page.getByText('The app')).not.toBeInTheDocument(); + await page.getByRole('button', { name: 'Muriel' }).click(); + await expect.element(page.getByText('The app')).toBeVisible(); + expect(api.sent('POST', '/auth/switch-profile')[0]?.body).toEqual({ profile_id: 'u2' }); + expect(session.user?.display_name).toBe('Muriel'); + }); + + it('staying on the current one sends nothing', async () => { + const api = stubApi({ 'GET /me/profiles': [vincent, muriel] }); + render(ProfileGate, { children }); + await page.getByRole('button', { name: 'Vincent' }).click(); + await expect.element(page.getByText('The app')).toBeVisible(); + expect(api.sent('POST', '/auth/switch-profile')).toHaveLength(0); + }); +}); diff --git a/web/src/lib/profiles/chosen.svelte.ts b/web/src/lib/profiles/chosen.svelte.ts new file mode 100644 index 00000000..1ab689eb --- /dev/null +++ b/web/src/lib/profiles/chosen.svelte.ts @@ -0,0 +1,28 @@ +// Whether this tab was asked who is watching: kept for the tab (a reload never asks again), and +// asked afresh by a new visit. sessionStorage can refuse: the choice then lasts the page. + +import { STORAGE } from '#lib/storage.ts'; + +function read(): boolean { + try { + return sessionStorage.getItem(STORAGE.profileChosen) === '1'; + } catch { + return false; + } +} + +class Chosen { + done = $state(read()); + + set(done: boolean) { + this.done = done; + try { + if (done) sessionStorage.setItem(STORAGE.profileChosen, '1'); + else sessionStorage.removeItem(STORAGE.profileChosen); + } catch { + // kept for the page only + } + } +} + +export const chosen = new Chosen(); diff --git a/web/src/lib/profiles/enter.ts b/web/src/lib/profiles/enter.ts new file mode 100644 index 00000000..b6aa949b --- /dev/null +++ b/web/src/lib/profiles/enter.ts @@ -0,0 +1,12 @@ +// Going onto a profile, the one way: the server opens its session, then the session view forgets +// what the last profile read (`accountChanged`) and every view starts over. + +import { profiles, type ProfileView } from '@iris/api/client'; +import { chosen } from './chosen.svelte.ts'; +import { session } from '#lib/session.svelte.ts'; + +export async function enter(profile: ProfileView) { + const user = profile.current ? undefined : await profiles.switchTo(profile.id); + chosen.set(true); + if (user) session.signedIn(user); +} diff --git a/web/src/lib/queries.ts b/web/src/lib/queries.ts index 217769ec..9aee4561 100644 --- a/web/src/lib/queries.ts +++ b/web/src/lib/queries.ts @@ -10,6 +10,7 @@ import { livetv, me as meApi, metadata, + profiles, progress, releaseNote, torrents, @@ -51,6 +52,7 @@ export const KEYS = { genres: ['genres'], languages: ['languages'], releaseNote: ['release-note'], + profiles: ['profiles'], playbackPrefsAll: ['playback-prefs'], playbackPrefs: (collectionId: string | null) => ['playback-prefs', collectionId] as const, tmdb: (id: number | null, kind: MediaKind | null) => ['tmdb', id, kind] as const, @@ -154,6 +156,7 @@ export const read = { preferences: () => ({ queryKey: KEYS.preferences, queryFn: meApi.preferences, staleTime: 5 * 60_000 }), genres: () => ({ queryKey: KEYS.genres, queryFn: discover.genres, staleTime: DAY }), languages: () => ({ queryKey: KEYS.languages, queryFn: discover.languages, staleTime: DAY }), + profiles: () => ({ queryKey: KEYS.profiles, queryFn: profiles.list }), releaseNote: () => ({ queryKey: KEYS.releaseNote, queryFn: releaseNote, staleTime: DAY }), /** The account's audio and subtitle choice, or a series' own when it has one. */ playbackPrefs: (collectionId: string | null) => ({ diff --git a/web/src/lib/session.svelte.ts b/web/src/lib/session.svelte.ts index b63d106e..fda51b3b 100644 --- a/web/src/lib/session.svelte.ts +++ b/web/src/lib/session.svelte.ts @@ -2,6 +2,7 @@ import { Session, type SessionState } from '@iris/api/session'; import { Gesture } from '#lib/gesture.svelte.ts'; +import { chosen } from '#lib/profiles/chosen.svelte.ts'; import { queryClient } from '#lib/query.ts'; import { rememberSearch } from '#lib/search/cache.ts'; @@ -22,6 +23,7 @@ class SessionView { this.#core.subscribe((s) => { // a household browser: the next person must never read, or save over, the last one's answers if (accountChanged(this.state, s)) forgetAccount(); + if (s.status === 'signed_out') chosen.set(false); this.state = s; }); } diff --git a/web/src/lib/storage.ts b/web/src/lib/storage.ts index 082c274b..0d803017 100644 --- a/web/src/lib/storage.ts +++ b/web/src/lib/storage.ts @@ -8,6 +8,8 @@ export const STORAGE = { adminView: 'iris-admin-view', /** The last version whose update sentence this browser has had. */ releaseSeen: 'iris-release-seen', + /** This tab was asked who is watching (sessionStorage). */ + profileChosen: 'iris-profile-chosen', /** The Live TV countries last picked, most recent first. */ liveCountries: 'iris-live-countries', /** Kept under the React app's names, so a browser keeps its volume and theater across the switch. */ diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 7876127e..1dd27c5c 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -13,6 +13,7 @@ import SignIn from '#lib/components/SignIn.svelte'; import Toasts from '#lib/components/Toasts.svelte'; import Outdated from '#lib/components/Outdated.svelte'; + import ProfileGate from '#lib/profiles/ProfileGate.svelte'; import { harmlessReload, outdated } from '#lib/update.svelte.ts'; let { children } = $props(); @@ -87,7 +88,9 @@ {:else} - {#key session.user?.id}{@render children()}{/key} + {#key session.user?.id} + {#if session.user}{@render children()}{:else}{@render children()}{/if} + {/key} {/if} From 4ac77337c467ade33754e2b62c9c24fdac2633da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9onard?= Date: Sat, 31 Oct 2026 09:03:30 +0100 Subject: [PATCH 05/11] feat(friends): friend requests between profiles, found by name, never by email; a declined asker is told nothing and cannot ask again; GET /me/friends/activity says what friends play now and watched or added lately, what they watch only once they share it, never a title they hid; home summary counts the requests waiting --- crates/iris-api/src/app.rs | 1 + crates/iris-api/src/openapi.rs | 20 + crates/iris-api/src/routes/friends.rs | 534 ++++++++++++++++++++++++++ crates/iris-api/src/routes/me.rs | 6 +- crates/iris-api/src/routes/mod.rs | 1 + crates/iris-db/src/collections.rs | 1 + crates/iris-db/src/friends.rs | 480 +++++++++++++++++++++++ crates/iris-db/src/lib.rs | 1 + migrations/0054_friends.sql | 33 ++ web/openapi.json | 469 ++++++++++++++++++++++ 10 files changed, 1545 insertions(+), 1 deletion(-) create mode 100644 crates/iris-api/src/routes/friends.rs create mode 100644 crates/iris-db/src/friends.rs create mode 100644 migrations/0054_friends.sql diff --git a/crates/iris-api/src/app.rs b/crates/iris-api/src/app.rs index 8b88e53e..4cdd1760 100644 --- a/crates/iris-api/src/app.rs +++ b/crates/iris-api/src/app.rs @@ -63,6 +63,7 @@ pub fn build_router(state: AppState) -> Router { .nest("/devices", routes::devices::me_router()) .nest("/passkeys", routes::passkeys::me_router()) .nest("/follows", routes::follows::router()) + .nest("/friends", routes::friends::router()) .nest("/preferences", routes::preferences::router()) .nest( "/playback-preferences", diff --git a/crates/iris-api/src/openapi.rs b/crates/iris-api/src/openapi.rs index e730c2c9..3e08b3e3 100644 --- a/crates/iris-api/src/openapi.rs +++ b/crates/iris-api/src/openapi.rs @@ -154,6 +154,16 @@ use utoipa::OpenApi; crate::routes::seleo::unshare, crate::routes::health::get, crate::routes::release_note::get, + crate::routes::friends::overview, + crate::routes::friends::search, + crate::routes::friends::ask, + crate::routes::friends::accept, + crate::routes::friends::decline, + crate::routes::friends::remove, + crate::routes::friends::set_sharing, + crate::routes::friends::hide, + crate::routes::friends::unhide, + crate::routes::friends::activity, crate::routes::profiles::list, crate::routes::profiles::create, crate::routes::profiles::remove, @@ -164,6 +174,15 @@ use utoipa::OpenApi; crate::routes::auth::LoginRequest, crate::routes::auth::UserResponse, crate::routes::release_note::ReleaseNote, + crate::routes::friends::PersonView, + crate::routes::friends::HiddenTitleView, + crate::routes::friends::FriendsOverview, + crate::routes::friends::SharingRequest, + crate::routes::friends::HideRequest, + crate::routes::friends::UnhideRequest, + crate::routes::friends::FriendAction, + crate::routes::friends::FriendEvent, + crate::routes::friends::FriendActivity, crate::routes::profiles::ProfileView, crate::routes::profiles::CreateProfileRequest, crate::routes::profiles::SwitchProfileRequest, @@ -233,6 +252,7 @@ use utoipa::OpenApi; (name = "providers", description = "Registered search providers and their capabilities."), (name = "metadata", description = "TMDB metadata lookup, typeahead search, SCENE-name resolution."), (name = "admin", description = "Admin-only: invitations, users, storage / GC, remux cache, presence, diagnostics, SéléoTV shares."), + (name = "friends", description = "Friends between profiles, and what they share."), (name = "health", description = "Unauthenticated liveness probe (excluded from the client-version gate)."), ), )] diff --git a/crates/iris-api/src/routes/friends.rs b/crates/iris-api/src/routes/friends.rs new file mode 100644 index 00000000..623fd26f --- /dev/null +++ b/crates/iris-api/src/routes/friends.rs @@ -0,0 +1,534 @@ +//! Friends: asking, answering, and the activity friends see. What a profile +//! adds is visible to its friends; what it watches only once it turned +//! sharing on, and never a title it hid. + +use std::collections::HashSet; + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::{delete, get, post, put}; +use axum::{Json, Router}; +use chrono::{DateTime, Utc}; +use iris_core::ids::UserId; +use iris_db::friends::{Event, Title}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::routes::extract::AuthUser; +use crate::state::AppState; + +/// Under `/me/friends`. +pub fn router() -> Router { + Router::new() + .route("/", get(overview)) + .route("/search", get(search)) + .route("/activity", get(activity)) + .route("/sharing", put(set_sharing)) + .route("/hidden", post(hide).delete(unhide)) + .route("/{id}", delete(remove).post(ask)) + .route("/{id}/accept", post(accept)) + .route("/{id}/decline", post(decline)) +} + +const RECENT: i64 = 30; +const FOUND: i64 = 20; + +#[derive(Debug, Serialize, ToSchema)] +pub struct PersonView { + pub id: Uuid, + pub name: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct HiddenTitleView { + pub collection_id: Option, + pub infohash: Option, + pub title: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct FriendsOverview { + pub friends: Vec, + /// Requests waiting for this profile's answer. + pub incoming: Vec, + /// Requests this profile sent, still unanswered as far as it can tell. + pub outgoing: Vec, + /// Whether friends see what this profile watches. + pub share_watching: bool, + pub hidden: Vec, +} + +#[utoipa::path( + get, + path = "/api/me/friends", + operation_id = "friends_overview", + responses((status = 200, description = "Friends, requests and what is shared", body = FriendsOverview)), + tag = "friends", +)] +pub(crate) async fn overview( + State(state): State, + user: AuthUser, +) -> ApiResult> { + let mut view = FriendsOverview { + friends: Vec::new(), + incoming: Vec::new(), + outgoing: Vec::new(), + share_watching: iris_db::friends::sharing(state.db(), user.id).await?, + hidden: iris_db::friends::hidden(state.db(), user.id) + .await? + .into_iter() + .map(|h| HiddenTitleView { + collection_id: h.collection_id, + infohash: h.infohash, + title: h.title, + }) + .collect(), + }; + for r in iris_db::friends::relations(state.db(), user.id).await? { + let list = match (r.accepted, r.incoming) { + (true, _) => &mut view.friends, + (false, true) => &mut view.incoming, + (false, false) => &mut view.outgoing, + }; + list.push(PersonView { + id: r.id, + name: r.name, + }); + } + Ok(Json(view)) +} + +#[derive(Debug, Deserialize, IntoParams)] +#[into_params(parameter_in = Query)] +pub(crate) struct SearchParams { + /// Part of a display name, two characters at least. + q: String, +} + +#[utoipa::path( + get, + path = "/api/me/friends/search", + operation_id = "friends_search", + params(SearchParams), + responses((status = 200, description = "Profiles to ask, by name; never an email", body = [PersonView])), + tag = "friends", +)] +pub(crate) async fn search( + State(state): State, + user: AuthUser, + Query(params): Query, +) -> ApiResult>> { + let q = params.q.trim(); + if q.chars().count() < 2 { + return Ok(Json(Vec::new())); + } + let found = iris_db::friends::search(state.db(), user.id, q, FOUND).await?; + Ok(Json( + found + .into_iter() + .map(|p| PersonView { + id: p.id, + name: p.name, + }) + .collect(), + )) +} + +#[utoipa::path( + post, + path = "/api/me/friends/{id}", + operation_id = "friends_ask", + params(("id" = Uuid, Path, description = "The profile to ask")), + responses( + (status = 204, description = "Asked; when they had asked first, you are friends"), + (status = 404, description = "No such profile"), + ), + tag = "friends", +)] +pub(crate) async fn ask( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult { + let other = UserId::from(id); + if other == user.id { + return Err(ApiError::BadRequest("that is you".into())); + } + if iris_db::users::find_by_id(state.db(), other) + .await? + .is_none() + { + return Err(ApiError::NotFound); + } + iris_db::friends::request(state.db(), user.id, other).await?; + Ok(StatusCode::NO_CONTENT) +} + +fn done(found: bool) -> ApiResult { + if found { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::NotFound) + } +} + +#[utoipa::path( + post, + path = "/api/me/friends/{id}/accept", + operation_id = "friends_accept", + params(("id" = Uuid, Path, description = "Who asked")), + responses( + (status = 204, description = "You are friends"), + (status = 404, description = "No such request"), + ), + tag = "friends", +)] +pub(crate) async fn accept( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult { + done(iris_db::friends::accept(state.db(), user.id, UserId::from(id)).await?) +} + +#[utoipa::path( + post, + path = "/api/me/friends/{id}/decline", + operation_id = "friends_decline", + params(("id" = Uuid, Path, description = "Who asked")), + responses( + (status = 204, description = "Declined; they are not told"), + (status = 404, description = "No such request"), + ), + tag = "friends", +)] +pub(crate) async fn decline( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult { + done(iris_db::friends::decline(state.db(), user.id, UserId::from(id)).await?) +} + +#[utoipa::path( + delete, + path = "/api/me/friends/{id}", + operation_id = "friends_remove", + params(("id" = Uuid, Path, description = "The friend, or who was asked")), + responses( + (status = 204, description = "Friendship ended or request taken back"), + (status = 404, description = "Nothing to remove"), + ), + tag = "friends", +)] +pub(crate) async fn remove( + State(state): State, + user: AuthUser, + Path(id): Path, +) -> ApiResult { + done(iris_db::friends::remove(state.db(), user.id, UserId::from(id)).await?) +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct SharingRequest { + pub share_watching: bool, +} + +#[utoipa::path( + put, + path = "/api/me/friends/sharing", + operation_id = "friends_set_sharing", + request_body = SharingRequest, + responses((status = 204, description = "Saved")), + tag = "friends", +)] +pub(crate) async fn set_sharing( + State(state): State, + user: AuthUser, + Json(body): Json, +) -> ApiResult { + iris_db::friends::set_sharing(state.db(), user.id, body.share_watching).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct HideRequest { + pub infohash: String, +} + +#[utoipa::path( + post, + path = "/api/me/friends/hidden", + operation_id = "friends_hide", + request_body = HideRequest, + responses( + (status = 204, description = "The release's whole title is kept from friends"), + (status = 404, description = "No such release"), + ), + tag = "friends", +)] +pub(crate) async fn hide( + State(state): State, + user: AuthUser, + Json(body): Json, +) -> ApiResult { + done(iris_db::friends::hide(state.db(), user.id, &body.infohash).await?) +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UnhideRequest { + pub collection_id: Option, + pub infohash: Option, +} + +#[utoipa::path( + delete, + path = "/api/me/friends/hidden", + operation_id = "friends_unhide", + request_body = UnhideRequest, + responses((status = 204, description = "Friends can see it again")), + tag = "friends", +)] +pub(crate) async fn unhide( + State(state): State, + user: AuthUser, + Json(body): Json, +) -> ApiResult { + iris_db::friends::unhide( + state.db(), + user.id, + body.collection_id, + body.infohash.as_deref(), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum FriendAction { + Watching, + Watched, + Added, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct FriendEvent { + pub friend_id: Uuid, + pub friend_name: String, + pub action: FriendAction, + pub title: String, + pub collection_id: Option, + pub season: Option, + pub episode: Option, + pub at: DateTime, +} + +impl FriendEvent { + fn of( + friend_id: Uuid, + friend_name: String, + action: FriendAction, + at: DateTime, + t: Title, + ) -> Self { + Self { + friend_id, + friend_name, + action, + title: t.title, + collection_id: t.collection_id, + season: t.season, + episode: t.episode, + at, + } + } + + /// One line per friend and title: an episode or release more of the same + /// title says nothing new. + fn key(&self) -> (Uuid, String) { + let title = self + .collection_id + .map_or_else(|| self.title.clone(), |c| c.to_string()); + (self.friend_id, title) + } +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct FriendActivity { + /// What friends are playing this minute. + pub live: Vec, + /// What they watched and added lately, newest first. + pub recent: Vec, +} + +#[utoipa::path( + get, + path = "/api/me/friends/activity", + operation_id = "friends_activity", + responses((status = 200, description = "What friends watch and add", body = FriendActivity)), + tag = "friends", +)] +pub(crate) async fn activity( + State(state): State, + user: AuthUser, +) -> ApiResult> { + let sharing = iris_db::friends::sharing_friends(state.db(), user.id).await?; + let sessions = state.presence().snapshot().await; + let mut live = Vec::new(); + for friend in sharing { + let Some(session) = sessions.iter().find(|s| s.user_id == friend.id) else { + continue; + }; + let shown = iris_db::friends::shown_title( + state.db(), + friend.id, + &session.infohash, + session.file_idx, + ) + .await?; + if let Some(title) = shown { + live.push(FriendEvent::of( + friend.id, + friend.name, + FriendAction::Watching, + session.last_seen_at, + title, + )); + } + } + + let event = + |action| move |e: Event| FriendEvent::of(e.friend_id, e.friend_name, action, e.at, e.title); + let mut recent: Vec<_> = iris_db::friends::recently_watched(state.db(), user.id, RECENT) + .await? + .into_iter() + .map(event(FriendAction::Watched)) + .chain( + iris_db::friends::recently_added(state.db(), user.id, RECENT) + .await? + .into_iter() + .map(event(FriendAction::Added)), + ) + .collect(); + recent.sort_by_key(|e| std::cmp::Reverse(e.at)); + let mut seen: HashSet<_> = live.iter().map(FriendEvent::key).collect(); + recent.retain(|e| seen.insert(e.key())); + recent.truncate(usize::try_from(RECENT).unwrap_or(usize::MAX)); + Ok(Json(FriendActivity { live, recent })) +} + +#[cfg(test)] +mod tests { + use axum::http::StatusCode; + + use crate::presence::{Heartbeat, PlaybackState}; + use crate::routes::auth::tests::{app_with_member, call}; + + #[tokio::test] + async fn live_watching_needs_a_friend_who_shares_and_never_shows_an_email() { + let (state, app, me, _) = app_with_member().await; + let friend = iris_db::test_support::make_named_user(state.db(), "Muriel").await; + sqlx::query( + "INSERT INTO torrents (id, infohash, name, total_size_bytes, added_by, added_at) \ + VALUES (?1, 'aaaa', 'Dune.2021', 1, ?2, ?3)", + ) + .bind(uuid::Uuid::new_v4()) + .bind(uuid::Uuid::from(friend)) + .bind(chrono::Utc::now()) + .execute(state.db()) + .await + .unwrap(); + state + .presence() + .touch(Heartbeat { + user_id: friend.into(), + infohash: "aaaa".into(), + file_idx: 0, + position_seconds: 1.0, + duration_seconds: None, + state: PlaybackState::Playing, + client: None, + client_version: None, + browser: None, + }) + .await; + let mine = state.jwt().issue_access(me, false).unwrap(); + let theirs = state.jwt().issue_access(friend, false).unwrap(); + let read = async || { + call( + &app, + "GET", + "/api/me/friends/activity", + Some(&mine), + None, + None, + ) + .await + }; + + let found = call( + &app, + "GET", + "/api/me/friends/search?q=mur", + Some(&mine), + None, + None, + ) + .await; + assert_eq!(found.json[0]["name"], "Muriel"); + assert!(found.json[0].get("email").is_none()); + + let path = format!("/api/me/friends/{}", uuid::Uuid::from(friend)); + assert_eq!( + call(&app, "POST", &path, Some(&mine), None, None) + .await + .status, + StatusCode::NO_CONTENT + ); + assert_eq!(read().await.json["recent"], serde_json::json!([])); + + let accepted = format!("/api/me/friends/{}/accept", uuid::Uuid::from(me)); + assert_eq!( + call(&app, "POST", &accepted, Some(&theirs), None, None) + .await + .status, + StatusCode::NO_CONTENT + ); + let friends = read().await; + assert_eq!(friends.json["live"], serde_json::json!([])); + assert_eq!(friends.json["recent"][0]["action"], "added"); + + call( + &app, + "PUT", + "/api/me/friends/sharing", + Some(&theirs), + None, + Some(serde_json::json!({ "share_watching": true })), + ) + .await; + let live = read().await; + assert_eq!(live.json["live"][0]["action"], "watching"); + assert_eq!(live.json["live"][0]["friend_name"], "Muriel"); + } + + #[tokio::test] + async fn only_the_one_asked_can_answer() { + let (state, app, me, _) = app_with_member().await; + let other = iris_db::test_support::make_user(state.db()).await; + let mine = state.jwt().issue_access(me, false).unwrap(); + iris_db::friends::request(state.db(), me, other) + .await + .unwrap(); + let path = format!("/api/me/friends/{}/accept", uuid::Uuid::from(other)); + assert_eq!( + call(&app, "POST", &path, Some(&mine), None, None) + .await + .status, + StatusCode::NOT_FOUND + ); + } +} diff --git a/crates/iris-api/src/routes/me.rs b/crates/iris-api/src/routes/me.rs index 469cabc4..438f6604 100644 --- a/crates/iris-api/src/routes/me.rs +++ b/crates/iris-api/src/routes/me.rs @@ -468,6 +468,8 @@ pub(crate) struct HomeSummary { /// Deprecated: always `0` (the watchlist cards count their own). #[schema(deprecated)] new_episodes: i64, + /// Friend requests waiting for this profile's answer. + friend_requests: Option, } #[derive(Debug, Serialize, ToSchema)] @@ -484,8 +486,9 @@ pub(crate) struct DiskSpace { )] pub(crate) async fn summary( State(state): State, - _user: AuthUser, + user: AuthUser, ) -> ApiResult> { + let friend_requests = iris_db::friends::incoming_count(state.db(), user.id).await?; let torrents = state.engine().list(); let active: Vec<_> = torrents .iter() @@ -508,6 +511,7 @@ pub(crate) async fn summary( downloading_eta_seconds, seeding: 0, new_episodes: 0, + friend_requests: Some(friend_requests), })) } diff --git a/crates/iris-api/src/routes/mod.rs b/crates/iris-api/src/routes/mod.rs index f5340f9f..b891b4a0 100644 --- a/crates/iris-api/src/routes/mod.rs +++ b/crates/iris-api/src/routes/mod.rs @@ -5,6 +5,7 @@ pub mod discover; pub mod extract; pub mod follows; pub mod foryou; +pub mod friends; pub mod health; pub mod library; pub mod live_tv; diff --git a/crates/iris-db/src/collections.rs b/crates/iris-db/src/collections.rs index a9616c13..d32108ec 100644 --- a/crates/iris-db/src/collections.rs +++ b/crates/iris-db/src/collections.rs @@ -508,6 +508,7 @@ pub async fn merge_into( WHERE collection_id = ?1", "UPDATE OR IGNORE cw_dismissed SET collection_id = ?2 WHERE collection_id = ?1", "UPDATE OR IGNORE ghost_dismissed SET collection_id = ?2 WHERE collection_id = ?1", + "UPDATE OR IGNORE friends_hidden SET collection_id = ?2 WHERE collection_id = ?1", ] { sqlx::query(sql) .bind(loser.id) diff --git a/crates/iris-db/src/friends.rs b/crates/iris-db/src/friends.rs new file mode 100644 index 00000000..a783a7e4 --- /dev/null +++ b/crates/iris-db/src/friends.rs @@ -0,0 +1,480 @@ +//! Friendships between profiles, what a profile shares with them, and the +//! activity they see. + +use chrono::{DateTime, Utc}; +use iris_core::ids::UserId; +use sqlx::SqlitePool; +use uuid::Uuid; + +/// Who a profile's accepted friends are, for `?1` the profile. +macro_rules! friends_cte { + () => { + "WITH friends(id) AS ( \ + SELECT addressee_id FROM friendships WHERE requester_id = ?1 AND accepted_at IS NOT NULL \ + UNION \ + SELECT requester_id FROM friendships WHERE addressee_id = ?1 AND accepted_at IS NOT NULL) " + }; +} + +/// Keeps a row of torrent `t` its owner `$owner` did not hide. +macro_rules! not_hidden { + ($owner:literal) => { + concat!( + " NOT EXISTS (SELECT 1 FROM friends_hidden h WHERE h.user_id = ", + $owner, + " AND (h.collection_id = t.collection_id OR h.infohash = t.infohash)) " + ) + }; +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Person { + pub id: Uuid, + pub name: String, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Relation { + pub id: Uuid, + pub name: String, + pub since: DateTime, + pub accepted: bool, + pub incoming: bool, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct HiddenTitle { + pub collection_id: Option, + pub infohash: Option, + pub title: String, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Title { + pub collection_id: Option, + pub infohash: String, + pub title: String, + pub season: Option, + pub episode: Option, +} + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Event { + pub friend_id: Uuid, + pub friend_name: String, + pub at: DateTime, + #[sqlx(flatten)] + pub title: Title, +} + +/// Ask `to`; when `to` already asked, that is a yes both ways. Asking again +/// changes nothing, and a declined asker is told nothing. +pub async fn request(pool: &SqlitePool, from: UserId, to: UserId) -> Result<(), sqlx::Error> { + let (from, to): (Uuid, Uuid) = (from.into(), to.into()); + let now = Utc::now(); + let mut tx = pool.begin_with("BEGIN IMMEDIATE").await?; + let answered = sqlx::query( + "UPDATE friendships SET accepted_at = COALESCE(accepted_at, ?1), declined_at = NULL \ + WHERE requester_id = ?2 AND addressee_id = ?3", + ) + .bind(now) + .bind(to) + .bind(from) + .execute(&mut *tx) + .await? + .rows_affected(); + if answered == 0 { + sqlx::query( + "INSERT OR IGNORE INTO friendships (requester_id, addressee_id, created_at) \ + VALUES (?1, ?2, ?3)", + ) + .bind(from) + .bind(to) + .bind(now) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +/// `true` when `other` had asked `me`, and now they are friends. +pub async fn accept(pool: &SqlitePool, me: UserId, other: UserId) -> Result { + let res = sqlx::query( + "UPDATE friendships SET accepted_at = ?1 \ + WHERE requester_id = ?2 AND addressee_id = ?3 \ + AND accepted_at IS NULL AND declined_at IS NULL", + ) + .bind(Utc::now()) + .bind(Uuid::from(other)) + .bind(Uuid::from(me)) + .execute(pool) + .await?; + Ok(res.rows_affected() == 1) +} + +pub async fn decline(pool: &SqlitePool, me: UserId, other: UserId) -> Result { + let res = sqlx::query( + "UPDATE friendships SET declined_at = ?1 \ + WHERE requester_id = ?2 AND addressee_id = ?3 \ + AND accepted_at IS NULL AND declined_at IS NULL", + ) + .bind(Utc::now()) + .bind(Uuid::from(other)) + .bind(Uuid::from(me)) + .execute(pool) + .await?; + Ok(res.rows_affected() == 1) +} + +/// End a friendship, or take back one's own request. A request `me` declined +/// stays, or asking again would get past the decline. +pub async fn remove(pool: &SqlitePool, me: UserId, other: UserId) -> Result { + let res = sqlx::query( + "DELETE FROM friendships \ + WHERE (requester_id = ?1 AND addressee_id = ?2) \ + OR (requester_id = ?2 AND addressee_id = ?1 AND accepted_at IS NOT NULL)", + ) + .bind(Uuid::from(me)) + .bind(Uuid::from(other)) + .execute(pool) + .await?; + Ok(res.rows_affected() > 0) +} + +/// Friends and live requests. What `me` declined is gone from its side; what +/// was declined to `me` still reads as pending. +pub async fn relations(pool: &SqlitePool, me: UserId) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT u.id, u.display_name AS name, COALESCE(f.accepted_at, f.created_at) AS since, \ + f.accepted_at IS NOT NULL AS accepted, f.addressee_id = ?1 AS incoming \ + FROM friendships f \ + JOIN users u ON u.id = CASE WHEN f.requester_id = ?1 THEN f.addressee_id ELSE f.requester_id END \ + WHERE (f.requester_id = ?1 OR f.addressee_id = ?1) \ + AND NOT (f.addressee_id = ?1 AND f.declined_at IS NOT NULL) \ + ORDER BY u.display_name COLLATE NOCASE", + ) + .bind(Uuid::from(me)) + .fetch_all(pool) + .await +} + +/// How many requests wait for `me`. +pub async fn incoming_count(pool: &SqlitePool, me: UserId) -> Result { + let (n,): (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM friendships \ + WHERE addressee_id = ?1 AND accepted_at IS NULL AND declined_at IS NULL", + ) + .bind(Uuid::from(me)) + .fetch_one(pool) + .await?; + Ok(n) +} + +/// Profiles by name for `me` to ask, none it is already linked to either way. +pub async fn search( + pool: &SqlitePool, + me: UserId, + query: &str, + limit: i64, +) -> Result, sqlx::Error> { + let escaped = query + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + sqlx::query_as( + "SELECT u.id, u.display_name AS name FROM users u \ + WHERE u.id <> ?1 AND u.display_name LIKE '%' || ?2 || '%' ESCAPE '\\' \ + AND NOT EXISTS (SELECT 1 FROM friendships f \ + WHERE (f.requester_id = ?1 AND f.addressee_id = u.id) \ + OR (f.requester_id = u.id AND f.addressee_id = ?1)) \ + ORDER BY u.display_name COLLATE NOCASE LIMIT ?3", + ) + .bind(Uuid::from(me)) + .bind(escaped) + .bind(limit) + .fetch_all(pool) + .await +} + +pub async fn sharing(pool: &SqlitePool, me: UserId) -> Result { + let (on,): (bool,) = sqlx::query_as("SELECT share_watching FROM users WHERE id = ?1") + .bind(Uuid::from(me)) + .fetch_one(pool) + .await?; + Ok(on) +} + +pub async fn set_sharing(pool: &SqlitePool, me: UserId, on: bool) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE users SET share_watching = ?1 WHERE id = ?2") + .bind(on) + .bind(Uuid::from(me)) + .execute(pool) + .await?; + Ok(()) +} + +/// Keep a release's whole title from friends. `false`: no such release. +pub async fn hide(pool: &SqlitePool, me: UserId, infohash: &str) -> Result { + let res = sqlx::query( + "INSERT OR IGNORE INTO friends_hidden (user_id, collection_id, infohash, created_at) \ + SELECT ?1, t.collection_id, CASE WHEN t.collection_id IS NULL THEN t.infohash END, ?2 \ + FROM torrents t WHERE t.infohash = ?3", + ) + .bind(Uuid::from(me)) + .bind(Utc::now()) + .bind(infohash) + .execute(pool) + .await?; + let (known,): (bool,) = + sqlx::query_as("SELECT EXISTS (SELECT 1 FROM torrents WHERE infohash = ?1)") + .bind(infohash) + .fetch_one(pool) + .await?; + Ok(res.rows_affected() > 0 || known) +} + +pub async fn unhide( + pool: &SqlitePool, + me: UserId, + collection_id: Option, + infohash: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query( + "DELETE FROM friends_hidden \ + WHERE user_id = ?1 AND (collection_id = ?2 OR infohash = ?3)", + ) + .bind(Uuid::from(me)) + .bind(collection_id) + .bind(infohash) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn hidden(pool: &SqlitePool, me: UserId) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT h.collection_id, h.infohash, COALESCE(c.display_title, t.name, '') AS title \ + FROM friends_hidden h \ + LEFT JOIN collections c ON c.id = h.collection_id \ + LEFT JOIN torrents t ON t.infohash = h.infohash \ + WHERE h.user_id = ?1 ORDER BY h.created_at DESC", + ) + .bind(Uuid::from(me)) + .fetch_all(pool) + .await +} + +/// `me`'s friends who share what they watch. +pub async fn sharing_friends(pool: &SqlitePool, me: UserId) -> Result, sqlx::Error> { + sqlx::query_as(concat!( + friends_cte!(), + "SELECT u.id, u.display_name AS name FROM users u \ + JOIN friends ON friends.id = u.id WHERE u.share_watching" + )) + .bind(Uuid::from(me)) + .fetch_all(pool) + .await +} + +/// What `owner` is playing, unless kept from friends. +pub async fn shown_title( + pool: &SqlitePool, + owner: Uuid, + infohash: &str, + file_idx: i64, +) -> Result, sqlx::Error> { + sqlx::query_as(concat!( + "SELECT t.collection_id, t.infohash, COALESCE(c.display_title, t.name) AS title, \ + ef.season, ef.episode \ + FROM torrents t \ + LEFT JOIN collections c ON c.id = t.collection_id \ + LEFT JOIN episode_files ef ON ef.infohash = t.infohash AND ef.file_idx = ?3 \ + WHERE t.infohash = ?2 AND", + not_hidden!("?1") + )) + .bind(owner) + .bind(infohash) + .bind(file_idx) + .fetch_optional(pool) + .await +} + +/// What friends who share watched last, newest first. +pub async fn recently_watched( + pool: &SqlitePool, + me: UserId, + limit: i64, +) -> Result, sqlx::Error> { + sqlx::query_as(concat!( + friends_cte!(), + "SELECT u.id AS friend_id, u.display_name AS friend_name, p.last_watched_at AS at, \ + t.collection_id, t.infohash, COALESCE(c.display_title, t.name) AS title, \ + ef.season, ef.episode \ + FROM playback_progress p \ + JOIN friends ON friends.id = p.user_id \ + JOIN users u ON u.id = p.user_id AND u.share_watching \ + JOIN torrents t ON t.infohash = p.infohash \ + LEFT JOIN collections c ON c.id = t.collection_id \ + LEFT JOIN episode_files ef ON ef.infohash = p.infohash AND ef.file_idx = p.file_idx \ + WHERE", + not_hidden!("p.user_id"), + "ORDER BY p.last_watched_at DESC LIMIT ?2" + )) + .bind(Uuid::from(me)) + .bind(limit) + .fetch_all(pool) + .await +} + +/// What friends added to the library last, newest first. +pub async fn recently_added( + pool: &SqlitePool, + me: UserId, + limit: i64, +) -> Result, sqlx::Error> { + sqlx::query_as(concat!( + friends_cte!(), + "SELECT u.id AS friend_id, u.display_name AS friend_name, t.added_at AS at, \ + t.collection_id, t.infohash, COALESCE(c.display_title, t.name) AS title, \ + NULL AS season, NULL AS episode \ + FROM torrents t \ + JOIN friends ON friends.id = t.added_by \ + JOIN users u ON u.id = t.added_by \ + LEFT JOIN collections c ON c.id = t.collection_id \ + WHERE t.deleted_at IS NULL AND", + not_hidden!("t.added_by"), + "ORDER BY t.added_at DESC LIMIT ?2" + )) + .bind(Uuid::from(me)) + .bind(limit) + .fetch_all(pool) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{make_named_user, make_user, migrated_pool}; + + async fn torrent(pool: &SqlitePool, infohash: &str, owner: UserId) { + sqlx::query( + "INSERT INTO torrents (id, infohash, name, total_size_bytes, added_by, added_at) \ + VALUES (?1, ?2, ?3, 1, ?4, ?5)", + ) + .bind(Uuid::new_v4()) + .bind(infohash) + .bind(format!("Release.{infohash}")) + .bind(Uuid::from(owner)) + .bind(Utc::now()) + .execute(pool) + .await + .unwrap(); + } + + async fn watch(pool: &SqlitePool, user: UserId, infohash: &str) { + sqlx::query( + "INSERT INTO playback_progress (user_id, infohash, file_idx, last_watched_at) \ + VALUES (?1, ?2, 0, ?3)", + ) + .bind(Uuid::from(user)) + .bind(infohash) + .bind(Utc::now()) + .execute(pool) + .await + .unwrap(); + } + + #[tokio::test] + async fn a_declined_asker_is_told_nothing_and_cannot_ask_again() { + let pool = migrated_pool().await; + let (a, b) = (make_user(&pool).await, make_user(&pool).await); + request(&pool, a, b).await.unwrap(); + assert_eq!(incoming_count(&pool, b).await.unwrap(), 1); + assert!(decline(&pool, b, a).await.unwrap()); + assert_eq!(incoming_count(&pool, b).await.unwrap(), 0); + assert!(relations(&pool, b).await.unwrap().is_empty()); + let asker = relations(&pool, a).await.unwrap(); + assert!(!asker[0].accepted && !asker[0].incoming); + + request(&pool, a, b).await.unwrap(); + assert_eq!(incoming_count(&pool, b).await.unwrap(), 0); + assert!(!remove(&pool, b, a).await.unwrap(), "the decline stays"); + } + + #[tokio::test] + async fn asking_someone_who_asked_is_a_yes() { + let pool = migrated_pool().await; + let (a, b) = (make_user(&pool).await, make_user(&pool).await); + request(&pool, a, b).await.unwrap(); + request(&pool, b, a).await.unwrap(); + assert!(relations(&pool, a).await.unwrap()[0].accepted); + assert!(relations(&pool, b).await.unwrap()[0].accepted); + } + + #[tokio::test] + async fn search_finds_by_name_and_leaves_out_who_is_linked() { + let pool = migrated_pool().await; + let me = make_named_user(&pool, "Vincent").await; + let muriel = make_named_user(&pool, "Muriel").await; + make_named_user(&pool, "100%").await; + let names = |v: Vec| v.into_iter().map(|p| p.name).collect::>(); + assert_eq!( + names(search(&pool, me, "uri", 20).await.unwrap()), + ["Muriel"] + ); + assert_eq!(names(search(&pool, me, "%", 20).await.unwrap()), ["100%"]); + assert!(search(&pool, me, "vin", 20).await.unwrap().is_empty()); + request(&pool, muriel, me).await.unwrap(); + assert!(search(&pool, me, "uri", 20).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn watching_reaches_friends_only_once_shared_and_never_when_hidden() { + let pool = migrated_pool().await; + let (me, friend, stranger) = ( + make_user(&pool).await, + make_user(&pool).await, + make_user(&pool).await, + ); + torrent(&pool, "aaaa", friend).await; + torrent(&pool, "bbbb", stranger).await; + watch(&pool, friend, "aaaa").await; + watch(&pool, stranger, "bbbb").await; + request(&pool, friend, me).await.unwrap(); + set_sharing(&pool, stranger, true).await.unwrap(); + + assert!( + recently_added(&pool, me, 10).await.unwrap().is_empty(), + "asked, not accepted" + ); + accept(&pool, me, friend).await.unwrap(); + + assert_eq!(recently_added(&pool, me, 10).await.unwrap().len(), 1); + assert!(recently_watched(&pool, me, 10).await.unwrap().is_empty()); + set_sharing(&pool, friend, true).await.unwrap(); + let watched = recently_watched(&pool, me, 10).await.unwrap(); + assert_eq!(watched[0].title.infohash, "aaaa"); + assert_eq!(watched.len(), 1, "a stranger shares nothing with me"); + assert_eq!(sharing_friends(&pool, me).await.unwrap().len(), 1); + + assert!(hide(&pool, friend, "aaaa").await.unwrap()); + assert!(recently_watched(&pool, me, 10).await.unwrap().is_empty()); + assert!(recently_added(&pool, me, 10).await.unwrap().is_empty()); + assert!( + shown_title(&pool, friend.into(), "aaaa", 0) + .await + .unwrap() + .is_none() + ); + assert_eq!(hidden(&pool, friend).await.unwrap().len(), 1); + unhide(&pool, friend, None, Some("aaaa")).await.unwrap(); + assert_eq!(recently_watched(&pool, me, 10).await.unwrap().len(), 1); + } + + #[tokio::test] + async fn hiding_an_unknown_release_is_refused() { + let pool = migrated_pool().await; + let me = make_user(&pool).await; + assert!(!hide(&pool, me, "nope").await.unwrap()); + } +} diff --git a/crates/iris-db/src/lib.rs b/crates/iris-db/src/lib.rs index e919d66f..16aa2177 100644 --- a/crates/iris-db/src/lib.rs +++ b/crates/iris-db/src/lib.rs @@ -39,6 +39,7 @@ pub mod collections; pub mod device_codes; pub mod episode_files; pub mod follows; +pub mod friends; pub mod invitations; pub mod migrate; pub mod passkeys; diff --git a/migrations/0054_friends.sql b/migrations/0054_friends.sql new file mode 100644 index 00000000..7e5a16d0 --- /dev/null +++ b/migrations/0054_friends.sql @@ -0,0 +1,33 @@ +-- Friendships hang on profiles (users rows). A declined request keeps its row: +-- the asker keeps seeing it pending, is told nothing, and cannot ask again. +CREATE TABLE friendships ( + requester_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE, + addressee_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL, + accepted_at TIMESTAMP, + declined_at TIMESTAMP, + PRIMARY KEY (requester_id, addressee_id), + CHECK (requester_id <> addressee_id) +); + +CREATE INDEX friendships_addressee_idx ON friendships(addressee_id); +CREATE UNIQUE INDEX friendships_pair_idx + ON friendships(min(requester_id, addressee_id), max(requester_id, addressee_id)); + +-- What a profile watches reaches its friends only once it says so. What it +-- adds to the library was already visible to everyone ("added by"). +ALTER TABLE users ADD COLUMN share_watching BOOLEAN NOT NULL DEFAULT 0; + +-- Titles kept from friends: by collection, by release when it has none. +CREATE TABLE friends_hidden ( + user_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE, + collection_id BLOB REFERENCES collections(id) ON DELETE CASCADE, + infohash TEXT, + created_at TIMESTAMP NOT NULL, + CHECK ((collection_id IS NULL) <> (infohash IS NULL)) +); + +CREATE UNIQUE INDEX friends_hidden_collection_idx + ON friends_hidden(user_id, collection_id) WHERE collection_id IS NOT NULL; +CREATE UNIQUE INDEX friends_hidden_release_idx + ON friends_hidden(user_id, infohash) WHERE infohash IS NOT NULL; diff --git a/web/openapi.json b/web/openapi.json index 9317f7bf..6d941790 100644 --- a/web/openapi.json +++ b/web/openapi.json @@ -2325,6 +2325,260 @@ } } }, + "/api/me/friends": { + "get": { + "tags": [ + "friends" + ], + "operationId": "friends_overview", + "responses": { + "200": { + "description": "Friends, requests and what is shared", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FriendsOverview" + } + } + } + } + } + } + }, + "/api/me/friends/activity": { + "get": { + "tags": [ + "friends" + ], + "operationId": "friends_activity", + "responses": { + "200": { + "description": "What friends watch and add", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FriendActivity" + } + } + } + } + } + } + }, + "/api/me/friends/hidden": { + "post": { + "tags": [ + "friends" + ], + "operationId": "friends_hide", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HideRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The release's whole title is kept from friends" + }, + "404": { + "description": "No such release" + } + } + }, + "delete": { + "tags": [ + "friends" + ], + "operationId": "friends_unhide", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnhideRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Friends can see it again" + } + } + } + }, + "/api/me/friends/search": { + "get": { + "tags": [ + "friends" + ], + "operationId": "friends_search", + "parameters": [ + { + "name": "q", + "in": "query", + "description": "Part of a display name, two characters at least.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Profiles to ask, by name; never an email", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonView" + } + } + } + } + } + } + } + }, + "/api/me/friends/sharing": { + "put": { + "tags": [ + "friends" + ], + "operationId": "friends_set_sharing", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharingRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Saved" + } + } + } + }, + "/api/me/friends/{id}": { + "post": { + "tags": [ + "friends" + ], + "operationId": "friends_ask", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The profile to ask", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Asked; when they had asked first, you are friends" + }, + "404": { + "description": "No such profile" + } + } + }, + "delete": { + "tags": [ + "friends" + ], + "operationId": "friends_remove", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The friend, or who was asked", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Friendship ended or request taken back" + }, + "404": { + "description": "Nothing to remove" + } + } + } + }, + "/api/me/friends/{id}/accept": { + "post": { + "tags": [ + "friends" + ], + "operationId": "friends_accept", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Who asked", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "You are friends" + }, + "404": { + "description": "No such request" + } + } + } + }, + "/api/me/friends/{id}/decline": { + "post": { + "tags": [ + "friends" + ], + "operationId": "friends_decline", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Who asked", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "204": { + "description": "Declined; they are not told" + }, + "404": { + "description": "No such request" + } + } + } + }, "/api/me/gone/dismiss": { "post": { "tags": [ @@ -5947,6 +6201,129 @@ } } }, + "FriendAction": { + "type": "string", + "enum": [ + "watching", + "watched", + "added" + ] + }, + "FriendActivity": { + "type": "object", + "required": [ + "live", + "recent" + ], + "properties": { + "live": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FriendEvent" + }, + "description": "What friends are playing this minute." + }, + "recent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FriendEvent" + }, + "description": "What they watched and added lately, newest first." + } + } + }, + "FriendEvent": { + "type": "object", + "required": [ + "friend_id", + "friend_name", + "action", + "title", + "at" + ], + "properties": { + "action": { + "$ref": "#/components/schemas/FriendAction" + }, + "at": { + "type": "string", + "format": "date-time" + }, + "collection_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "episode": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "friend_id": { + "type": "string", + "format": "uuid" + }, + "friend_name": { + "type": "string" + }, + "season": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "title": { + "type": "string" + } + } + }, + "FriendsOverview": { + "type": "object", + "required": [ + "friends", + "incoming", + "outgoing", + "share_watching", + "hidden" + ], + "properties": { + "friends": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonView" + } + }, + "hidden": { + "type": "array", + "items": { + "$ref": "#/components/schemas/HiddenTitleView" + } + }, + "incoming": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonView" + }, + "description": "Requests waiting for this profile's answer." + }, + "outgoing": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PersonView" + }, + "description": "Requests this profile sent, still unanswered as far as it can tell." + }, + "share_watching": { + "type": "boolean", + "description": "Whether friends see what this profile watches." + } + } + }, "GcReport": { "type": "object", "required": [ @@ -6223,6 +6600,41 @@ "hlg" ] }, + "HiddenTitleView": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "collection_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "infohash": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": "string" + } + } + }, + "HideRequest": { + "type": "object", + "required": [ + "infohash" + ], + "properties": { + "infohash": { + "type": "string" + } + } + }, "HistoryItem": { "type": "object", "description": "One row of the caller's full watch history (in-progress AND completed),\nincluding items whose source torrent has since been deleted\n(disk-reclaim GC, admin cleanup) — unlike [`ContinueWatchingItem`], an\nentry never just vanishes; `deleted` flags it instead so the client can\nshow \"no longer available\" rather than a dead resume link.", @@ -6395,6 +6807,14 @@ "format": "double", "description": "Overall progress of those downloads, by bytes (0–100)." }, + "friend_requests": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Friend requests waiting for this profile's answer." + }, "new_episodes": { "type": "integer", "format": "int64", @@ -7547,6 +7967,22 @@ } } }, + "PersonView": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + } + } + }, "PlayStatus": { "type": "object", "description": "State of the per-file remux job exposed to the player UI. Polled\nbefore mounting the `