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 } } ]