From 45a7758eb07043b9cc51655298b9016468204099 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 31 Oct 2026 18:14:45 +0600 Subject: [PATCH] feat(jam): harden the room server, add device bans and friendly names Security: - Participants connect with a per-room key derived from a random per-install secret; others only see an id derived from it, so nobody can reconnect as someone else (including the host). - "create" only works on a fresh code or for the room's own host, so empty rooms can't be taken over. - Per-socket message and queue budgets disconnect clients that flood; the host throttles itself well under them. - Bans also cover the device: a hashed digest of device traits combined with the network, stored only as an opaque per-room value. Earlier members are never caught by someone else's ban. - Peer queue items and suggestions are sanitized (streaming tracks only, no URLs, https covers); suggestions are credited to the server-attested sender; local file paths go out as opaque ids. Names are cleaned server-side. - Room codes and keys use a secure random source; the deploy workflow is read-only. Names: - Participants get a stable generated name ("Mellow Otter") per install, and can pick their own on the Jam screen or in Jam settings. The participant list marks "(you)". Also translate the Jam strings into all locales and add tests for the above. --- .github/workflows/jam-server-deploy.yml | 5 + AGENTS.md | 1 + .../spotube/core/jam/JamDevice.android.kt | 37 +++++ .../composeResources/values-ar/strings.xml | 22 +++ .../composeResources/values-bn/strings.xml | 22 +++ .../composeResources/values-de/strings.xml | 22 +++ .../composeResources/values-es/strings.xml | 22 +++ .../composeResources/values-fa/strings.xml | 22 +++ .../composeResources/values-fr/strings.xml | 22 +++ .../composeResources/values-hi/strings.xml | 22 +++ .../composeResources/values-id/strings.xml | 22 +++ .../composeResources/values-it/strings.xml | 22 +++ .../composeResources/values-ja/strings.xml | 22 +++ .../composeResources/values-ko/strings.xml | 22 +++ .../composeResources/values-nl/strings.xml | 22 +++ .../composeResources/values-pl/strings.xml | 22 +++ .../composeResources/values-pt/strings.xml | 22 +++ .../composeResources/values-ru/strings.xml | 22 +++ .../composeResources/values-th/strings.xml | 22 +++ .../composeResources/values-tr/strings.xml | 22 +++ .../composeResources/values-uk/strings.xml | 22 +++ .../composeResources/values-vi/strings.xml | 22 +++ .../values-zh-rTW/strings.xml | 22 +++ .../composeResources/values-zh/strings.xml | 22 +++ .../composeResources/values/strings.xml | 143 +++++++++++++++++- .../org/spotube/spotube/core/jam/JamDevice.kt | 42 +++++ .../org/spotube/spotube/core/jam/JamNames.kt | 52 +++++++ .../spotube/spotube/core/jam/JamProtocol.kt | 32 +++- .../spotube/spotube/core/jam/JamRoomClient.kt | 63 +++++--- .../spotube/spotube/core/jam/JamRoomCode.kt | 20 ++- .../spotube/core/jam/JamRoomService.kt | 111 ++++++++++---- .../spotube/spotube/modules/jam/JamScreen.kt | 16 +- .../spotube/modules/jam/JamViewModel.kt | 24 ++- .../modules/settings/JamSettingsScreen.kt | 11 ++ .../modules/settings/JamSettingsViewModel.kt | 16 +- .../modules/settings/SettingsModels.kt | 2 + .../settings/sections/JamNameSetting.kt | 58 +++++++ .../spotube/spotube/core/jam/JamNamesTest.kt | 44 ++++++ .../spotube/core/jam/JamSecurityTest.kt | 93 ++++++++++++ .../spotube/spotube/core/jam/JamDevice.ios.kt | 36 +++++ .../spotube/spotube/core/jam/JamDevice.jvm.kt | 30 ++++ .../core/jam/JamRoomClientIntegrationTest.kt | 17 ++- .../core/jam/JamSessionSimulationTest.kt | 9 +- jam-server/README.md | 45 ++++++ jam-server/src/index.ts | 27 +++- jam-server/src/protocol.ts | 62 +++++++- jam-server/src/room.ts | 104 +++++++++++-- jam-server/test/smoke.mjs | 104 ++++++++++--- jam-server/wrangler.jsonc | 4 +- 49 files changed, 1555 insertions(+), 115 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/org/spotube/spotube/core/jam/JamDevice.android.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamDevice.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt create mode 100644 composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt create mode 100644 composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt create mode 100644 composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt create mode 100644 composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt diff --git a/.github/workflows/jam-server-deploy.yml b/.github/workflows/jam-server-deploy.yml index 8ffe31b0..fe183a98 100644 --- a/.github/workflows/jam-server-deploy.yml +++ b/.github/workflows/jam-server-deploy.yml @@ -6,6 +6,11 @@ on: paths: ["jam-server/**"] workflow_dispatch: +# The deploy only reads the repo; the Cloudflare token comes from secrets, which +# workflows triggered by pull requests from forks never receive. +permissions: + contents: read + concurrency: group: jam-server-deploy cancel-in-progress: false diff --git a/AGENTS.md b/AGENTS.md index 9d48dcfa..b2caeff0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ - Room server: `jam-server/` (Cloudflare Worker + one Durable Object per six-letter room code, TypeScript, wrangler). It relays/retains small JSON frames and serves the clock; no audio passes through it. Protocol in `jam-server/src/protocol.ts`; keep it in sync with the wire frames in `core/jam/JamRoomClient.kt` (bump `PROTOCOL_VERSION` on both sides for breaking changes). - App side: `core/jam/` — `JamRoomClient` (Ktor WebSocket, reconnects, keep-alives, clock pings), `JamClock`/`JamDriftCorrector` (sync math), `JamRoomService` (host broadcasts queue + playback anchors in server time; guests follow by nudging playback rate, seeking only when far off). - Cost and battery: the room is billed per incoming message and storage write, and on phones each transmission wakes the cellular radio. Don't add periodic frames or polling. Keep-alives go out once a minute. Guests' sync loop sleeps while the room is paused or buffering, and the host checks its own drift only while playing. Anchors go out on change (plus host drift and a 60 s heartbeat), keep-alives are runtime auto-responses, and anchors aren't persisted. See `jam-server/README.md`. +- Security: the server enforces identity (per-install secret → per-room key → public id), device-level bans (`JamDevice.kt` traits, only ever sent hashed), host-only powers and flood limits. The app treats everything from peers as untrusted and runs it through `JamMediaItem.sanitized` before loading it. See "Security" in `jam-server/README.md`. - A network drop must never crash the app: `JamRoomClient.pump` turns socket failures into a reconnect. `JamSessionSimulationTest.roomSurvivesNetworkLoss` cuts the network with `TcpProxy`. - Server URL setting: `UserSettings.jamServerUrl` (default `DEFAULT_JAM_SERVER_URL` in `SettingsModels.kt`). - Tests: `JamSyncTest` (pure), plus `JamRoomClientIntegrationTest` and `JamSessionSimulationTest`, which only run with `JAM_TEST_URL` set (start `npm run dev` in `jam-server/`). diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/jam/JamDevice.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/jam/JamDevice.android.kt new file mode 100644 index 00000000..45d18513 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/jam/JamDevice.android.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import android.content.res.Resources +import android.os.Build +import java.util.Locale +import java.util.TimeZone + +internal actual fun jamDeviceTraits(): List { + val display = Resources.getSystem().displayMetrics + return listOf( + "android", + Build.MANUFACTURER, + Build.MODEL, + Build.DEVICE, + Build.VERSION.RELEASE, + Build.VERSION.SDK_INT.toString(), + "${minOf(display.widthPixels, display.heightPixels)}x${maxOf(display.widthPixels, display.heightPixels)}@${display.densityDpi}", + Locale.getDefault().toLanguageTag(), + TimeZone.getDefault().id) +} diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml index 7910c67d..848261ee 100644 --- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -344,6 +344,28 @@ تعذّر الانضمام إلى جلسة الجام أدخل رمز غرفة مكوّنًا من ستة أحرف وأرقام. انضم إلى جام Spotube الخاص بي باستخدام الرمز: %1$s + خادم الغرف + رابط الخادم + أدخل رابطًا يبدأ بـ https:// + تحقّق من خادم الغرف قبل بدء غرفة. + يستخدم الجام خادم الغرف المجاني من Spotube. للاستضافة الذاتية، انشر jam-server من مستودع Spotube وأدخل رابطه. + أنت + الخادم: %1$s + حدّد خادم غرف في الإعدادات لاستضافة غرفة أو الانضمام إليها. + إعدادات الخادم + %1$s (أنت) + اسمك + يراك الآخرون في الجام باسم %1$s + يظهر لجميع من في الجام. اتركه فارغًا لاستخدام %1$s. يُطبَّق الاسم الجديد في المرة القادمة التي تنشئ فيها غرفة أو تنضم إليها. + استخدم %1$d حرفًا كحد أقصى. + لم يتم إعداد خادم للجام + تعذّر الوصول إلى خادم الجام. تحقّق من الشبكة أو من رابط الخادم في الإعدادات ← الجام الجماعي. + يعمل خادم الجام بإصدار غير متوافق. + لا توجد غرفة بهذا الرمز. تحقّق منه وحاول مجددًا. + هذه الغرفة ممتلئة. + حظرك المضيف من هذه الغرفة. + أزالك المضيف من الغرفة. + فُتحت هذه الغرفة مجددًا على هذا الجهاز، لذا أُغلق هذا الاتصال. الانتقال إلى الألبوم مؤقت النوم diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml index d5742676..08dc21ff 100644 --- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -328,6 +328,28 @@ জ্যাম সেশনে যোগ দেওয়া যায়নি অক্ষর ও সংখ্যা দিয়ে ছয় অক্ষরের একটি রুম কোড দিন। এই কোড দিয়ে আমার Spotube জ্যামে যোগ দিন: %1$s + রুম সার্ভার + সার্ভার URL + https:// দিয়ে শুরু হওয়া একটি URL দিন + রুম শুরু করার আগে রুম সার্ভার পরীক্ষা করুন। + জ্যাম Spotube-এর বিনামূল্যের রুম সার্ভার ব্যবহার করে। নিজে হোস্ট করতে Spotube রিপোজিটরি থেকে jam-server ডিপ্লয় করে তার URL দিন। + আপনি + সার্ভার: %1$s + রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি রুম সার্ভার সেট করুন। + সার্ভার সেটিংস + %1$s (আপনি) + আপনার নাম + জ্যামের অন্যরা আপনাকে %1$s নামে দেখবে + জ্যামের সবাই দেখতে পাবে। %1$s ব্যবহার করতে ফাঁকা রাখুন। নতুন নাম পরের বার রুম তৈরি বা যোগ দেওয়ার সময় থেকে কার্যকর হবে। + সর্বোচ্চ %1$dটি অক্ষর ব্যবহার করুন। + কোনো জ্যাম সার্ভার সেট করা নেই + জ্যাম সার্ভারে পৌঁছানো যায়নি। আপনার নেটওয়ার্ক বা সেটিংস → গ্রুপ জ্যাম-এ সার্ভার URL পরীক্ষা করুন। + জ্যাম সার্ভারের সংস্করণটি সামঞ্জস্যপূর্ণ নয়। + এই কোডের কোনো রুম নেই। কোডটি দেখে আবার চেষ্টা করুন। + এই রুমটি পূর্ণ। + হোস্ট আপনাকে এই রুম থেকে ব্যান করেছেন। + হোস্ট আপনাকে রুম থেকে সরিয়ে দিয়েছেন। + এই রুমটি এই ডিভাইসে আবার খোলা হয়েছে, তাই এই কানেকশনটি বন্ধ করা হয়েছে। অ্যালবামে যান স্লিপ টাইমার diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 0837991c..34d0a674 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -328,6 +328,28 @@ Beitritt zur Jam-Session fehlgeschlagen Gib einen sechsstelligen Raumcode aus Buchstaben und Ziffern ein. Tritt meiner Spotube-Jam bei, Code: %1$s + Raumserver + Server-URL + Gib eine URL ein, die mit https:// beginnt. + Prüfe den Raumserver, bevor du einen Raum startest. + Jam nutzt den kostenlosen Raumserver von Spotube. Zum Selbsthosten stelle jam-server aus dem Spotube-Repository bereit und gib seine URL ein. + Du + Server: %1$s + Lege in den Einstellungen einen Raumserver fest, um einen Raum zu hosten oder beizutreten. + Servereinstellungen + %1$s (du) + Dein Name + Andere im Jam sehen dich als %1$s + Wird allen im Jam angezeigt. Lass das Feld leer, um %1$s zu verwenden. Ein neuer Name gilt ab dem nächsten Raum, den du startest oder dem du beitrittst. + Verwende höchstens %1$d Zeichen. + Kein Jam-Server eingerichtet + Der Jam-Server ist nicht erreichbar. Prüfe dein Netzwerk oder die Server-URL unter Einstellungen → Gruppen-Jam. + Der Jam-Server verwendet eine inkompatible Version. + Kein Raum mit diesem Code. Prüfe ihn und versuche es erneut. + Dieser Raum ist voll. + Der Host hat dich aus diesem Raum gesperrt. + Der Host hat dich aus dem Raum entfernt. + Dieser Raum wurde auf diesem Gerät erneut geöffnet, daher wurde diese Verbindung getrennt. Zum Album Sleep-Timer diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index 6d486799..4f9e7296 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -332,6 +332,28 @@ No se pudo unir a la sesión de Jam Introduce un código de sala de seis caracteres con letras y números. Únete a mi Jam de Spotube con el código: %1$s + Servidor de salas + URL del servidor + Introduce una URL que empiece por https:// + Comprueba el servidor de salas antes de crear una sala. + Jam usa el servidor de salas gratuito de Spotube. Para alojarlo tú mismo, despliega jam-server desde el repositorio de Spotube e introduce su URL. + Tú + Servidor: %1$s + Configura un servidor de salas en Ajustes para crear una sala o unirte a una. + Ajustes del servidor + %1$s (tú) + Tu nombre + Los demás en la jam te ven como %1$s + Todos en la jam lo verán. Déjalo vacío para usar %1$s. Un nombre nuevo se aplica la próxima vez que crees una sala o te unas a una. + Usa como máximo %1$d caracteres. + No hay ningún servidor de jam configurado + No se pudo conectar con el servidor de jam. Revisa tu red o la URL del servidor en Ajustes → Jam en grupo. + El servidor de jam usa una versión incompatible. + No hay ninguna sala con ese código. Revísalo e inténtalo de nuevo. + Esta sala está llena. + El anfitrión te ha bloqueado en esta sala. + El anfitrión te ha quitado de la sala. + Esta sala se abrió de nuevo en este dispositivo, así que se cerró esta conexión. Ir al álbum Temporizador diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml index 3c0dbc1a..ad55e61e 100644 --- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -328,6 +328,28 @@ پیوستن به جلسهٔ جم ناموفق بود یک کد اتاق شش‌نویسه‌ای شامل حروف و اعداد وارد کنید. با این کد به جم Spotube من بپیوندید: %1$s + سرور اتاق‌ها + نشانی سرور + نشانی‌ای وارد کنید که با https:// شروع شود + پیش از شروع اتاق، سرور اتاق‌ها را بررسی کنید. + جم از سرور رایگان اتاق‌های Spotube استفاده می‌کند. برای میزبانی شخصی، jam-server را از مخزن Spotube راه‌اندازی کنید و نشانی آن را وارد کنید. + شما + سرور: %1$s + برای میزبانی یا پیوستن به اتاق، در تنظیمات یک سرور اتاق تعیین کنید. + تنظیمات سرور + %1$s (شما) + نام شما + دیگران در جم شما را با نام %1$s می‌بینند + به همه در جم نشان داده می‌شود. برای استفاده از %1$s خالی بگذارید. نام جدید از دفعهٔ بعدی که اتاقی می‌سازید یا به آن می‌پیوندید اعمال می‌شود. + حداکثر %1$d نویسه وارد کنید. + هیچ سرور جمی تنظیم نشده است + دسترسی به سرور جم ممکن نیست. شبکه یا نشانی سرور را در تنظیمات ← جم گروهی بررسی کنید. + سرور جم نسخهٔ ناسازگاری اجرا می‌کند. + اتاقی با این کد وجود ندارد. آن را بررسی کنید و دوباره امتحان کنید. + این اتاق پر است. + میزبان شما را از این اتاق مسدود کرد. + میزبان شما را از اتاق بیرون کرد. + این اتاق دوباره روی همین دستگاه باز شد، بنابراین این اتصال بسته شد. رفتن به آلبوم زمان‌سنج خواب diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 4372e030..08d0398c 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -332,6 +332,28 @@ Impossible de rejoindre la session Jam Saisissez un code de salle de six caractères (lettres et chiffres). Rejoignez ma Jam Spotube avec le code : %1$s + Serveur de salles + URL du serveur + Saisissez une URL commençant par https:// + Vérifiez le serveur de salles avant de lancer une salle. + Jam utilise le serveur de salles gratuit de Spotube. Pour l’héberger vous-même, déployez jam-server depuis le dépôt Spotube et saisissez son URL. + Vous + Serveur : %1$s + Définissez un serveur de salles dans les Paramètres pour créer ou rejoindre une salle. + Paramètres du serveur + %1$s (vous) + Votre nom + Les autres participants vous voient comme %1$s + Visible par tous les participants de la jam. Laissez vide pour utiliser %1$s. Un nouveau nom s’applique la prochaine fois que vous créez ou rejoignez une salle. + Utilisez au maximum %1$d caractères. + Aucun serveur de jam configuré + Impossible de joindre le serveur de jam. Vérifiez votre réseau ou l’URL du serveur dans Paramètres → Jam de groupe. + Le serveur de jam utilise une version incompatible. + Aucune salle avec ce code. Vérifiez-le et réessayez. + Cette salle est pleine. + L’hôte vous a banni de cette salle. + L’hôte vous a retiré de la salle. + Cette salle a été rouverte sur cet appareil, cette connexion a donc été fermée. Accéder à l\'album Minuteur de veille diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index 50c52c2d..82cb35aa 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -327,6 +327,28 @@ जैम सेशन से नहीं जुड़ सके अक्षरों और अंकों वाला छह अक्षर का रूम कोड डालें। मेरे Spotube जैम से जुड़ें, कोड: %1$s + रूम सर्वर + सर्वर URL + https:// से शुरू होने वाला URL डालें + रूम शुरू करने से पहले रूम सर्वर जांच लें। + जैम Spotube के मुफ़्त रूम सर्वर का इस्तेमाल करता है। खुद होस्ट करने के लिए Spotube रिपॉज़िटरी से jam-server डिप्लॉय करें और उसका URL डालें। + आप + सर्वर: %1$s + रूम होस्ट करने या उसमें शामिल होने के लिए सेटिंग में रूम सर्वर सेट करें। + सर्वर सेटिंग + %1$s (आप) + आपका नाम + जैम में बाकी लोग आपको %1$s के रूप में देखते हैं + जैम में सभी को दिखता है। %1$s इस्तेमाल करने के लिए खाली छोड़ें। नया नाम अगली बार रूम बनाने या उसमें शामिल होने पर लागू होगा। + ज़्यादा से ज़्यादा %1$d अक्षर इस्तेमाल करें। + कोई जैम सर्वर सेट नहीं है + जैम सर्वर तक नहीं पहुंच पाए। अपना नेटवर्क या सेटिंग → ग्रुप जैम में सर्वर URL जांचें। + जैम सर्वर का वर्शन संगत नहीं है। + इस कोड वाला कोई रूम नहीं है। कोड जांचकर फिर से कोशिश करें। + यह रूम भर गया है। + होस्ट ने आपको इस रूम से बैन कर दिया है। + होस्ट ने आपको रूम से हटा दिया है। + यह रूम इस डिवाइस पर फिर से खोला गया, इसलिए यह कनेक्शन बंद कर दिया गया। एल्बम पर जाएं स्लीप टाइमर diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index 1b8ee7e5..1f162c8d 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -324,6 +324,28 @@ Gagal bergabung ke sesi Jam Masukkan kode ruang enam karakter berisi huruf dan angka. Gabung ke Jam Spotube-ku dengan kode: %1$s + Server ruang + URL server + Masukkan URL yang diawali https:// + Periksa server ruang sebelum memulai ruang. + Jam memakai server ruang gratis milik Spotube. Untuk meng-host sendiri, deploy jam-server dari repositori Spotube lalu masukkan URL-nya. + Kamu + Server: %1$s + Atur server ruang di Pengaturan untuk membuat atau bergabung ke ruang. + Pengaturan server + %1$s (kamu) + Namamu + Orang lain di jam melihatmu sebagai %1$s + Ditampilkan ke semua orang di jam. Biarkan kosong untuk memakai %1$s. Nama baru berlaku saat kamu berikutnya membuat atau bergabung ke ruang. + Gunakan paling banyak %1$d karakter. + Belum ada server jam yang diatur + Tidak bisa menjangkau server jam. Periksa jaringan atau URL server di Pengaturan → Jam Grup. + Server jam menjalankan versi yang tidak kompatibel. + Tidak ada ruang dengan kode itu. Periksa lalu coba lagi. + Ruang ini sudah penuh. + Host memblokirmu dari ruang ini. + Host mengeluarkanmu dari ruang. + Ruang ini dibuka lagi di perangkat ini, jadi koneksi ini ditutup. Buka album Timer tidur diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 2a71283c..eb429ba5 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -332,6 +332,28 @@ Impossibile partecipare alla sessione Jam Inserisci un codice stanza di sei caratteri composto da lettere e cifre. Unisciti alla mia Jam su Spotube con il codice: %1$s + Server delle stanze + URL del server + Inserisci un URL che inizi con https:// + Controlla il server delle stanze prima di avviare una stanza. + Jam usa il server delle stanze gratuito di Spotube. Per ospitarlo in proprio, distribuisci jam-server dal repository di Spotube e inseriscine l’URL. + Tu + Server: %1$s + Imposta un server delle stanze nelle Impostazioni per ospitare una stanza o entrarci. + Impostazioni del server + %1$s (tu) + Il tuo nome + Gli altri nella jam ti vedono come %1$s + Visibile a tutti nella jam. Lascia vuoto per usare %1$s. Un nuovo nome vale dalla prossima volta che avvii una stanza o ci entri. + Usa al massimo %1$d caratteri. + Nessun server jam configurato + Impossibile raggiungere il server jam. Controlla la rete o l’URL del server in Impostazioni → Jam di gruppo. + Il server jam usa una versione non compatibile. + Nessuna stanza con questo codice. Controllalo e riprova. + Questa stanza è piena. + L’host ti ha bannato da questa stanza. + L’host ti ha rimosso dalla stanza. + Questa stanza è stata riaperta su questo dispositivo, quindi questa connessione è stata chiusa. Vai all\'album Timer di spegnimento diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index 473e213d..ce8e54a2 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -324,6 +324,28 @@ ジャムセッションに参加できませんでした 英数字6文字のルームコードを入力してください。 コード %1$s で私のSpotubeジャムに参加しよう + ルームサーバー + サーバーURL + https:// で始まるURLを入力してください。 + ルームを始める前にルームサーバーを確認します。 + ジャムはSpotubeの無料ルームサーバーを使います。自分でホストするには、Spotubeリポジトリのjam-serverをデプロイし、そのURLを入力してください。 + あなた + サーバー: %1$s + ルームをホストまたは参加するには、設定でルームサーバーを指定してください。 + サーバー設定 + %1$s(あなた) + あなたの名前 + ジャムの他の参加者には %1$s と表示されます + ジャムの全員に表示されます。空欄にすると %1$s を使います。新しい名前は次にルームを作成または参加したときから使われます。 + %1$d 文字以内で入力してください。 + ジャムサーバーが設定されていません + ジャムサーバーに接続できません。ネットワーク、または 設定 → グループジャム のサーバーURLを確認してください。 + ジャムサーバーのバージョンに互換性がありません。 + そのコードのルームはありません。コードを確認してもう一度お試しください。 + このルームは満員です。 + ホストによってこのルームからBANされました。 + ホストによってルームから削除されました。 + このデバイスでこのルームが再度開かれたため、この接続は閉じられました。 アルバムへ移動 スリープタイマー diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index 69dac887..b7fb9f12 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -324,6 +324,28 @@ 잼 세션에 참여하지 못했어요 영문자와 숫자로 된 6자리 방 코드를 입력하세요. 내 Spotube 잼에 참여하세요. 코드: %1$s + 방 서버 + 서버 URL + https://로 시작하는 URL을 입력하세요. + 방을 시작하기 전에 방 서버를 확인하세요. + 잼은 Spotube의 무료 방 서버를 사용합니다. 직접 호스팅하려면 Spotube 저장소의 jam-server를 배포하고 URL을 입력하세요. + 나 + 서버: %1$s + 방을 만들거나 참여하려면 설정에서 방 서버를 지정하세요. + 서버 설정 + %1$s (나) + 내 이름 + 잼의 다른 사람들에게 %1$s(으)로 표시됩니다 + 잼의 모든 사람에게 표시됩니다. 비워 두면 %1$s을(를) 사용합니다. 새 이름은 다음에 방을 만들거나 참여할 때부터 적용됩니다. + 최대 %1$d자까지 입력하세요. + 설정된 잼 서버가 없습니다 + 잼 서버에 연결할 수 없습니다. 네트워크 또는 설정 → 그룹 잼의 서버 URL을 확인하세요. + 잼 서버의 버전이 호환되지 않습니다. + 해당 코드의 방이 없습니다. 코드를 확인하고 다시 시도하세요. + 이 방은 가득 찼습니다. + 호스트가 이 방에서 나를 차단했습니다. + 호스트가 나를 방에서 내보냈습니다. + 이 기기에서 이 방이 다시 열려 이 연결이 종료되었습니다. 앨범으로 이동 취침 타이머 diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index c1b6c506..76fb4235 100644 --- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -328,6 +328,28 @@ Deelnemen aan Jam-sessie mislukt Voer een sessiecode van zes tekens in met letters en cijfers. Doe mee met mijn Spotube Jam met code: %1$s + Sessieserver + Server-URL + Voer een URL in die begint met https:// + Controleer de sessieserver voordat je een sessie start. + Jam gebruikt de gratis sessieserver van Spotube. Wil je hem zelf hosten, deploy dan jam-server uit de Spotube-repository en voer de URL in. + Jij + Server: %1$s + Stel in Instellingen een sessieserver in om een sessie te hosten of eraan deel te nemen. + Serverinstellingen + %1$s (jij) + Je naam + Anderen in de jam zien je als %1$s + Zichtbaar voor iedereen in de jam. Laat leeg om %1$s te gebruiken. Een nieuwe naam geldt vanaf de volgende keer dat je een sessie start of eraan deelneemt. + Gebruik maximaal %1$d tekens. + Geen jamserver ingesteld + Kan de jamserver niet bereiken. Controleer je netwerk of de server-URL in Instellingen → Group Jam. + De jamserver draait een incompatibele versie. + Geen sessie met die code. Controleer de code en probeer het opnieuw. + Deze sessie is vol. + De host heeft je uit deze sessie verbannen. + De host heeft je uit de sessie verwijderd. + Deze sessie is opnieuw geopend op dit apparaat, dus deze verbinding is gesloten. Naar album Slaaptimer diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index b749cd80..a196725e 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -336,6 +336,28 @@ Nie udało się dołączyć do sesji Jam Wpisz sześcioznakowy kod pokoju złożony z liter i cyfr. Dołącz do mojego Jamu w Spotube, kod: %1$s + Serwer pokojów + Adres URL serwera + Wpisz adres URL zaczynający się od https:// + Sprawdź serwer pokojów przed utworzeniem pokoju. + Jam korzysta z darmowego serwera pokojów Spotube. Aby hostować własny, wdróż jam-server z repozytorium Spotube i wpisz jego adres URL. + Ty + Serwer: %1$s + Ustaw serwer pokojów w Ustawieniach, aby utworzyć pokój lub do niego dołączyć. + Ustawienia serwera + %1$s (ty) + Twoja nazwa + Inni w jamie widzą cię jako %1$s + Widoczna dla wszystkich w jamie. Zostaw puste, aby użyć %1$s. Nowa nazwa zacznie obowiązywać przy następnym utworzeniu pokoju lub dołączeniu do niego. + Użyj maksymalnie %1$d znaków. + Nie skonfigurowano serwera jamu + Nie można połączyć się z serwerem jamu. Sprawdź sieć lub adres URL serwera w Ustawienia → Grupowy Jam. + Serwer jamu używa niezgodnej wersji. + Nie ma pokoju o tym kodzie. Sprawdź go i spróbuj ponownie. + Ten pokój jest pełny. + Gospodarz zablokował cię w tym pokoju. + Gospodarz usunął cię z pokoju. + Ten pokój został ponownie otwarty na tym urządzeniu, więc to połączenie zostało zamknięte. Przejdź do albumu Wyłącznik czasowy diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index c1fdef3b..76942052 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -332,6 +332,28 @@ Falha ao entrar na Jam Digite um código de sala com seis caracteres, usando letras e números. Entre na minha Jam do Spotube com o código: %1$s + Servidor de salas + URL do servidor + Digite uma URL que comece com https:// + Verifique o servidor de salas antes de criar uma sala. + O Jam usa o servidor de salas gratuito do Spotube. Para hospedar o seu, implante o jam-server do repositório do Spotube e informe a URL dele. + Você + Servidor: %1$s + Defina um servidor de salas nas Configurações para criar ou entrar em uma sala. + Configurações do servidor + %1$s (você) + Seu nome + Os outros no jam veem você como %1$s + Visível para todos no jam. Deixe em branco para usar %1$s. Um novo nome vale a partir da próxima vez que você criar ou entrar em uma sala. + Use no máximo %1$d caracteres. + Nenhum servidor de jam configurado + Não foi possível acessar o servidor de jam. Verifique sua rede ou a URL do servidor em Configurações → Jam em grupo. + O servidor de jam usa uma versão incompatível. + Nenhuma sala com esse código. Confira e tente de novo. + Esta sala está cheia. + O anfitrião baniu você desta sala. + O anfitrião removeu você da sala. + Esta sala foi aberta de novo neste dispositivo, então esta conexão foi encerrada. Ir para o álbum Timer diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index eaa76c30..3f89ffd8 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -336,6 +336,28 @@ Не удалось присоединиться к джему Введите код комнаты из шести букв и цифр. Присоединяйся к моему джему в Spotube по коду: %1$s + Сервер комнат + URL сервера + Введите URL, начинающийся с https:// + Проверьте сервер комнат перед созданием комнаты. + Джем использует бесплатный сервер комнат Spotube. Чтобы развернуть свой, установите jam-server из репозитория Spotube и укажите его URL. + Вы + Сервер: %1$s + Укажите сервер комнат в настройках, чтобы создать комнату или присоединиться к ней. + Настройки сервера + %1$s (вы) + Ваше имя + Другие участники джема видят вас как %1$s + Видно всем в джеме. Оставьте пустым, чтобы использовать %1$s. Новое имя применится, когда вы в следующий раз создадите комнату или присоединитесь к ней. + Не больше %1$d символов. + Сервер джема не настроен + Не удалось подключиться к серверу джема. Проверьте сеть или URL сервера в разделе Настройки → Групповой джем. + Сервер джема использует несовместимую версию. + Комнаты с таким кодом нет. Проверьте код и попробуйте снова. + Эта комната заполнена. + Организатор заблокировал вас в этой комнате. + Организатор удалил вас из комнаты. + Эта комната была снова открыта на этом устройстве, поэтому это подключение закрыто. Перейти к альбому Таймер сна diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index 56a198a2..db86245d 100644 --- a/composeApp/src/commonMain/composeResources/values-th/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -324,6 +324,28 @@ เข้าร่วมเซสชันแจมไม่สำเร็จ ใส่รหัสห้อง 6 ตัวอักษร ที่ประกอบด้วยตัวอักษรและตัวเลข มาร่วมแจมกับฉันบน Spotube ด้วยรหัส: %1$s + เซิร์ฟเวอร์ห้อง + URL ของเซิร์ฟเวอร์ + ใส่ URL ที่ขึ้นต้นด้วย https:// + ตรวจสอบเซิร์ฟเวอร์ห้องก่อนเริ่มห้อง + แจมใช้เซิร์ฟเวอร์ห้องฟรีของ Spotube หากต้องการโฮสต์เอง ให้ติดตั้ง jam-server จากรีโพสิทอรีของ Spotube แล้วใส่ URL ของเซิร์ฟเวอร์นั้น + คุณ + เซิร์ฟเวอร์: %1$s + ตั้งค่าเซิร์ฟเวอร์ห้องในการตั้งค่าเพื่อโฮสต์หรือเข้าร่วมห้อง + การตั้งค่าเซิร์ฟเวอร์ + %1$s (คุณ) + ชื่อของคุณ + คนอื่นในแจมจะเห็นคุณเป็น %1$s + ทุกคนในแจมจะเห็นชื่อนี้ เว้นว่างไว้เพื่อใช้ %1$s ชื่อใหม่จะมีผลเมื่อคุณสร้างหรือเข้าร่วมห้องครั้งถัดไป + ใช้ได้ไม่เกิน %1$d ตัวอักษร + ยังไม่ได้ตั้งค่าเซิร์ฟเวอร์แจม + เชื่อมต่อเซิร์ฟเวอร์แจมไม่ได้ ตรวจสอบเครือข่ายหรือ URL ของเซิร์ฟเวอร์ใน การตั้งค่า → แจมกลุ่ม + เซิร์ฟเวอร์แจมใช้เวอร์ชันที่เข้ากันไม่ได้ + ไม่มีห้องที่ใช้รหัสนี้ ตรวจสอบแล้วลองอีกครั้ง + ห้องนี้เต็มแล้ว + โฮสต์แบนคุณออกจากห้องนี้ + โฮสต์นำคุณออกจากห้อง + ห้องนี้ถูกเปิดอีกครั้งบนอุปกรณ์นี้ จึงปิดการเชื่อมต่อนี้ ไปที่อัลบั้ม ตั้งเวลาปิด diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 2f8c21e9..c151fd39 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -328,6 +328,28 @@ Jam oturumuna katılınamadı Harf ve rakamlardan oluşan altı karakterli bir oda kodu gir. Spotube Jam\'ime şu kodla katıl: %1$s + Oda sunucusu + Sunucu URL’si + https:// ile başlayan bir URL gir. + Oda başlatmadan önce oda sunucusunu kontrol et. + Jam, Spotube’un ücretsiz oda sunucusunu kullanır. Kendin barındırmak için Spotube deposundaki jam-server’ı kur ve URL’sini gir. + Sen + Sunucu: %1$s + Oda açmak veya bir odaya katılmak için Ayarlar’da bir oda sunucusu belirle. + Sunucu ayarları + %1$s (sen) + Adın + Jam’deki diğer kişiler seni %1$s olarak görür + Jam’deki herkese gösterilir. %1$s adını kullanmak için boş bırak. Yeni ad, bir sonraki oda açışında veya katılışında geçerli olur. + En fazla %1$d karakter kullan. + Jam sunucusu ayarlanmamış + Jam sunucusuna ulaşılamadı. Ağını veya Ayarlar → Grup Jam bölümündeki sunucu URL’sini kontrol et. + Jam sunucusu uyumsuz bir sürüm çalıştırıyor. + Bu koda sahip bir oda yok. Kodu kontrol edip tekrar dene. + Bu oda dolu. + Oda sahibi seni bu odadan engelledi. + Oda sahibi seni odadan çıkardı. + Bu oda bu cihazda yeniden açıldı, bu yüzden bu bağlantı kapatıldı. Albüme git Uyku zamanlayıcısı diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml index 9f87d27f..a2621789 100644 --- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -336,6 +336,28 @@ Не вдалося приєднатися до джему Введіть код кімнати з шести літер і цифр. Приєднуйся до мого джему в Spotube за кодом: %1$s + Сервер кімнат + URL сервера + Введіть URL, що починається з https:// + Перевірте сервер кімнат перед створенням кімнати. + Джем використовує безкоштовний сервер кімнат Spotube. Щоб розгорнути власний, встановіть jam-server із репозиторію Spotube і вкажіть його URL. + Ви + Сервер: %1$s + Укажіть сервер кімнат у налаштуваннях, щоб створити кімнату або приєднатися до неї. + Налаштування сервера + %1$s (ви) + Ваше ім’я + Інші учасники джему бачать вас як %1$s + Видно всім у джемі. Залиште порожнім, щоб використовувати %1$s. Нове ім’я застосується, коли ви наступного разу створите кімнату або приєднаєтеся до неї. + Не більше %1$d символів. + Сервер джему не налаштовано + Не вдалося підключитися до сервера джему. Перевірте мережу або URL сервера в розділі Налаштування → Груповий джем. + Сервер джему використовує несумісну версію. + Кімнати з таким кодом немає. Перевірте код і спробуйте ще раз. + Ця кімната заповнена. + Організатор заблокував вас у цій кімнаті. + Організатор видалив вас із кімнати. + Цю кімнату знову відкрито на цьому пристрої, тому це підключення закрито. Перейти до альбому Таймер сну diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 80a93679..f38ef955 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -324,6 +324,28 @@ Không thể tham gia phiên Jam Nhập mã phòng gồm sáu ký tự chữ và số. Tham gia Jam Spotube của mình với mã: %1$s + Máy chủ phòng + URL máy chủ + Nhập URL bắt đầu bằng https:// + Kiểm tra máy chủ phòng trước khi tạo phòng. + Jam dùng máy chủ phòng miễn phí của Spotube. Để tự lưu trữ, hãy triển khai jam-server từ kho mã Spotube rồi nhập URL của nó. + Bạn + Máy chủ: %1$s + Đặt máy chủ phòng trong Cài đặt để tạo hoặc tham gia phòng. + Cài đặt máy chủ + %1$s (bạn) + Tên của bạn + Mọi người trong jam thấy bạn là %1$s + Hiển thị cho mọi người trong jam. Để trống để dùng %1$s. Tên mới sẽ áp dụng từ lần tới bạn tạo hoặc tham gia phòng. + Dùng tối đa %1$d ký tự. + Chưa cấu hình máy chủ jam + Không kết nối được máy chủ jam. Hãy kiểm tra mạng hoặc URL máy chủ trong Cài đặt → Jam nhóm. + Máy chủ jam đang chạy phiên bản không tương thích. + Không có phòng nào với mã này. Hãy kiểm tra và thử lại. + Phòng này đã đầy. + Chủ phòng đã cấm bạn khỏi phòng này. + Chủ phòng đã xóa bạn khỏi phòng. + Phòng này đã được mở lại trên thiết bị này nên kết nối này đã bị đóng. Đi tới album Hẹn giờ tắt diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml index f3710bb5..98f81efd 100644 --- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -324,6 +324,28 @@ 無法加入 Jam 請輸入由英文字母和數字組成的六位房間代碼。 用代碼 %1$s 加入我的 Spotube Jam + 房間伺服器 + 伺服器網址 + 請輸入以 https:// 開頭的網址 + 建立房間前先檢查房間伺服器。 + Jam 使用 Spotube 的免費房間伺服器。若要自行架設,請部署 Spotube 儲存庫中的 jam-server 並輸入其網址。 + 你 + 伺服器:%1$s + 請在設定中指定房間伺服器,以建立或加入房間。 + 伺服器設定 + %1$s(你) + 你的名稱 + Jam 裡的其他人會看到你是 %1$s + Jam 裡的每個人都看得到。留空即使用 %1$s。新名稱會在你下次建立或加入房間時生效。 + 最多 %1$d 個字元。 + 尚未設定 Jam 伺服器 + 無法連線到 Jam 伺服器。請檢查網路,或 設定 → 群組 Jam 中的伺服器網址。 + Jam 伺服器的版本不相容。 + 沒有使用此代碼的房間。請檢查後再試一次。 + 此房間已滿。 + 主持人已將你從此房間封鎖。 + 主持人已將你移出房間。 + 此房間已在這部裝置上再次開啟,因此這個連線已關閉。 前往專輯 睡眠定時器 diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index 722ff10c..c4670129 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -324,6 +324,28 @@ 加入一起听会话失败 请输入由字母和数字组成的六位房间码。 用房间码加入我的 Spotube 一起听:%1$s + 房间服务器 + 服务器网址 + 请输入以 https:// 开头的网址 + 创建房间前先检查房间服务器。 + 一起听使用 Spotube 的免费房间服务器。如需自行托管,请部署 Spotube 仓库中的 jam-server 并填写其网址。 + 你 + 服务器:%1$s + 请在设置中指定房间服务器,以创建或加入房间。 + 服务器设置 + %1$s(你) + 你的名字 + 房间里的其他人看到你的名字是 %1$s + 房间里的所有人都能看到。留空则使用 %1$s。新名字将在你下次创建或加入房间时生效。 + 最多 %1$d 个字符。 + 未配置一起听服务器 + 无法连接到一起听服务器。请检查网络,或在 设置 → 一起听 中检查服务器网址。 + 一起听服务器的版本不兼容。 + 没有使用该代码的房间。请检查后重试。 + 该房间已满。 + 房主已将你从此房间封禁。 + 房主已将你移出房间。 + 此房间已在本设备上再次打开,因此此连接已关闭。 转到专辑 睡眠定时器 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 764bff82..e6c3b823 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -321,6 +321,7 @@ Failed: %1$s Connection Diagnostics + You Group Jam @@ -343,6 +344,7 @@ Participants (%1$d) Host Listener + %1$s (you) Waiting for listeners… Remove Ban @@ -354,6 +356,145 @@ Failed to join jam session Enter a six-character room code using letters and digits. Join my Spotube Jam with code: %1$s + Your name + Others in the jam see you as %1$s + Shown to everyone in the jam. Leave it empty to use %1$s. A new name applies the next time you start or join a room. + Use at most %1$d characters. + + %1$s %2$s + + + Brave + Bright + Calm + Cheerful + Chill + Clever + Cosmic + Cozy + Curious + Dapper + Dreamy + Eager + Electric + Fancy + Fearless + Fizzy + Fluffy + Funky + Gentle + Giddy + Glowing + Golden + Groovy + Happy + Humble + Jazzy + Jolly + Jumpy + Kind + Lively + Lucky + Lunar + Mellow + Merry + Mighty + Misty + Nimble + Peppy + Plucky + Polite + Quick + Quiet + Rapid + Rhythmic + Rosy + Silly + Sleepy + Smooth + Snappy + Soulful + Sparkly + Speedy + Spicy + Sunny + Swift + Tidy + Tiny + Vivid + Wavy + Whistling + Witty + Zany + Zesty + Zippy + + + Alpaca + Badger + Beaver + Bison + Bunny + Capybara + Cheetah + Chipmunk + Cobra + Corgi + Coyote + Crane + Dingo + Dolphin + Duckling + Eagle + Falcon + Ferret + Finch + Flamingo + Fox + Gazelle + Gecko + Giraffe + Hedgehog + Heron + Hippo + Ibis + Jaguar + Koala + Lemur + Llama + Lynx + Magpie + Manatee + Marmot + Meerkat + Mongoose + Moose + Narwhal + Ocelot + Octopus + Orca + Otter + Owl + Panda + Pangolin + Parrot + Pelican + Penguin + Puffin + Quokka + Raccoon + Raven + Robin + Seal + Sloth + Sparrow + Squirrel + Tapir + Tiger + Toucan + Walrus + Wombat + Go to album Sleep timer @@ -547,7 +688,7 @@ This room is full. The host banned you from this room. The host removed you from the room. - You joined this room from another device. + This room was opened again on this device, so this connection was closed. Connection failed Connection lost diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamDevice.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamDevice.kt new file mode 100644 index 00000000..ebef9b60 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamDevice.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import okio.ByteString.Companion.encodeUtf8 + +/** + * Stable traits of this device (model, OS version, locale, …) for jam bans. They never + * leave the device as they are: [jamDeviceDigest] hashes them with the room code, and the + * server hashes that again with the network, so a room only ever holds an opaque value. + */ +internal expect fun jamDeviceTraits(): List + +/** Per-room digest of [jamDeviceTraits]: unlinkable across rooms. */ +internal fun jamDeviceDigest(roomCode: String): String? = runCatching { + val traits = jamDeviceTraits().filter { it.isNotBlank() } + if (traits.isEmpty()) return null + "spotube-jam-device:$roomCode:${traits.joinToString("|")}".encodeUtf8().sha256().hex() +}.getOrNull() + +/** + * This install's participant secret for room [roomCode], derived from the install's + * [deviceKey] (random, kept in settings). The same install gets the same identity in a + * room every time, so a ban sticks across rejoins; different rooms get unrelated keys. + */ +internal fun jamParticipantKey(deviceKey: String, roomCode: String): String = + "spotube-jam:$roomCode".encodeUtf8().hmacSha256(deviceKey.encodeUtf8()).hex() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt new file mode 100644 index 00000000..023bd85c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import okio.ByteString.Companion.encodeUtf8 +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.getStringArray +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_name_adjectives +import spotube.composeapp.generated.resources.jam_name_animals +import spotube.composeapp.generated.resources.jam_name_format + +/** Friendly default names for jam participants ("Mellow Otter"), like npm or Docker use. */ +object JamNames { + /** The server's limit (`MAX_NAME_LENGTH` in `jam-server/src/protocol.ts`). */ + const val MAX_LENGTH = 40 + + /** + * The generated name for [seed]. The same seed always gets the same name, so with this + * install's key a device keeps its name across sessions until the user picks their own. + */ + suspend fun generate(seed: String): String { + val adjectives = getStringArray(Res.array.jam_name_adjectives) + val animals = getStringArray(Res.array.jam_name_animals) + val hash = "spotube-jam-name:$seed".encodeUtf8().sha256() + val adjective = adjectives[pick(hash[0], hash[1], adjectives.size)] + val animal = animals[pick(hash[2], hash[3], animals.size)] + return getString(Res.string.jam_name_format, adjective, animal).take(MAX_LENGTH) + } + + /** Cleans a name the user typed; blank means "use the generated one". */ + fun normalize(input: String): String = + input.filterNot { it.isISOControl() }.replace(Regex("\\s+"), " ").trim() + + private fun pick(high: Byte, low: Byte, size: Int): Int = + (((high.toInt() and 0xFF) shl 8) or (low.toInt() and 0xFF)) % size +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt index b01fe8e0..7744c5f7 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt @@ -99,10 +99,35 @@ data class JamMediaItem( val coverUrl: String, val protocol: String, val addedBy: String = "") { - /** Identifies the track across devices: the metadata id, or the file for local tracks. */ - val key: String get() = trackId.ifBlank { "local:$url" } + /** Identifies the track across devices: the metadata id, or an opaque id for local files. */ + val key: String get() = trackId.ifBlank { url } + + /** + * This item as received from another participant, made safe to load: streaming tracks + * only (a peer's local file can't play here, and a URL would make this device fetch + * whatever a peer chose), https covers only, and bounded text. Null if unusable. + */ + fun sanitized(): JamMediaItem? { + if (trackId.isBlank() || trackId.length > MAX_ID_LENGTH) return null + return copy( + url = "", + title = title.take(MAX_TEXT_LENGTH), + artist = artist.take(MAX_TEXT_LENGTH), + album = album.take(MAX_TEXT_LENGTH), + durationMs = durationMs.coerceAtLeast(0), + coverUrl = coverUrl.takeIf { it.startsWith("https://") && it.length <= MAX_URL_LENGTH }.orEmpty(), + protocol = protocol.take(MAX_ID_LENGTH), + addedBy = addedBy.take(MAX_ID_LENGTH)) + } companion object { + private const val MAX_ID_LENGTH = 200 + private const val MAX_TEXT_LENGTH = 300 + private const val MAX_URL_LENGTH = 2048 + + /** The cross-device key of a local file at [path], which doesn't reveal the path. */ + fun localTrackKey(path: String): String = "local:" + path.hashCode().toUInt().toString(16) + fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { is QueueEntry.StreamingTrack -> JamMediaItem( url = "", @@ -117,8 +142,9 @@ data class JamMediaItem( protocol = entry.protocol.name, addedBy = entry.addedBy) + // Only an opaque id: the path would show the host's folders (and user name) to everyone. is QueueEntry.LocalTrack -> JamMediaItem( - url = entry.url, + url = localTrackKey(entry.url), trackId = "", title = entry.name, artist = entry.artists.joinToString(", "), diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt index c204ede2..fd529abf 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt @@ -117,6 +117,13 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) { private var lastFrameAtMs = 0L /** The room's host as last reported by the server. */ private var hostId: String? = null + + /** + * Our public participant id in the current room, as the server derived it from our + * secret key. Null until the first welcome. + */ + var selfId: String? = null + private set private val pongs = MutableStateFlow(0) private val _isConnected = MutableStateFlow(false) @@ -144,16 +151,22 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) { * Opens room [code] on [serverUrl] and syncs the clock. Fails with [JamRoomException] * when the server refuses (code taken, room not found, banned, …). Once connected, * drops are retried in the background until [disconnect] or a refusal. + * + * [clientKey] is this participant's secret ([jamParticipantKey]): reconnecting with + * it makes us the same participant, so it never leaves this device except to the + * server. [deviceDigest] ([jamDeviceDigest]) lets a ban cover this device too. */ suspend fun connect( serverUrl: String, code: String, - clientId: String, + clientKey: String, displayName: String, - create: Boolean): Result { + create: Boolean, + deviceDigest: String? = null): Result { disconnect() clock.reset() - val welcome = runCatching { open(serverUrl, code, clientId, displayName, create) } + val opener = Opener(serverUrl, code, clientKey, displayName, deviceDigest) + val welcome = runCatching { open(opener, create) } .onFailure { e -> log.w(e) { "Failed to open jam room $code" } _connectionError.value = e.message ?: getString(Res.string.jam_error_connection_failed) @@ -176,11 +189,13 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) { while (isActive) { delay(RECONNECT_BACKOFF_MS[attempt.coerceAtMost(RECONNECT_BACKOFF_MS.lastIndex)]) attempt++ - var reopened = runCatching { open(serverUrl, code, clientId, displayName, create = false) } + var reopened = runCatching { open(opener, create = false) } // Everyone was gone long enough for the room to expire: its host brings it back. - if ((reopened.exceptionOrNull() as? JamRoomException)?.code == "room_not_found" && hostId == clientId) { + if ((reopened.exceptionOrNull() as? JamRoomException)?.code == "room_not_found" && + hostId != null && hostId == selfId + ) { log.i { "Jam room $code expired while offline; recreating it" } - reopened = runCatching { open(serverUrl, code, clientId, displayName, create = true) } + reopened = runCatching { open(opener, create = true) } } val error = reopened.exceptionOrNull() if (error is JamRoomException) { @@ -210,6 +225,7 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) { connectionJob?.cancel() connectionJob = null hostId = null + selfId = null reader?.cancel() reader = null val current = session @@ -225,13 +241,17 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) { * Connects, waits for the welcome (or refusal) and syncs the clock. Returns the welcome * and the socket's reader, which completes with the server's refusal (if any) on close. */ - private suspend fun open( - serverUrl: String, - code: String, - clientId: String, - displayName: String, - create: Boolean): Pair> { - val socket = runCatching { http.webSocketSession(roomUrl(serverUrl, code, clientId, displayName, create)) } + /** Everything needed to (re)open one room connection. */ + private data class Opener( + val serverUrl: String, + val code: String, + val clientKey: String, + val displayName: String, + val deviceDigest: String?) + + private suspend fun open(opener: Opener, create: Boolean): Pair> { + val code = opener.code + val socket = runCatching { http.webSocketSession(roomUrl(opener, create)) } .getOrElse { throw IllegalStateException(getString(Res.string.jam_error_server_unreachable), it) } try { val welcome = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { awaitWelcome(socket) } @@ -241,6 +261,7 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) { } session = socket + selfId = welcome.you hostId = welcome.host lastFrameAtMs = now() _isConnected.value = true @@ -249,7 +270,7 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) { val socketReader = scope.async { pump(socket) } reader = socketReader syncClock(socket) - log.i { "Joined jam room $code as $clientId (host=${welcome.host}, rtt=${clock.rttMs}ms)" } + log.i { "Joined jam room $code as ${welcome.you} (host=${welcome.host}, rtt=${clock.rttMs}ms)" } return welcome.toEvent() to socketReader } catch (e: Throwable) { runCatching { socket.close() } @@ -351,7 +372,8 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) { is ServerFrame.Queue -> _events.emit(JamRoomEvent.Queue(decoded.body)) is ServerFrame.Anchor -> _events.emit(JamRoomEvent.Anchor(decoded.body)) is ServerFrame.Cmd -> _events.emit(JamRoomEvent.Command(decoded.from, decoded.body)) - is ServerFrame.Error -> return decoded.code + // Sent too much too fast (a burst of skips, say): not a refusal, just reconnect. + is ServerFrame.Error -> return decoded.code.takeIf { it != "rate_limited" } null -> Unit } return null @@ -395,13 +417,14 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) { else -> getString(Res.string.jam_error_connection_failed) } - private fun roomUrl(serverUrl: String, code: String, clientId: String, displayName: String, create: Boolean) = - URLBuilder(serverUrl.trim()).apply { + private fun roomUrl(opener: Opener, create: Boolean) = + URLBuilder(opener.serverUrl.trim()).apply { protocol = if (protocol == URLProtocol.HTTP || protocol == URLProtocol.WS) URLProtocol.WS else URLProtocol.WSS - appendPathSegments("v1", "rooms", code) - parameters.append("id", clientId) - parameters.append("name", displayName) + appendPathSegments("v1", "rooms", opener.code) + parameters.append("key", opener.clientKey) + parameters.append("name", opener.displayName) parameters.append("mode", if (create) "create" else "join") + opener.deviceDigest?.let { parameters.append("dev", it) } }.buildString() private fun healthUrl(serverUrl: String) = diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt index e6ec412b..3846e813 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt @@ -17,7 +17,8 @@ package org.spotube.spotube.core.jam -import kotlin.random.Random +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * Six-character room codes shared verbally / by text. Codes are opaque keys used @@ -30,12 +31,23 @@ object JamRoomCode { const val LENGTH = 6 private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" - fun generate(): String = buildString(LENGTH) { - repeat(LENGTH) { - append(ALPHABET[Random.nextInt(ALPHABET.length)]) + /** From a cryptographically secure source: a predictable code would let others in. */ + @OptIn(ExperimentalUuidApi::class) + fun generate(): String { + val bytes = Uuid.random().toByteArray() + // 32 symbols = 5 bits each; the low 5 bits of a random byte are uniform. + return buildString(LENGTH) { + repeat(LENGTH) { append(ALPHABET[bytes[it].toInt() and 31]) } } } + /** + * A participant's secret for one session, from a cryptographically secure source. + * The server derives the public participant id from it; only this device knows it. + */ + @OptIn(ExperimentalUuidApi::class) + fun newClientKey(): String = Uuid.random().toHexString() + Uuid.random().toHexString() + /** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */ fun normalize(input: String): String = input .uppercase() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt index abeb2ff4..252db77f 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt @@ -35,7 +35,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.merge @@ -55,7 +57,6 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeoutOrNull import kotlin.math.abs -import kotlin.random.Random import org.jetbrains.compose.resources.getString import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.jam_error_invalid_code @@ -129,7 +130,6 @@ class JamRoomService( private val _shuffleEnabled = MutableStateFlow(false) val shuffleEnabled: StateFlow = _shuffleEnabled.asStateFlow() - private var localClientId: String = "" private var localDisplayName: String = "" /** Display name of the local participant. */ @@ -138,7 +138,7 @@ class JamRoomService( /** Stable id of the local participant (stamped on items this device adds). */ val participantClientId: String - get() = localClientId + get() = jamClient.selfId.orEmpty() private var roleJob: Job? = null private var leaving = false @@ -175,6 +175,8 @@ class JamRoomService( private var ownSeekUntilMs = 0L private var entryChangedAtMs = 0L + private val anchorRequests = Channel(Channel.CONFLATED) + // The host's last anchor, to notice its own player drifting away from it. private var sentAnchor: JamAnchor? = null private var sentAnchorAtMs = 0L @@ -222,8 +224,8 @@ class JamRoomService( suspend fun createRoom(): Result { val serverUrl = serverUrl() ?: return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_server))) - localClientId = newClientId() - localDisplayName = participantName("Host") + val deviceKey = deviceKey() + localDisplayName = participantName(deviceKey) enter() // A fresh code is almost never taken; retry the rare collision with another one. @@ -232,7 +234,9 @@ class JamRoomService( var attempts = 0 do { code = JamRoomCode.generate() - result = jamClient.connect(serverUrl, code, localClientId, localDisplayName, create = true) + result = jamClient.connect( + serverUrl, code, jamParticipantKey(deviceKey, code), localDisplayName, create = true, + deviceDigest = jamDeviceDigest(code)) val taken = (result.exceptionOrNull() as? JamRoomException)?.code == "room_taken" } while (taken && ++attempts < CREATE_ATTEMPTS) @@ -250,11 +254,13 @@ class JamRoomService( if (!JamRoomCode.isValid(normalized)) { return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_invalid_code))) } - localClientId = newClientId() - localDisplayName = participantName("Guest") + val deviceKey = deviceKey() + localDisplayName = participantName(deviceKey) enter() - val welcome = jamClient.connect(serverUrl, normalized, localClientId, localDisplayName, create = false) + val welcome = jamClient.connect( + serverUrl, normalized, jamParticipantKey(deviceKey, normalized), localDisplayName, create = false, + deviceDigest = jamDeviceDigest(normalized)) .getOrElse { inRoom = false return Result.failure(it) @@ -322,12 +328,12 @@ class JamRoomService( } suspend fun suggestTrack(track: MetadataTrack) { - jamClient.sendCommand(JamCommand.Suggest(listOf(JamMediaItem.fromTrack(track)), addedBy = localClientId)) + jamClient.sendCommand(JamCommand.Suggest(listOf(JamMediaItem.fromTrack(track)), addedBy = participantClientId)) } suspend fun suggestPlaylist(tracks: List) { if (tracks.isEmpty()) return - jamClient.sendCommand(JamCommand.Suggest(tracks.map(JamMediaItem::fromTrack), addedBy = localClientId)) + jamClient.sendCommand(JamCommand.Suggest(tracks.map(JamMediaItem::fromTrack), addedBy = participantClientId)) } suspend fun kickParticipant(participantId: String) { @@ -343,7 +349,7 @@ class JamRoomService( /** The host applies its own controls; a guest's go to the host. */ private fun control(command: JamCommand) { scope.launch { - if (_role.value == JamRole.Host) applyCommand(command) else jamClient.sendCommand(command) + if (_role.value == JamRole.Host) applyCommand(command, from = participantClientId) else jamClient.sendCommand(command) } } @@ -367,7 +373,8 @@ class JamRoomService( anchor = event.anchor if (_role.value == JamRole.Guest) onAnchor(event.anchor) } - is JamRoomEvent.Command -> if (_role.value == JamRole.Host) applyCommand(event.command) + // [JamRoomEvent.Command.from] is the server's word for who sent it; the body is the sender's. + is JamRoomEvent.Command -> if (_role.value == JamRole.Host) applyCommand(event.command, event.from) is JamRoomEvent.Closed -> { log.i { "Left jam room: ${event.code}" } reset() @@ -385,7 +392,7 @@ class JamRoomService( private fun onMembership(host: String?, participants: List, force: Boolean = false) { _participants.value = participants - val role = if (host == localClientId) JamRole.Host else JamRole.Guest + val role = if (host != null && host == jamClient.selfId) JamRole.Host else JamRole.Guest // A (re)welcome restarts the role so a guest re-applies the room's state. if (role == _role.value && !force) return log.i { "Jam role: ${_role.value} -> $role" } @@ -406,16 +413,28 @@ class JamRoomService( JamQueue(queue.map(JamMediaItem::fromQueueEntry), shuffle) } .distinctUntilChanged() + // At most one upload a second (the server's limit), always the latest queue. + .conflate() .onEach { queue -> _shuffleEnabled.value = queue.shuffleEnabled // After a reconnect the server already has it: skip the re-upload. if (queue != roomQueue) { roomQueue = queue jamClient.sendQueue(queue) + delay(QUEUE_MIN_GAP_MS) } } .launchIn(this) + // Anchors are built when sent, so coalescing a burst of requests (rapid skips) + // loses nothing; it keeps the host under the server's frame budget. + launch { + for (request in anchorRequests) { + sendAnchor() + delay(ANCHOR_MIN_GAP_MS) + } + } + combine( audioPlayerQueue.currentQueueEntryFlow, playIntent, @@ -464,7 +483,12 @@ class JamRoomService( } } - private suspend fun publishAnchor() { + /** Asks the host loop to send a fresh anchor (coalesced, see [runHost]). */ + private fun publishAnchor() { + anchorRequests.trySend(Unit) + } + + private suspend fun sendAnchor() { if (_role.value != JamRole.Host) return val current = audioPlayerQueue.currentQueueEntryFlow.value ?: return val playing = playIntent.value @@ -491,7 +515,7 @@ class JamRoomService( ?.let { PluginTaggedAudioSource(plugin.pluginId, it) } } - private suspend fun applyCommand(command: JamCommand) { + private suspend fun applyCommand(command: JamCommand, from: String) { runCatching { when (command) { // Applied locally; the anchor carries the change to every guest. @@ -503,15 +527,21 @@ class JamRoomService( seek(command.positionMs) publishAnchor() } - is JamCommand.Suggest -> acceptSuggestion(command.items.map { it.copy(addedBy = command.addedBy) }) + is JamCommand.Suggest -> acceptSuggestion(command.items, from) } }.onFailure { log.w(it) { "Failed to apply jam command $command" } } } - private suspend fun acceptSuggestion(items: List) { - if (items.isEmpty()) return - log.i { "Accepting ${items.size} suggested item(s) into the jam queue" } - audioPlayerQueue.addAllToQueue(items.map { it.toQueueEntry() }) + /** Suggestions come from other devices: only clean streaming items, credited to the actual sender. */ + private suspend fun acceptSuggestion(items: List, from: String) { + val accepted = items.asSequence() + .mapNotNull { it.sanitized() } + .take(MAX_SUGGESTED_ITEMS) + .map { it.copy(addedBy = from) } + .toList() + if (accepted.isEmpty()) return + log.i { "Accepting ${accepted.size} suggested item(s) into the jam queue" } + audioPlayerQueue.addAllToQueue(accepted.map { it.toQueueEntry() }) } // ---------- Guest ---------- @@ -575,7 +605,8 @@ class JamRoomService( private suspend fun applyQueue(queue: JamQueue) { roomVersion.value++ _shuffleEnabled.value = queue.shuffleEnabled - val items = queue.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } + // The host's local files can't play here, and anything else is cleaned first. + val items = queue.items.mapNotNull { it.sanitized() } if (items == lastAppliedItems) return val previous = lastAppliedItems lastAppliedItems = items @@ -747,7 +778,7 @@ class JamRoomService( // The user seeked: it applies to the whole room. when (_role.value) { - JamRole.Host -> scope.launch { publishAnchor() } + JamRole.Host -> publishAnchor() JamRole.Guest -> { holdSync(SEEK_HOLD_MS * 2) jamClient.sendCommand(JamCommand.SeekTo(positionMs)) @@ -761,15 +792,29 @@ class JamRoomService( private fun serverUrl(): String? = settingsRepository.userSettings.value.jamServerUrl.trim().ifBlank { null } - private fun participantName(fallbackPrefix: String): String { - val configured = settingsRepository.userSettings.value.jamParticipantName - return configured.ifBlank { "$fallbackPrefix-${Random.nextInt(1000, 9999)}" } + private suspend fun participantName(deviceKey: String): String = + JamNames.normalize(settingsRepository.userSettings.value.jamParticipantName) + .take(JamNames.MAX_LENGTH) + .ifBlank { JamNames.generate(deviceKey) } + + /** The name this device gets when the user hasn't picked one; the same every time. */ + suspend fun defaultParticipantName(): String = JamNames.generate(deviceKey()) + + /** Saves the user's own name; blank goes back to [defaultParticipantName]. */ + suspend fun setParticipantName(name: String) { + val settings = settingsRepository.userSettings.value + settingsRepository.updateSettings( + settings.copy(jamParticipantName = JamNames.normalize(name).take(JamNames.MAX_LENGTH))) } - private fun newClientId(): String = buildString { - append("sp-") - val chars = "0123456789abcdef" - repeat(12) { append(chars[Random.nextInt(chars.length)]) } + /** This install's jam secret, created on first use. */ + private suspend fun deviceKey(): String { + val settings = settingsRepository.userSettings.value + if (settings.jamDeviceKey.isNotBlank()) return settings.jamDeviceKey + val generated = JamRoomCode.newClientKey() + runCatching { settingsRepository.updateSettings(settings.copy(jamDeviceKey = generated)) } + .onFailure { log.w(it) { "Failed to save the jam device key" } } + return generated } private fun persistLastCode(code: String) { @@ -785,7 +830,7 @@ class JamRoomService( private fun QueueEntry.jamKey(): String = when (this) { is QueueEntry.StreamingTrack -> track.id - is QueueEntry.LocalTrack -> "local:$url" + is QueueEntry.LocalTrack -> JamMediaItem.localTrackKey(url) } private fun JamMediaItem.toQueueEntry(): QueueEntry = when { @@ -827,6 +872,10 @@ class JamRoomService( companion object { private const val CREATE_ATTEMPTS = 3 + /** Keeps the host under the server's budgets (see `jam-server/src/protocol.ts`). */ + private const val QUEUE_MIN_GAP_MS = 1_000L + private const val ANCHOR_MIN_GAP_MS = 250L + private const val MAX_SUGGESTED_ITEMS = 500 private const val HOST_DRIFT_CHECK_INTERVAL_MS = 2_000L private const val HOST_DRIFT_WINDOW = 5 /** Below the guests' dead band, so the host's own error never adds up to a correction. */ diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt index d9964f27..9c827d29 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt @@ -62,6 +62,7 @@ import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes import org.spotube.spotube.core.ui.component.ApplicationMainBar import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.settings.sections.JamNameSettingCard import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.IconsaxSetting2 @@ -138,6 +139,15 @@ fun JamScreen(navigationCommands: NavigationCommands) { viewModel::createSession, viewModel::joinSession ) + // Pick a name before starting or joining, without a detour through Settings. + JamPanel { + JamNameSettingCard( + customName = state.customName, + defaultName = state.defaultName, + onNameSaved = viewModel::setParticipantName, + enabled = !state.isBusy + ) + } } ServerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) } } @@ -412,7 +422,11 @@ internal fun SessionPanel( } Column(Modifier.weight(1f)) { Text( - participant.displayName, + if (participant.isYou) { + stringResource(Res.string.jam_participants_you, participant.displayName) + } else { + participant.displayName + }, style = AppTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt index 42fc524a..97799ccb 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt @@ -48,6 +48,7 @@ data class JamParticipantUi( val initial: String, val isHost: Boolean, val canModerate: Boolean, + val isYou: Boolean = false, val menuExpanded: Boolean = false ) @@ -68,7 +69,11 @@ data class JamUiState( val isLeaving: Boolean = false, val isHost: Boolean = false, val supportsNativeShare: Boolean = false, - val codeCopied: Boolean = false + val codeCopied: Boolean = false, + /** The name the user picked (blank when they haven't). */ + val customName: String = "", + /** This device's generated name, used when [customName] is blank. */ + val defaultName: String = "" ) class JamViewModel( @@ -85,6 +90,7 @@ class JamViewModel( private val codeInput = TextFieldState() private var copyFeedbackJob: Job? = null private val participantMenu = MutableStateFlow(null) + private val defaultName = MutableStateFlow("") val supportsNativeShare: Boolean = getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS @@ -102,7 +108,8 @@ class JamViewModel( codeCopied, snapshotFlow { codeInput.text.toString() }, participantMenu, - isLeaving + isLeaving, + defaultName ) { values -> @Suppress("UNCHECKED_CAST") val role = values[0] as JamRole? @@ -128,6 +135,7 @@ class JamViewModel( initial = participant.displayName.firstOrNull()?.uppercase() ?: "?", isHost = participant.isHost, canModerate = role == JamRole.Host && !participant.isHost, + isYou = participant.id == jamRoomService.participantClientId, menuExpanded = values[11] == participant.id ) }, @@ -145,7 +153,9 @@ class JamViewModel( canJoin = serverConfigured && JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)), isHost = role == JamRole.Host, - supportsNativeShare = supportsNativeShare + supportsNativeShare = supportsNativeShare, + customName = settings?.jamParticipantName.orEmpty(), + defaultName = values[13] as String ) }.stateIn( viewModelScope, @@ -153,6 +163,14 @@ class JamViewModel( JamUiState(codeInput = codeInput) ) + init { + viewModelScope.launch { defaultName.value = jamRoomService.defaultParticipantName() } + } + + fun setParticipantName(name: String) { + viewModelScope.launch { jamRoomService.setParticipantName(name) } + } + // ---------- Session lifecycle ---------- fun createSession() { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt index 5ac5953d..295b7536 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.spotube.spotube.core.ui.component.ApplicationMainBar import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.modules.settings.sections.JamNameSettingCard import org.spotube.spotube.modules.settings.sections.SettingsItemIcon import org.spotube.spotube.modules.settings.sections.settingsSectionCard import org.spotube.spotube.modules.settings.sections.settingsSectionHeader @@ -33,6 +34,7 @@ import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.jam_error_connection_failed import spotube.composeapp.generated.resources.settings_jam_section_connection import spotube.composeapp.generated.resources.settings_jam_section_diagnostics +import spotube.composeapp.generated.resources.settings_jam_section_you import spotube.composeapp.generated.resources.settings_jam_server_diagnostics_subtitle import spotube.composeapp.generated.resources.settings_jam_server_note import spotube.composeapp.generated.resources.settings_jam_server_test @@ -77,6 +79,15 @@ internal fun JamSettingsContent( .widthIn(max = 1280.dp) .align(Alignment.TopCenter), contentPadding = contentPadding) { + settingsSectionHeader(Res.string.settings_jam_section_you) + settingsSectionCard( + items = listOf( + { + JamNameSettingCard( + customName = uiState.settings.jamParticipantName, + defaultName = uiState.defaultName, + onNameSaved = viewModel::setParticipantName) + })) settingsSectionHeader(Res.string.settings_jam_section_connection) settingsSectionCard( items = listOf( diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt index 2cfc3ad7..ea58bd38 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt @@ -3,6 +3,7 @@ package org.spotube.spotube.modules.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.spotube.spotube.core.jam.JamRoomClient +import org.spotube.spotube.core.jam.JamRoomService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -12,15 +13,22 @@ import kotlinx.coroutines.launch data class JamSettingsUiState( val settings: UserSettings = UserSettings(), val isTesting: Boolean = false, - val testResult: Result? = null) + val testResult: Result? = null, + /** This device's generated jam name, used while the user hasn't picked one. */ + val defaultName: String = "") class JamSettingsViewModel( private val settingsRepository: SettingsRepository, - private val jamRoomClient: JamRoomClient) : ViewModel() { + private val jamRoomClient: JamRoomClient, + private val jamRoomService: JamRoomService) : ViewModel() { private val _uiState = MutableStateFlow(JamSettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { + viewModelScope.launch { + val name = jamRoomService.defaultParticipantName() + _uiState.update { it.copy(defaultName = name) } + } viewModelScope.launch { settingsRepository.userSettings.collect { settings -> _uiState.update { it.copy(settings = settings) } @@ -28,6 +36,10 @@ class JamSettingsViewModel( } } + fun setParticipantName(name: String) { + viewModelScope.launch { jamRoomService.setParticipantName(name) } + } + /** A blank URL goes back to the public server. */ fun setServerUrl(url: String) { viewModelScope.launch { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index 8b7aca13..5b93e6e7 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -64,6 +64,8 @@ data class UserSettings( /** Room server (`jam-server/`); self-hosters point this at their own deployment. */ val jamServerUrl: String = DEFAULT_JAM_SERVER_URL, val lastJamCode: String = "", + /** Random per-install secret behind this device's jam identities (see `jamParticipantKey`). */ + val jamDeviceKey: String = "", // Downloads val overloadedDownloadFolder: String? = null, // When null, uses default music folder diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt new file mode 100644 index 00000000..843c3402 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.stringResource +import org.spotube.spotube.core.jam.JamNames +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_name_description +import spotube.composeapp.generated.resources.jam_name_subtitle +import spotube.composeapp.generated.resources.jam_name_title +import spotube.composeapp.generated.resources.jam_name_too_long + +/** + * "Your name" card for Group Jam. Shared by the Jam screen and Jam settings so both stay + * identical, like the remote control device name card. + * + * [customName] is what the user saved (blank = none), [defaultName] the generated one. + */ +@Composable +internal fun JamNameSettingCard( + customName: String, + defaultName: String, + onNameSaved: (String) -> Unit, + enabled: Boolean = true +) { + val tooLong = stringResource(Res.string.jam_name_too_long, JamNames.MAX_LENGTH) + TextInputSettingCard( + title = stringResource(Res.string.jam_name_title), + subtitle = stringResource(Res.string.jam_name_subtitle, customName.ifBlank { defaultName }), + icon = { SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.jam_name_title)) }, + value = customName, + dialogDescription = stringResource(Res.string.jam_name_description, defaultName), + placeholder = defaultName, + normalize = JamNames::normalize, + validate = { value -> if (value.length > JamNames.MAX_LENGTH) tooLong else null }, + onValueSaved = onNameSaved, + enabled = enabled + ) +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt new file mode 100644 index 00000000..ab3b9691 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class JamNamesTest { + @Test + fun sameSeedSameName() = runTest { + assertEquals(JamNames.generate("device-a"), JamNames.generate("device-a")) + } + + @Test + fun namesAreTwoFriendlyWordsAndVaried() = runTest { + val names = List(60) { JamNames.generate("device-$it") } + assertTrue(names.all { Regex("^[A-Z][a-z]+ [A-Z][a-z]+$").matches(it) }, names.toString()) + assertTrue(names.all { it.length <= JamNames.MAX_LENGTH }) + assertTrue(names.toSet().size >= 50, "only ${names.toSet().size} distinct names") + } + + @Test + fun typedNamesAreCleaned() { + assertEquals("Kingkor Roy", JamNames.normalize(" Kingkor \u0000 \t Roy \n")) + assertEquals("", JamNames.normalize(" ")) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt new file mode 100644 index 00000000..bc8cfcbb --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class JamSecurityTest { + private fun item(trackId: String = "t1", url: String = "", coverUrl: String = "https://i.example/c.jpg") = + JamMediaItem(url = url, trackId = trackId, title = "Song", artist = "A", album = "B", + durationMs = 1_000, coverUrl = coverUrl, protocol = "PROGRESSIVE") + + @Test + fun peerItemsWithoutATrackIdAreDropped() { + assertNull(item(trackId = "", url = "http://attacker.example/track.mp3").sanitized()) + assertNull(item(trackId = "", url = "file:///etc/passwd").sanitized()) + } + + @Test + fun peerItemsLoseUrlsAndNonHttpsCovers() { + val clean = item(url = "http://attacker.example/x", coverUrl = "http://attacker.example/pixel.png").sanitized()!! + assertEquals("", clean.url) + assertEquals("", clean.coverUrl) + assertEquals("https://i.example/c.jpg", item().sanitized()!!.coverUrl) + } + + @Test + fun peerTextIsBounded() { + val clean = item().copy(title = "x".repeat(10_000), durationMs = -5).sanitized()!! + assertTrue(clean.title.length <= 300) + assertEquals(0, clean.durationMs) + } + + @Test + fun localTrackKeysDontRevealPaths() { + val key = JamMediaItem.localTrackKey("/home/alice/Music/song.mp3") + assertFalse("alice" in key || "Music" in key) + assertEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/song.mp3")) + assertNotEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/other.mp3")) + } + + @Test + fun clientKeysAreLongRandomAndAcceptedByTheServer() { + val keys = List(100) { JamRoomCode.newClientKey() } + assertEquals(keys.size, keys.toSet().size) + // Mirrors CLIENT_KEY in jam-server/src/protocol.ts. + val serverPattern = Regex("^[A-Za-z0-9_-]{22,64}$") + assertTrue(keys.all { serverPattern.matches(it) }) + } + + @Test + fun participantKeysAreStablePerRoomAndUnlinkableAcrossRooms() { + val device = JamRoomCode.newClientKey() + assertEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(device, "ABCDEF")) + assertNotEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(device, "ABCDEG")) + assertNotEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(JamRoomCode.newClientKey(), "ABCDEF")) + assertTrue(Regex("^[A-Za-z0-9_-]{22,64}$").matches(jamParticipantKey(device, "ABCDEF"))) + } + + @Test + fun deviceDigestsRevealNothingAndDifferPerRoom() { + val digest = jamDeviceDigest("ABCDEF")!! + assertEquals(digest, jamDeviceDigest("ABCDEF")) + assertNotEquals(digest, jamDeviceDigest("ABCDEG")) + assertTrue(jamDeviceTraits().none { trait -> trait.length > 3 && trait in digest }) + // Mirrors DEVICE_DIGEST in jam-server/src/protocol.ts. + assertTrue(Regex("^[A-Za-z0-9_-]{16,64}$").matches(digest)) + } + + @Test + fun roomCodesAreValid() { + repeat(200) { assertTrue(JamRoomCode.isValid(JamRoomCode.generate())) } + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt new file mode 100644 index 00000000..ff45cbf5 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import platform.Foundation.NSLocale +import platform.Foundation.NSTimeZone +import platform.Foundation.currentLocale +import platform.Foundation.localTimeZone +import platform.Foundation.localeIdentifier +import platform.UIKit.UIDevice + +internal actual fun jamDeviceTraits(): List { + val device = UIDevice.currentDevice + return listOf( + "ios", + device.model, + device.systemName(), + device.systemVersion, + NSLocale.currentLocale.localeIdentifier, + NSTimeZone.localTimeZone.name) +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt new file mode 100644 index 00000000..c37e8a47 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import java.util.Locale +import java.util.TimeZone + +internal actual fun jamDeviceTraits(): List = listOf( + "desktop", + System.getProperty("os.name").orEmpty(), + System.getProperty("os.version").orEmpty(), + System.getProperty("os.arch").orEmpty(), + Runtime.getRuntime().availableProcessors().toString(), + Locale.getDefault().toLanguageTag(), + TimeZone.getDefault().id) diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt index dcc7ed86..90bdce47 100644 --- a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt @@ -46,8 +46,9 @@ class JamRoomClientIntegrationTest { try { host.testConnection(url).getOrThrow() - val hostWelcome = host.connect(url, code, "it-host-1", "Host", create = true).getOrThrow() - assertEquals("it-host-1", hostWelcome.host) + val hostWelcome = host.connect(url, code, JamRoomCode.newClientKey(), "Host", create = true).getOrThrow() + val hostId = hostWelcome.you + assertEquals(hostId, hostWelcome.host) // Same machine: the clock offset is just measurement noise. assertTrue(abs(host.clock.offsetMs!!) < 50, "offset ${host.clock.offsetMs}") @@ -59,22 +60,24 @@ class JamRoomClientIntegrationTest { Thread.sleep(300) // Late joiners get the retained queue and anchor in the welcome. - val guestWelcome = guest.connect(url, code, "it-guest-1", "Guest", create = false).getOrThrow() + val guestKey = JamRoomCode.newClientKey() + val guestWelcome = guest.connect(url, code, guestKey, "Guest", create = false).getOrThrow() + val guestId = guestWelcome.you assertEquals(queue, guestWelcome.queue) assertEquals(anchor, guestWelcome.anchor) - assertEquals(listOf("it-host-1", "it-guest-1"), guestWelcome.participants.map { it.id }) + assertEquals(listOf(hostId, guestId), guestWelcome.participants.map { it.id }) val received = async(start = CoroutineStart.UNDISPATCHED) { host.events.filterIsInstance().first() } guest.sendCommand(JamCommand.SeekTo(42_000)) val command = withTimeout(5_000) { received.await() } - assertEquals("it-guest-1", command.from) + assertEquals(guestId, command.from) assertEquals(JamCommand.SeekTo(42_000), command.command) val closed = async(start = CoroutineStart.UNDISPATCHED) { guest.events.filterIsInstance().first() } - host.kick("it-guest-1", ban = false) + host.kick(guestId, ban = false) assertEquals("kicked", withTimeout(5_000) { closed.await() }.code) - val wrongRoom = guest.connect(url, JamRoomCode.generate(), "it-guest-1", "Guest", create = false) + val wrongRoom = guest.connect(url, JamRoomCode.generate(), guestKey, "Guest", create = false) assertEquals("room_not_found", (wrongRoom.exceptionOrNull() as JamRoomException).code) } finally { host.disconnect() diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt index 60c1a888..88c33db8 100644 --- a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt @@ -130,10 +130,17 @@ class JamSessionSimulationTest { assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value) assertInSync(host, guest, "after the skip") + // Mashing skip stays within the server's flood limits (the host isn't cut off). + repeat(20) { if (it % 2 == 0) host.service.skipNext() else host.service.skipPrevious() } + delay(6_000) + assertEquals(true, host.service.isConnected.value, "host still connected after mashing skip") + assertEquals(host.player.index.value, guest.player.index.value, "guest followed the skips") + assertInSync(host, guest, "after mashing skip") + // Steady playback costs (almost) nothing: count the anchors a listener receives. val listener = JamRoomClient(http) try { - listener.connect(url, code, "it-listener", "Listener", create = false).getOrThrow() + listener.connect(url, code, JamRoomCode.newClientKey(), "Listener", create = false).getOrThrow() delay(1_000) // The host answers the join with an anchor. var anchors = 0 val counting = launch { diff --git a/jam-server/README.md b/jam-server/README.md index c6f7b5e0..cf7c020d 100644 --- a/jam-server/README.md +++ b/jam-server/README.md @@ -35,6 +35,51 @@ A four-person room sends roughly 200 messages an hour (about 10 billed requests) The wire protocol is in [`src/protocol.ts`](src/protocol.ts). The app side is `composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/`. +## Security + +Anyone who knows a room code can join it, as with any party-code app. Everything else +is enforced here, not in the app: + +- **Identity**: each install keeps a random secret, and its key for a room is derived + from that secret and the room code. Everyone else only sees an id derived from the key + (`participantId`, different in every room). Knowing someone's id, the host's included, + doesn't let you connect as them, so nobody can take over hosting, kick people or replace + someone's connection. +- **Host powers**: only the current host can set the queue and anchor or kick and ban. + Guests can only send commands, and those go to the host, which decides what to apply. +- **Taking over rooms**: "create" only works on a fresh code, or for the room's own host + bringing back a room that expired while everyone was offline. +- **Abuse and cost**: connects are rate-limited per IP (60 a minute; also slows down code + guessing). Each socket has a message budget and a queue budget (`FRAME_BUDGET`, + `QUEUE_BUDGET`), and a client that floods is disconnected with `rate_limited`. + Frames over 512 KB are refused, and names are cleaned of control characters. +- **What the app does with peer data**: queue items and suggestions from other devices + are cleaned before use (`JamMediaItem.sanitized`): streaming tracks only, no URLs, only + https covers. The host credits suggestions to the sender the server reports, not to + whatever name the sender claims. The host's local files go out as an opaque id, never + as a path. +- **Privacy**: participants never see each other's IP addresses, since everything goes + through the room. The room keeps no history: the queue and bans are deleted 10 minutes + after the last person leaves. + +Known limits: + +- **Bans**: a ban covers the identity (the same install gets the same identity in a + room every time) and the device. The app sends a digest of stable device traits + (model, OS version, locale, …) hashed with the room code. The server hashes that again + with the network (IPv4 address or IPv6 /64), so the room only stores an opaque value + that means nothing outside it. Someone who reinstalls and rejoins from the same device + and network is refused. People who were already in the room aren't affected, so a + friend on the same Wi-Fi with the same phone is never caught by someone else's ban. + Ways around it: a different device or network, or a modified client. A new arrival + with exactly the same phone, OS version and settings on the banned person's network + would also be refused. +- **Room codes**: six characters, about a billion codes. That's fine against guessing + at the rate limit, but a code someone sees on your screen lets them in. +- **Spending**: on the paid plan, a determined attacker with many IPs can still run up + usage. Set a usage alert in the Cloudflare dashboard. The free plan just stops at its + daily limit (00:00 UTC reset) instead of billing. + ## Develop ```sh diff --git a/jam-server/src/index.ts b/jam-server/src/index.ts index 5a9e27fa..c5af9e95 100644 --- a/jam-server/src/index.ts +++ b/jam-server/src/index.ts @@ -1,4 +1,4 @@ -import { CLIENT_ID, MAX_NAME_LENGTH, PROTOCOL_VERSION, ROOM_CODE } from "./protocol"; +import { CLIENT_KEY, DEVICE_DIGEST, PROTOCOL_VERSION, ROOM_CODE, cleanName, deviceMark, participantId } from "./protocol"; export { JamRoom } from "./room"; @@ -7,7 +7,10 @@ const JSON_HEADERS = { "content-type": "application/json" }; /** * Routes: * - `GET /v1/health` → `{ ok, v, now }` (used by the app's "Test connection") - * - `GET /v1/rooms/{CODE}?id=&name=&mode=create|join` (WebSocket) → the room's Durable Object + * - `GET /v1/rooms/{CODE}?key=&name=&mode=create|join[&dev=]` (WebSocket) → the room's Durable Object + * + * Durable Objects aren't reachable from the internet, only through this Worker, so the + * room trusts the `id` and `name` this Worker hands it. */ export default { async fetch(request, env): Promise { @@ -25,10 +28,17 @@ export default { } const code = match[1].toUpperCase(); - const id = url.searchParams.get("id") ?? ""; - const name = (url.searchParams.get("name") ?? "").trim().slice(0, MAX_NAME_LENGTH); + const key = url.searchParams.get("key") ?? ""; + const name = cleanName(url.searchParams.get("name") ?? ""); const mode = url.searchParams.get("mode"); - if (!ROOM_CODE.test(code) || !CLIENT_ID.test(id) || !name || (mode !== "create" && mode !== "join")) { + const dev = url.searchParams.get("dev"); + if ( + !ROOM_CODE.test(code) || + !CLIENT_KEY.test(key) || + !name || + (mode !== "create" && mode !== "join") || + (dev !== null && !DEVICE_DIGEST.test(dev)) + ) { return new Response(JSON.stringify({ error: "bad_request" }), { status: 400, headers: JSON_HEADERS }); } @@ -37,7 +47,12 @@ export default { if (!success) return new Response("Too many connections", { status: 429 }); const forwarded = new URL(request.url); - forwarded.search = new URLSearchParams({ id, name, mode }).toString(); + forwarded.search = new URLSearchParams({ + id: await participantId(code, key), + name, + mode, + mark: (await deviceMark(code, dev, ip)) ?? "", + }).toString(); const room = env.JAM_ROOM.get(env.JAM_ROOM.idFromName(code)); return room.fetch(new Request(forwarded, request)); }, diff --git a/jam-server/src/protocol.ts b/jam-server/src/protocol.ts index 4aaefb87..b59ce813 100644 --- a/jam-server/src/protocol.ts +++ b/jam-server/src/protocol.ts @@ -10,11 +10,30 @@ export const PROTOCOL_VERSION = 1; /** Same alphabet as the app's `JamRoomCode` (no I, O, 0, 1). */ export const ROOM_CODE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/; -export const CLIENT_ID = /^[A-Za-z0-9_-]{4,64}$/; +/** + * A participant's secret, chosen by the client (at least 128 random bits). The server + * never shares it: everyone else only sees the id derived from it ([participantId]), + * so knowing someone's id doesn't let you reconnect as them. + */ +export const CLIENT_KEY = /^[A-Za-z0-9_-]{22,64}$/; export const MAX_NAME_LENGTH = 40; export const MAX_PARTICIPANTS = 32; /** A long queue with cover URLs is the largest frame, a few hundred KB at most. */ export const MAX_FRAME_BYTES = 512 * 1024; +export const MAX_BANS = 256; +/** Everyone who has been in the room, to tell a returning member from a new arrival. */ +export const MAX_MEMBERS = 512; + +/** Optional per-room device digest from the client (it never sends device details). */ +export const DEVICE_DIGEST = /^[A-Za-z0-9_-]{16,64}$/; + +/** + * Per-socket token buckets. Every incoming message is billed, so a client that floods + * is disconnected. Real clients stay far below: a few frames a minute, bursts of a + * dozen when skipping quickly, and the host sends at most one queue a second. + */ +export const FRAME_BUDGET = { burst: 40, perSecond: 6 }; +export const QUEUE_BUDGET = { burst: 5, perSecond: 1 }; /** * A host that drops keeps the room for this long before someone else takes over (long @@ -70,7 +89,8 @@ export type ErrorCode = | "banned" | "kicked" | "replaced" - | "frame_too_large"; + | "frame_too_large" + | "rate_limited"; /** WebSocket close codes the app maps back to an [ErrorCode]. */ export const CLOSE_CODES: Record = { @@ -82,4 +102,42 @@ export const CLOSE_CODES: Record = { kicked: 4008, replaced: 4009, frame_too_large: 4013, + rate_limited: 4029, }; + +/** First 96 bits of SHA-256, base64url. */ +async function shortHash(text: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); + return btoa(String.fromCharCode(...new Uint8Array(digest).slice(0, 12))) + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +/** The public id for [key] in room [code]: a different id per room, so ids can't link rooms. */ +export function participantId(code: string, key: string): Promise { + return shortHash(`${code}:${key}`); +} + +/** + * What a ban remembers about a device besides its participant id: the client's device + * digest combined with its network, hashed again here so the room only ever stores an + * opaque value. Null without a digest. + */ +export function deviceMark(code: string, digest: string | null, ip: string): Promise | null { + return digest ? shortHash(`${code}:${digest}:${networkOf(ip)}`) : null; +} + +/** An IPv4 address, or an IPv6 /64 (one home or phone gets a whole /64). */ +export function networkOf(ip: string): string { + if (!ip.includes(":")) return ip; + const [head, tail] = ip.split("::"); + const left = head ? head.split(":") : []; + const right = tail ? tail.split(":") : []; + const groups = tail === undefined ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right]; + return groups.slice(0, 4).map((g) => parseInt(g || "0", 16).toString(16)).join(":") + "::/64"; +} + +/** Names are shown to everyone: no control or formatting characters, no padding. */ +export function cleanName(raw: string): string { + return raw.replace(/[\p{C}]/gu, "").replace(/\s+/gu, " ").trim().slice(0, MAX_NAME_LENGTH); +} diff --git a/jam-server/src/room.ts b/jam-server/src/room.ts index 72496412..207fad2e 100644 --- a/jam-server/src/room.ts +++ b/jam-server/src/room.ts @@ -4,10 +4,14 @@ import { KEEPALIVE_PING, KEEPALIVE_PONG, EMPTY_ROOM_TTL_MS, + FRAME_BUDGET, HOST_GRACE_MS, + MAX_BANS, + MAX_MEMBERS, MAX_FRAME_BYTES, MAX_PARTICIPANTS, PROTOCOL_VERSION, + QUEUE_BUDGET, type ClientFrame, type ErrorCode, type JoinMode, @@ -15,17 +19,35 @@ import { type ServerFrame, } from "./protocol"; +interface Bucket { + tokens: number; + at: number; +} + +/** Takes one token; false when [bucket] is empty. */ +function spend(bucket: Bucket, budget: { burst: number; perSecond: number }, now: number): boolean { + bucket.tokens = Math.min(budget.burst, bucket.tokens + ((now - bucket.at) / 1000) * budget.perSecond); + bucket.at = now; + if (bucket.tokens < 1) return false; + bucket.tokens -= 1; + return true; +} + /** Kept on each socket so it survives hibernation. */ interface Attachment { id: string; name: string; joined: number; + /** See [deviceMark]; empty when the client sent no device digest. */ + mark: string; } const enum Key { Host = "host", Queue = "queue", Banned = "banned", + BannedMarks = "bannedMarks", + Members = "members", HostGoneAt = "hostGoneAt", EmptySince = "emptySince", } @@ -46,6 +68,10 @@ const enum Key { */ export class JamRoom extends DurableObject { private anchor: unknown = null; + // In memory: a room that hibernated starts everyone with a full budget again, which + // only matters to a client that floods, and that one wakes the room right away. + private frameBudgets = new WeakMap(); + private queueBudgets = new WeakMap(); constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); @@ -57,16 +83,17 @@ export class JamRoom extends DurableObject { const id = url.searchParams.get("id")!; const name = url.searchParams.get("name")!; const mode = url.searchParams.get("mode") as JoinMode; + const mark = url.searchParams.get("mark") ?? ""; const { 0: client, 1: server } = new WebSocketPair(); const previous = this.ctx.getWebSockets(id); this.ctx.acceptWebSocket(server, [id]); - server.serializeAttachment({ id, name, joined: Date.now() } satisfies Attachment); + server.serializeAttachment({ id, name, joined: Date.now(), mark } satisfies Attachment); // A reconnect with the same id (network change, app restart) takes over the old socket. for (const ws of previous) this.reject(ws, "replaced"); - const refusal = await this.admit(id, mode); + const refusal = await this.admit(id, mode, mark); if (refusal) { this.reject(server, refusal); } else { @@ -94,6 +121,7 @@ export class JamRoom extends DurableObject { const size = typeof message === "string" ? message.length : message.byteLength; if (size > MAX_FRAME_BYTES) return this.reject(ws, "frame_too_large"); if (typeof message !== "string") return; + if (!this.withinBudget(this.frameBudgets, ws, FRAME_BUDGET)) return this.reject(ws, "rate_limited"); let frame: ClientFrame; try { @@ -110,6 +138,7 @@ export class JamRoom extends DurableObject { case "queue": if (!(await this.isHost(sender.id))) return; + if (!this.withinBudget(this.queueBudgets, ws, QUEUE_BUDGET)) return this.reject(ws, "rate_limited"); await this.ctx.storage.put(Key.Queue, frame.body); this.broadcast({ t: "queue", body: frame.body }, ws); return; @@ -133,12 +162,7 @@ export class JamRoom extends DurableObject { case "kick": { if (!(await this.isHost(sender.id)) || frame.target === sender.id) return; - if (frame.ban) { - const banned = (await this.ctx.storage.get(Key.Banned)) ?? []; - if (!banned.includes(frame.target)) { - await this.ctx.storage.put(Key.Banned, [...banned, frame.target]); - } - } + if (frame.ban) await this.ban(frame.target); for (const target of this.ctx.getWebSockets(frame.target)) { this.reject(target, frame.ban ? "banned" : "kicked"); } @@ -183,16 +207,32 @@ export class JamRoom extends DurableObject { // ---------- Membership ---------- - private async admit(id: string, mode: JoinMode): Promise { - const others = this.live().filter((ws) => this.attachment(ws).id !== id); + /** + * Bans [id], and the device it's on: someone who comes back with a fresh identity from + * the same device and network is refused too, unless they were already a member. + */ + private async ban(id: string): Promise { + const marks = this.ctx + .getWebSockets(id) + .map((ws) => this.attachment(ws).mark) + .filter((mark) => mark); + const [banned, bannedMarks] = await Promise.all([ + this.ctx.storage.get(Key.Banned), + this.ctx.storage.get(Key.BannedMarks), + ]); + await this.ctx.storage.put({ + [Key.Banned]: [...new Set([...(banned ?? []), id])].slice(-MAX_BANS), + [Key.BannedMarks]: [...new Set([...(bannedMarks ?? []), ...marks])].slice(-MAX_BANS), + }); + } - if (mode === "create") { - if (others.length > 0) return "room_taken"; - // Leftovers of an expired-but-not-yet-cleaned room must not leak into a new one. + private async admit(id: string, mode: JoinMode, mark: string): Promise { + const others = this.live().filter((ws) => this.attachment(ws).id !== id); + // An empty room past its TTL whose cleanup alarm hasn't run yet is gone. + const emptySince = await this.ctx.storage.get(Key.EmptySince); + if (others.length === 0 && emptySince !== undefined && Date.now() - emptySince >= EMPTY_ROOM_TTL_MS) { await this.ctx.storage.deleteAll(); this.anchor = null; - await this.ctx.storage.put(Key.Host, id); - return null; } const [host, banned, queue] = await Promise.all([ @@ -200,9 +240,27 @@ export class JamRoom extends DurableObject { this.ctx.storage.get(Key.Banned), this.ctx.storage.get(Key.Queue), ]); - if (host === undefined && queue === undefined && others.length === 0) return "room_not_found"; + const exists = host !== undefined || queue !== undefined || others.length > 0; + + if (mode === "create") { + // Only a fresh code, or the room's own host bringing it back. Anyone else + // "creating" an existing room (even an empty one) would take it over. + if (others.length > 0 || (exists && host !== id)) return "room_taken"; + if (!exists) await this.ctx.storage.put({ [Key.Host]: id, [Key.Members]: [id] }); + return null; + } + + if (!exists) return "room_not_found"; if (banned?.includes(id)) return "banned"; + const members = (await this.ctx.storage.get(Key.Members)) ?? []; + const isMember = members.includes(id); + // A member who was in the room before a ban (a friend on the same Wi-Fi with the + // same phone, say) is never caught by another person's device ban. + if (mark && !isMember && (await this.ctx.storage.get(Key.BannedMarks))?.includes(mark)) { + return "banned"; + } if (others.length >= MAX_PARTICIPANTS) return "room_full"; + if (!isMember) await this.ctx.storage.put(Key.Members, [...members, id].slice(-MAX_MEMBERS)); // Joining a room whose host is gone (and whose grace period is over) promotes someone. const hostLive = host === id || others.some((ws) => this.attachment(ws).id === host); @@ -276,6 +334,20 @@ export class JamRoom extends DurableObject { .map(({ id, name }) => ({ id, name })); } + private withinBudget( + buckets: WeakMap, + ws: WebSocket, + budget: { burst: number; perSecond: number }, + ): boolean { + const now = Date.now(); + let bucket = buckets.get(ws); + if (!bucket) { + bucket = { tokens: budget.burst, at: now }; + buckets.set(ws, bucket); + } + return spend(bucket, budget, now); + } + private attachment(ws: WebSocket): Attachment { return ws.deserializeAttachment() as Attachment; } diff --git a/jam-server/test/smoke.mjs b/jam-server/test/smoke.mjs index a2d080dd..d6736dc5 100644 --- a/jam-server/test/smoke.mjs +++ b/jam-server/test/smoke.mjs @@ -6,9 +6,12 @@ const BASE = process.env.JAM_URL ?? "http://127.0.0.1:8787"; const WS_BASE = BASE.replace(/^http/, "ws"); const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const code = () => Array.from({ length: 6 }, () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)]).join(""); +const newKey = () => crypto.randomUUID().replaceAll("-", ""); -function connect(room, id, mode, name = id) { - const ws = new WebSocket(`${WS_BASE}/v1/rooms/${room}?id=${id}&name=${encodeURIComponent(name)}&mode=${mode}`); +/** [id] is only a label for errors; the server assigns the real id (welcome.you). */ +function connect(room, id, mode, name = id, key = newKey(), dev = null) { + const devParam = dev ? `&dev=${dev}` : ""; + const ws = new WebSocket(`${WS_BASE}/v1/rooms/${room}?key=${key}&name=${encodeURIComponent(name)}&mode=${mode}${devParam}`); const inbox = []; const waiters = []; ws.addEventListener("message", (e) => { @@ -20,6 +23,7 @@ function connect(room, id, mode, name = id) { const closed = new Promise((resolve) => ws.addEventListener("close", (e) => resolve(e.code))); return { ws, + key, closed, send: (frame) => ws.send(JSON.stringify(frame)), next(t, pred = () => true, timeoutMs = 5_000) { @@ -46,12 +50,14 @@ await step("health", async () => { }); const room = code(); -const host = connect(room, "host-0001", "create", "Host"); -let guest; +const host = connect(room, "host", "create", "Host"); +let guest, hostId, guestId; await step("host creates the room", async () => { const welcome = await host.next("welcome"); - assert.equal(welcome.host, "host-0001"); + hostId = welcome.you; + assert.match(hostId, /^[A-Za-z0-9_-]{16}$/); + assert.equal(welcome.host, hostId); host.send({ t: "queue", body: { items: [1, 2, 3] } }); }); @@ -59,11 +65,13 @@ await step("guest joins and gets the retained queue", async () => { // The host's queue frame is processed before the guest's join is. host.send({ t: "ping", id: 1, c: 0 }); await host.next("pong"); - guest = connect(room, "guest-0001", "join", "Guest"); + guest = connect(room, "guest", "join", "Guest\u202e\u0000 1"); const welcome = await guest.next("welcome"); - assert.equal(welcome.host, "host-0001"); + guestId = welcome.you; + assert.equal(welcome.host, hostId); assert.deepEqual(welcome.queue, { items: [1, 2, 3] }); - assert.ok(welcome.participants.some((p) => p.id === "guest-0001")); + const me = welcome.participants.find((p) => p.id === guestId); + assert.equal(me.name, "Guest 1", "control and formatting characters are stripped"); await host.next("presence", (f) => f.participants.length === 2); }); @@ -95,37 +103,99 @@ await step("guest anchors are ignored", async () => { await step("guest commands reach the host", async () => { guest.send({ t: "cmd", body: { type: "skipNext" } }); const cmd = await host.next("cmd"); - assert.equal(cmd.from, "guest-0001"); + assert.equal(cmd.from, guestId); +}); + +await step("nobody can take over the host's identity", async () => { + // The host's id is public; using it as a key yields a different id. + const impostor = connect(room, "impostor", "join", "Host", hostId.padEnd(22, "x")); + const welcome = await impostor.next("welcome"); + assert.notEqual(welcome.you, hostId); + assert.equal(welcome.host, hostId); + impostor.send({ t: "kick", target: guestId, ban: true }); + impostor.send({ t: "anchor", body: { positionMs: 666 } }); + impostor.send({ t: "ping", id: 9, c: 0 }); + await impostor.next("pong"); + await assert.rejects(guest.next("anchor", () => true, 500), "an impostor's anchor isn't relayed"); + impostor.ws.close(); + await host.next("presence", (f) => f.participants.length === 2); }); await step("a taken code can't be created again", async () => { - const intruder = connect(room, "intruder-1", "create"); + const intruder = connect(room, "intruder", "create"); const error = await intruder.next("error"); assert.equal(error.code, "room_taken"); assert.equal(await intruder.closed, 4001); }); await step("unknown rooms are not found", async () => { - const lost = connect(code(), "lost-0001", "join"); + const lost = connect(code(), "lost", "join"); assert.equal((await lost.next("error")).code, "room_not_found"); }); await step("ban closes the socket and blocks rejoining", async () => { - host.send({ t: "kick", target: "guest-0001", ban: true }); + host.send({ t: "kick", target: guestId, ban: true }); assert.equal((await guest.next("error")).code, "banned"); assert.equal(await guest.closed, 4003); - const again = connect(room, "guest-0001", "join"); + const again = connect(room, "guest", "join", "Guest", guest.key); assert.equal((await again.next("error")).code, "banned"); }); +await step("a ban covers the device, not just the identity", async () => { + const party = code(); + const phone = newKey(); // The same device digest: same model, OS, locale… + const owner = connect(party, "owner", "create", "Owner", newKey(), newKey()); + await owner.next("welcome"); + // A friend with an identical phone, already in the room before the ban. + const friend = connect(party, "friend", "join", "Friend", newKey(), phone); + const friendId = (await friend.next("welcome")).you; + const troll = connect(party, "troll", "join", "Troll", newKey(), phone); + const trollId = (await troll.next("welcome")).you; + + owner.send({ t: "kick", target: trollId, ban: true }); + assert.equal((await troll.next("error")).code, "banned"); + + // Back with a fresh identity (reinstall) from the same device and network: refused. + const again = connect(party, "troll-again", "join", "Troll", newKey(), phone); + assert.equal((await again.next("error")).code, "banned"); + // The friend can still reconnect, and someone on another device can join. + friend.ws.close(); + const friendBack = connect(party, "friend", "join", "Friend", friend.key, phone); + assert.equal((await friendBack.next("welcome")).you, friendId); + const stranger = connect(party, "stranger", "join", "Stranger", newKey(), newKey()); + await stranger.next("welcome"); + for (const c of [owner, friendBack, stranger]) c.ws.close(); +}); + +await step("a client that floods is cut off", async () => { + const flooder = connect(code(), "flooder", "create"); + await flooder.next("welcome"); + for (let i = 0; i < 100; i++) flooder.send({ t: "ping", id: i, c: 0 }); + assert.equal(await flooder.closed, 4029); +}); + await step("host hands over after the grace period", async () => { - const heir = connect(room, "heir-0001", "join", "Heir"); - await heir.next("welcome"); + const heir = connect(room, "heir", "join", "Heir"); + const heirId = (await heir.next("welcome")).you; host.ws.close(); - const presence = await heir.next("presence", (f) => f.host === "heir-0001", 45_000); - assert.deepEqual(presence.participants.map((p) => p.id), ["heir-0001"]); + const presence = await heir.next("presence", (f) => f.host === heirId, 45_000); + assert.deepEqual(presence.participants.map((p) => p.id), [heirId]); heir.ws.close(); }); +await step("an empty room can't be taken over, but its host can bring it back", async () => { + const other = code(); + const owner = connect(other, "owner", "create"); + await owner.next("welcome"); + owner.ws.close(); + await owner.closed; + const squatter = connect(other, "squatter", "create"); + assert.equal((await squatter.next("error")).code, "room_taken"); + const back = connect(other, "owner", "create", "Owner", owner.key); + const welcome = await back.next("welcome"); + assert.equal(welcome.host, welcome.you); + back.ws.close(); +}); + console.log("all smoke tests passed"); process.exit(0); diff --git a/jam-server/wrangler.jsonc b/jam-server/wrangler.jsonc index 4ec4a1bb..edabb8ec 100644 --- a/jam-server/wrangler.jsonc +++ b/jam-server/wrangler.jsonc @@ -8,7 +8,9 @@ "bindings": [{ "name": "JAM_ROOM", "class_name": "JamRoom" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["JamRoom"] }], - // Room connects per client IP; stops code guessing from waking (and billing) rooms. + // Room connects per client IP: slows down code guessing and connection floods, which + // wake (and bill) rooms. Generous because carrier-grade NAT puts many phones behind + // one IP. Enforced per Cloudflare location, so approximate. "ratelimits": [ { "name": "CONNECT_LIMITER", "namespace_id": "1001", "simple": { "limit": 60, "period": 60 } } ]