diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx index bba42f4c..2735029b 100644 --- a/client/src/components/Collab/CollabChat.tsx +++ b/client/src/components/Collab/CollabChat.tsx @@ -768,7 +768,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) { )} {/* Composer */} -
+
{/* Reply preview */} {replyTo && (
= { // System notices — personal thank you 'system_notice.v3_thankyou.title': 'كلمة شخصية مني', 'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': 'إجراء مطلوب: تعارض في حسابات المستخدمين', + 'system_notice.v3014_whitespace_collision.body': 'اكتشف ترقية 3.0.14 تعارضًا في أسماء مستخدمين أو بريد إلكتروني ناتجًا عن مسافات بيضاء في بداية أو نهاية القيم المخزنة. تمت إعادة تسمية الحسابات المتأثرة تلقائيًا. تحقق من سجلات الخادم بحثًا عن أسطر تبدأ بـ **[migration] WHITESPACE COLLISION** لتحديد الحسابات التي تحتاج إلى مراجعة.', 'transport.addTransport': 'إضافة وسيلة نقل', 'transport.modalTitle.create': 'إضافة وسيلة نقل', 'transport.modalTitle.edit': 'تعديل وسيلة النقل', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index c2ae52ea..0757c3d2 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -2346,6 +2346,9 @@ const br: Record = { // System notices — personal thank you 'system_notice.v3_thankyou.title': 'Uma nota pessoal minha', 'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': 'Ação necessária: conflito de conta de usuário', + 'system_notice.v3014_whitespace_collision.body': 'A atualização 3.0.14 detectou um ou mais conflitos de nome de usuário ou e-mail causados por espaços em branco no início ou fim dos valores armazenados. As contas afetadas foram renomeadas automaticamente. Verifique os logs do servidor por linhas começando com **[migration] WHITESPACE COLLISION** para identificar quais contas precisam de revisão.', 'transport.addTransport': 'Adicionar transporte', 'transport.modalTitle.create': 'Adicionar transporte', 'transport.modalTitle.edit': 'Editar transporte', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 5085236c..a14b633d 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -2350,6 +2350,9 @@ const cs: Record = { // System notices — personal thank you 'system_notice.v3_thankyou.title': 'Osobní slovo ode mě', 'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': 'Vyžadována akce: konflikt uživatelského účtu', + 'system_notice.v3014_whitespace_collision.body': 'Aktualizace 3.0.14 zjistila jeden nebo více konfliktů uživatelského jména nebo e-mailu způsobených mezerami na začátku nebo konci uložených hodnot. Dotčené účty byly automaticky přejmenovány. Zkontrolujte protokoly serveru na řádky začínající **[migration] WHITESPACE COLLISION** a zjistěte, které účty vyžadují kontrolu.', 'transport.addTransport': 'Přidat dopravu', 'transport.modalTitle.create': 'Přidat dopravu', 'transport.modalTitle.edit': 'Upravit dopravu', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index f6a8e3f7..cbb6d153 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -2356,6 +2356,9 @@ const de: Record = { // System notices — persönlicher Dank 'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir', 'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': 'Aktion erforderlich: Benutzerkontokonflikt', + 'system_notice.v3014_whitespace_collision.body': 'Das 3.0.14-Upgrade hat einen oder mehrere Konflikte bei Benutzernamen oder E-Mail-Adressen festgestellt, die durch führende oder nachgestellte Leerzeichen in gespeicherten Konten verursacht wurden. Betroffene Konten wurden automatisch umbenannt. Prüfe die Serverprotokolle auf Zeilen, die mit **[migration] WHITESPACE COLLISION** beginnen, um die betroffenen Konten zu identifizieren.', 'transport.addTransport': 'Transport hinzufügen', 'transport.modalTitle.create': 'Transport hinzufügen', 'transport.modalTitle.edit': 'Transport bearbeiten', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 7b2cebe9..ce8321a6 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -2393,6 +2393,10 @@ const en: Record = { 'system_notice.v3_thankyou.title': 'A personal note from me', 'system_notice.v3_thankyou.body': 'Before you go — I want to take a moment.\n\nTREK started as a side project I built for my own trips. I never imagined it would grow into something that 4,000 of you now trust to plan your adventures. Every star, every issue, every feature request — I read them all, and they keep me going through late nights between a full-time job and university.\n\nI want you to know: TREK will always be open source, always self-hosted, always yours. No tracking, no subscriptions, no strings attached. Just a tool built by someone who loves traveling as much as you do.\n\nSpecial thanks to [jubnl](https://github.com/jubnl) — you have become an incredible collaborator. So much of what makes 3.0 great carries your fingerprints. Thank you for believing in this project when it was still rough around the edges.\n\nAnd to every single one of you who filed a bug, translated a string, shared TREK with a friend, or simply used it to plan a trip — **thank you**. You are the reason this exists.\n\nHere\'s to many more adventures together.\n\n— Maurice\n\n---\n\n[Join the community on Discord](https://discord.gg/7Q6M6jDwzf)\n\nIf TREK makes your travels better, a [small coffee](https://ko-fi.com/mauriceboe) always keeps the lights on.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': 'Action required: user account conflict', + 'system_notice.v3014_whitespace_collision.body': 'The 3.0.14 upgrade detected one or more username or email collisions caused by leading/trailing whitespace in stored accounts. Affected accounts were renamed automatically. Check the server logs for lines starting with **[migration] WHITESPACE COLLISION** to identify which accounts need review.', + // System notices — onboarding 'system_notice.welcome_v1.title': 'Welcome to TREK', 'system_notice.welcome_v1.body': 'Your all-in-one travel planner. Build itineraries, share trips with friends, and stay organized — online or offline.', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 5348dbc6..a66bdfb6 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -2352,6 +2352,9 @@ const es: Record = { // System notices — personal thank you 'system_notice.v3_thankyou.title': 'Una nota personal de mi parte', 'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': 'Acción requerida: conflicto de cuenta de usuario', + 'system_notice.v3014_whitespace_collision.body': 'La actualización 3.0.14 detectó uno o más conflictos de nombre de usuario o correo electrónico causados por espacios en blanco al inicio o al final de los valores almacenados. Las cuentas afectadas se renombraron automáticamente. Revisa los registros del servidor en busca de líneas que empiecen por **[migration] WHITESPACE COLLISION** para identificar qué cuentas necesitan revisión.', 'transport.addTransport': 'Añadir transporte', 'transport.modalTitle.create': 'Añadir transporte', 'transport.modalTitle.edit': 'Editar transporte', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 88cd4577..c7cd1605 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -2346,6 +2346,9 @@ const fr: Record = { // System notices — personal thank you 'system_notice.v3_thankyou.title': 'Un mot personnel de ma part', 'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': "Action requise : conflit de compte utilisateur", + 'system_notice.v3014_whitespace_collision.body': "La mise à niveau 3.0.14 a détecté un ou plusieurs conflits de nom d'utilisateur ou d'adresse e-mail causés par des espaces en début ou en fin de valeur dans les comptes enregistrés. Les comptes concernés ont été renommés automatiquement. Consultez les journaux du serveur pour les lignes commençant par **[migration] WHITESPACE COLLISION** afin d'identifier les comptes nécessitant une vérification.", 'transport.addTransport': 'Ajouter un transport', 'transport.modalTitle.create': 'Ajouter un transport', 'transport.modalTitle.edit': 'Modifier le transport', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 263bfbc7..f8046fab 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -2347,6 +2347,9 @@ const hu: Record = { // System notices — personal thank you 'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem', 'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': 'Szükséges beavatkozás: felhasználói fiókütközés', + 'system_notice.v3014_whitespace_collision.body': 'A 3.0.14-es frissítés egy vagy több felhasználónév- vagy e-mail-ütközést észlelt, amelyeket a tárolt értékek elején vagy végén lévő szóközök okoztak. Az érintett fiókok automatikusan át lettek nevezve. Ellenőrizze a szervernaplókat a **[migration] WHITESPACE COLLISION** kezdetű soroknál a felülvizsgálatot igénylő fiókok azonosításához.', 'transport.addTransport': 'Közlekedés hozzáadása', 'transport.modalTitle.create': 'Közlekedés hozzáadása', 'transport.modalTitle.edit': 'Közlekedés szerkesztése', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index 1cf3050c..112d17fc 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -2388,6 +2388,9 @@ const id: Record = { // System notices — personal thank you 'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya', 'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': 'Tindakan diperlukan: konflik akun pengguna', + 'system_notice.v3014_whitespace_collision.body': 'Pembaruan 3.0.14 mendeteksi satu atau lebih konflik nama pengguna atau email yang disebabkan oleh spasi di awal atau akhir nilai yang tersimpan. Akun yang terpengaruh telah diganti nama secara otomatis. Periksa log server untuk baris yang dimulai dengan **[migration] WHITESPACE COLLISION** guna mengidentifikasi akun mana yang perlu ditinjau.', 'transport.addTransport': 'Tambah transportasi', 'transport.modalTitle.create': 'Tambah transportasi', 'transport.modalTitle.edit': 'Edit transportasi', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 6286cb6f..2ac5424f 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -2347,6 +2347,9 @@ const it: Record = { // System notices — personal thank you 'system_notice.v3_thankyou.title': 'Una nota personale da parte mia', 'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': 'Azione richiesta: conflitto di account utente', + 'system_notice.v3014_whitespace_collision.body': "L'aggiornamento 3.0.14 ha rilevato uno o più conflitti di nome utente o e-mail causati da spazi iniziali o finali nei valori memorizzati. Gli account interessati sono stati rinominati automaticamente. Controlla i log del server per le righe che iniziano con **[migration] WHITESPACE COLLISION** per identificare quali account richiedono revisione.", 'transport.addTransport': 'Aggiungi trasporto', 'transport.modalTitle.create': 'Aggiungi trasporto', 'transport.modalTitle.edit': 'Modifica trasporto', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 551b3779..0cb55bc1 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -2346,6 +2346,9 @@ const nl: Record = { // System notices — personal thank you 'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij', 'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': 'Actie vereist: gebruikersaccountconflict', + 'system_notice.v3014_whitespace_collision.body': 'De 3.0.14-upgrade heeft één of meer conflicten in gebruikersnaam of e-mailadres gedetecteerd, veroorzaakt door spaties aan het begin of einde van opgeslagen waarden. Getroffen accounts zijn automatisch hernoemd. Controleer de serverlogboeken op regels die beginnen met **[migration] WHITESPACE COLLISION** om te achterhalen welke accounts moeten worden beoordeeld.', 'transport.addTransport': 'Vervoer toevoegen', 'transport.modalTitle.create': 'Vervoer toevoegen', 'transport.modalTitle.edit': 'Vervoer bewerken', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 8a9b16a2..87f768a9 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -2339,6 +2339,9 @@ const pl: Record = { // System notices — personal thank you 'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie', 'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': 'Wymagane działanie: konflikt konta użytkownika', + 'system_notice.v3014_whitespace_collision.body': 'Aktualizacja 3.0.14 wykryła jeden lub więcej konfliktów nazwy użytkownika lub adresu e-mail spowodowanych spacjami na początku lub końcu przechowywanych wartości. Dotknięte konta zostały automatycznie przemianowane. Sprawdź logi serwera pod kątem wierszy zaczynających się od **[migration] WHITESPACE COLLISION**, aby zidentyfikować konta wymagające przeglądu.', 'transport.addTransport': 'Dodaj transport', 'transport.modalTitle.create': 'Dodaj transport', 'transport.modalTitle.edit': 'Edytuj transport', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 1f4c9302..f4f23fb8 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -2346,6 +2346,9 @@ const ru: Record = { // System notices — personal thank you 'system_notice.v3_thankyou.title': 'Личное слово от меня', 'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': 'Требуется действие: конфликт учётных записей', + 'system_notice.v3014_whitespace_collision.body': 'Обновление 3.0.14 обнаружило один или несколько конфликтов имён пользователей или адресов электронной почты, вызванных ведущими или завершающими пробелами в сохранённых значениях. Затронутые учётные записи были автоматически переименованы. Проверьте логи сервера на строки, начинающиеся с **[migration] WHITESPACE COLLISION**, чтобы определить учётные записи, требующие проверки.', 'transport.addTransport': 'Добавить транспорт', 'transport.modalTitle.create': 'Добавить транспорт', 'transport.modalTitle.edit': 'Изменить транспорт', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index e3a97283..ffa564b6 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -2346,6 +2346,9 @@ const zh: Record = { // System notices — personal thank you 'system_notice.v3_thankyou.title': '来自我的一封私人信', 'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': '需要操作:用户账户冲突', + 'system_notice.v3014_whitespace_collision.body': '3.0.14 版本升级检测到一个或多个由存储账户中首尾空白字符引发的用户名或邮箱冲突。受影响的账户已自动重命名。请检查服务器日志中以 **[migration] WHITESPACE COLLISION** 开头的行,以确认哪些账户需要审查。', 'transport.addTransport': '添加交通', 'transport.modalTitle.create': '添加交通', 'transport.modalTitle.edit': '编辑交通', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 26207c7f..331596c5 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -2347,6 +2347,9 @@ const zhTw: Record = { // System notices — personal thank you 'system_notice.v3_thankyou.title': '來自我的一封私人信', 'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。', + // System notices — 3.0.14 + 'system_notice.v3014_whitespace_collision.title': '需要操作:使用者帳戶衝突', + 'system_notice.v3014_whitespace_collision.body': '3.0.14 版本升級偵測到一個或多個由儲存帳戶中前後空白字元引發的使用者名稱或電子郵件衝突。受影響的帳戶已自動重新命名。請檢查伺服器日誌中以 **[migration] WHITESPACE COLLISION** 開頭的行,以確認哪些帳戶需要審查。', 'transport.addTransport': '新增交通', 'transport.modalTitle.create': '新增交通', 'transport.modalTitle.edit': '編輯交通', diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 8be7cd58..bb9a1eab 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -1191,7 +1191,7 @@ export default function TripPlannerPage(): React.ReactElement | null { )} {activeTab === 'collab' && ( -
+
)} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 417a6b9c..510cc4c6 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1,6 +1,74 @@ import Database from 'better-sqlite3'; import { encrypt_api_key } from '../services/apiKeyCrypto'; +/** Returns true if any collision was encountered (renamed row). */ +export function trimUserWhitespace(db: Database.Database): boolean { + type DirtyRow = { id: number; username?: string; email?: string }; + let hadCollision = false; + + const dirtyUsernames = db.prepare( + `SELECT id, username FROM users WHERE username != TRIM(username)` + ).all() as DirtyRow[]; + + for (const row of dirtyUsernames) { + const trimmed = row.username!.trim(); + const collision = db.prepare( + `SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?` + ).get(trimmed, row.id) as { id: number } | undefined; + + const final = collision ? `${trimmed}__migrated_${row.id}` : trimmed; + if (collision) { + hadCollision = true; + console.warn( + `[migration] WHITESPACE COLLISION username: user id=${row.id} ` + + `original=${JSON.stringify(row.username)} trimmed="${trimmed}" ` + + `collides with user id=${collision.id}. Renamed to "${final}". ` + + `Manual review required.` + ); + } else { + console.warn( + `[migration] Trimmed username for user id=${row.id}: ` + + `${JSON.stringify(row.username)} → "${final}"` + ); + } + db.prepare(`UPDATE users SET username = ? WHERE id = ?`).run(final, row.id); + } + + const dirtyEmails = db.prepare( + `SELECT id, email FROM users WHERE email != TRIM(email)` + ).all() as DirtyRow[]; + + for (const row of dirtyEmails) { + const trimmed = row.email!.trim(); + const collision = db.prepare( + `SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ?` + ).get(trimmed, row.id) as { id: number } | undefined; + + let final = trimmed; + if (collision) { + hadCollision = true; + const at = trimmed.lastIndexOf('@'); + final = at > 0 + ? `${trimmed.slice(0, at)}__migrated_${row.id}${trimmed.slice(at)}` + : `${trimmed}__migrated_${row.id}`; + console.warn( + `[migration] WHITESPACE COLLISION email: user id=${row.id} ` + + `original=${JSON.stringify(row.email)} trimmed="${trimmed}" ` + + `collides with user id=${collision.id}. Renamed to "${final}". ` + + `User cannot sign in with this email until manually corrected.` + ); + } else { + console.warn( + `[migration] Trimmed email for user id=${row.id}: ` + + `${JSON.stringify(row.email)} → "${final}"` + ); + } + db.prepare(`UPDATE users SET email = ? WHERE id = ?`).run(final, row.id); + } + + return hadCollision; +} + function runMigrations(db: Database.Database): void { db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)'); const versionRow = db.prepare('SELECT version FROM schema_version').get() as { version: number } | undefined; @@ -2141,6 +2209,19 @@ function runMigrations(db: Database.Database): void { > (SELECT day_number FROM days WHERE id = end_day_id) `); }, + // prepare migration to nest + typeorm + () => { + db.exec(`CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL);`); + db.exec(`INSERT INTO migrations (timestamp, name) VALUES (1777810195344, 'InitialSchema1777810195344');`); + db.exec(`INSERT INTO app_settings (key, value) VALUES ('app_version', '${process.env.APP_VERSION || '3.0.14'}')`); + }, + // trim leading/trailing whitespace from stored usernames and emails + () => { + const hadCollision = trimUserWhitespace(db); + if (hadCollision) { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 5cf49e79..310b869d 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -474,6 +474,8 @@ function createTables(db: Database.Database): void { PRIMARY KEY (user_id, event_type, channel) ); CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id); + + CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL); `); } diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index f0fc5420..b2c50438 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -112,7 +112,9 @@ export function createUser(data: { username: string; email: string; password: st } export function updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) { - const { username, email, role, password } = data; + const username = typeof data.username === 'string' ? data.username.trim() : data.username; + const email = typeof data.email === 'string' ? data.email.trim() : data.email; + const { role, password } = data; const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined; if (!user) return { error: 'User not found', status: 404 }; diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index ba949481..71d61a5a 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -343,7 +343,9 @@ export function registerUser(body: { password?: string; invite_token?: string; }): { error?: string; status?: number; token?: string; user?: Record; auditUserId?: number; auditDetails?: Record } { - const { username, email, password, invite_token } = body; + const username = typeof body.username === 'string' ? body.username.trim() : ''; + const email = typeof body.email === 'string' ? body.email.trim() : ''; + const { password, invite_token } = body; const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count; diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index 42c0c5cd..edce054f 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -350,7 +350,7 @@ export function findOrCreateUser( config: OidcConfig, inviteToken?: string, ): { user: User } | { error: string } { - const email = userInfo.email!.toLowerCase(); + const email = userInfo.email!.trim().toLowerCase(); const name = userInfo.name || userInfo.preferred_username || email.split('@')[0]; const sub = userInfo.sub; diff --git a/server/src/systemNotices/registry.ts b/server/src/systemNotices/registry.ts index 19f056ce..4f5320d8 100644 --- a/server/src/systemNotices/registry.ts +++ b/server/src/systemNotices/registry.ts @@ -1,4 +1,11 @@ import type { SystemNotice } from './types.js'; +import { registerPredicate } from './conditions.js'; +import { db } from '../db/database.js'; + +registerPredicate('whitespace-collision-detected', () => { + const row = db.prepare("SELECT value FROM app_settings WHERE key = 'whitespace_migration_collision'").get() as { value: string } | undefined; + return row?.value === 'true'; +}); /** * SYSTEM NOTICE REGISTRY @@ -124,6 +131,26 @@ export const SYSTEM_NOTICES: SystemNotice[] = [ maxVersion: '4.0.0', }, + // ── 3.0.14 admin notice — whitespace migration collision ─────────────────── + + { + id: 'v3014-whitespace-collision', + display: 'banner', + severity: 'warn', + icon: 'AlertTriangle', + titleKey: 'system_notice.v3014_whitespace_collision.title', + bodyKey: 'system_notice.v3014_whitespace_collision.body', + dismissible: true, + conditions: [ + { kind: 'existingUserBeforeVersion', version: '3.0.14' }, + { kind: 'role', roles: ['admin'] }, + { kind: 'custom', id: 'whitespace-collision-detected' }, + ], + publishedAt: '2026-05-03T00:00:00Z', + priority: 85, + minVersion: '3.0.14', + }, + // ── Onboarding ───────────────────────────────────────────────────────────── { diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts index 19ed98dc..f9b4255f 100644 --- a/server/src/utils/ssrfGuard.ts +++ b/server/src/utils/ssrfGuard.ts @@ -66,11 +66,6 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean const hostname = url.hostname.toLowerCase(); - // Block internal hostname suffixes (no override — these are too easy to abuse) - if (isInternalHostname(hostname) && hostname !== 'localhost') { - return { allowed: false, isPrivate: false, error: 'Requests to .local/.internal domains are not allowed' }; - } - // Resolve hostname to IP let resolvedIp: string; try { diff --git a/server/tests/integration/admin.test.ts b/server/tests/integration/admin.test.ts index beeaa0a1..e96d2234 100644 --- a/server/tests/integration/admin.test.ts +++ b/server/tests/integration/admin.test.ts @@ -368,6 +368,53 @@ describe('Admin user management', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// Admin user management — whitespace normalization +// ───────────────────────────────────────────────────────────────────────────── + +describe('Admin user management — whitespace normalization', () => { + it('ADMIN-UPDATE-TRIM-1 — PUT /admin/users/:id trims username before storing', async () => { + const { user: admin } = createAdmin(testDb); + const { user } = createUser(testDb); + + const res = await request(app) + .put(`/api/admin/users/${user.id}`) + .set('Cookie', authCookie(admin.id)) + .send({ username: ' trimmedadmin ' }); + + expect(res.status).toBe(200); + const row = testDb.prepare('SELECT username FROM users WHERE id = ?').get(user.id) as { username: string }; + expect(row.username).toBe('trimmedadmin'); + }); + + it('ADMIN-UPDATE-TRIM-2 — PUT /admin/users/:id trims email before storing', async () => { + const { user: admin } = createAdmin(testDb); + const { user } = createUser(testDb); + + const res = await request(app) + .put(`/api/admin/users/${user.id}`) + .set('Cookie', authCookie(admin.id)) + .send({ email: ' newemail@example.com ' }); + + expect(res.status).toBe(200); + const row = testDb.prepare('SELECT email FROM users WHERE id = ?').get(user.id) as { email: string }; + expect(row.email).toBe('newemail@example.com'); + }); + + it('ADMIN-UPDATE-TRIM-3 — PUT /admin/users/:id with whitespace-padded username that trims to existing returns 409', async () => { + const { user: admin } = createAdmin(testDb); + const { user: existing } = createUser(testDb, { username: 'carol' }); + const { user: target } = createUser(testDb); + + const res = await request(app) + .put(`/api/admin/users/${target.id}`) + .set('Cookie', authCookie(admin.id)) + .send({ username: ` ${existing.username} ` }); + + expect(res.status).toBe(409); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // System stats // ───────────────────────────────────────────────────────────────────────────── diff --git a/server/tests/integration/auth.test.ts b/server/tests/integration/auth.test.ts index d60dbc0e..fb3c7ea2 100644 --- a/server/tests/integration/auth.test.ts +++ b/server/tests/integration/auth.test.ts @@ -218,6 +218,54 @@ describe('Registration', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// Registration — whitespace normalization +// ───────────────────────────────────────────────────────────────────────────── + +describe('Registration — whitespace normalization', () => { + it('AUTH-REG-TRIM-1 — username with surrounding whitespace is trimmed before storage', async () => { + const res = await request(app).post('/api/auth/register').send({ + username: ' trimmeduser ', + email: 'trimmed@example.com', + password: 'Str0ng!Pass', + }); + expect(res.status).toBe(201); + const row = testDb.prepare('SELECT username FROM users WHERE email = ?').get('trimmed@example.com') as { username: string }; + expect(row.username).toBe('trimmeduser'); + }); + + it('AUTH-REG-TRIM-2 — email with surrounding whitespace is trimmed before storage', async () => { + const res = await request(app).post('/api/auth/register').send({ + username: 'emailtrimuser', + email: ' emailtrim@example.com ', + password: 'Str0ng!Pass', + }); + expect(res.status).toBe(201); + const row = testDb.prepare('SELECT email FROM users WHERE username = ?').get('emailtrimuser') as { email: string }; + expect(row.email).toBe('emailtrim@example.com'); + }); + + it('AUTH-REG-TRIM-3 — whitespace-padded username that trims to existing username returns 409', async () => { + createUser(testDb, { username: 'alice', email: 'alice@example.com' }); + const res = await request(app).post('/api/auth/register').send({ + username: ' alice ', + email: 'alice2@example.com', + password: 'Str0ng!Pass', + }); + expect(res.status).toBe(409); + }); + + it('AUTH-REG-TRIM-4 — whitespace-padded email that trims to existing email returns 409', async () => { + createUser(testDb, { username: 'bob', email: 'bob@example.com' }); + const res = await request(app).post('/api/auth/register').send({ + username: 'bob2', + email: ' bob@example.com ', + password: 'Str0ng!Pass', + }); + expect(res.status).toBe(409); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // Session / Me // ───────────────────────────────────────────────────────────────────────────── diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts index 40179329..5bc88fae 100644 --- a/server/tests/integration/systemNotices.test.ts +++ b/server/tests/integration/systemNotices.test.ts @@ -39,7 +39,7 @@ import { createApp } from '../../src/app'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb } from '../helpers/test-db'; -import { createUser } from '../helpers/factories'; +import { createUser, createAdmin } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; import { SYSTEM_NOTICES } from '../../src/systemNotices/registry'; import type { SystemNotice } from '../../src/systemNotices/types'; @@ -242,3 +242,129 @@ describe('POST /api/system-notices/:id/dismiss', () => { } }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// v3014-whitespace-collision notice +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Helper: creates an admin user whose first_seen_version is before 3.0.14 + * (so existingUserBeforeVersion('3.0.14') passes) and whose login_count is + * high enough to suppress the firstLogin and v3-upgrade notice conditions. + */ +function setupCollisionAdmin() { + const { user } = createAdmin(testDb); + testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); + return user; +} + +describe('v3014-whitespace-collision notice', () => { + const NOTICE_ID = 'v3014-whitespace-collision'; + const originalAppVersion = process.env.APP_VERSION; + + beforeEach(() => { + process.env.APP_VERSION = '3.0.14'; + }); + + afterEach(() => { + if (originalAppVersion === undefined) { + delete process.env.APP_VERSION; + } else { + process.env.APP_VERSION = originalAppVersion; + } + }); + + it('SN-COLLISION-1 — shown to admin when collision flag is set and user predates 3.0.14', async () => { + const user = setupCollisionAdmin(); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); + + const res = await request(app) + .get('/api/system-notices/active') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeDefined(); + }); + + it('SN-COLLISION-2 — hidden when collision flag is absent', async () => { + const user = setupCollisionAdmin(); + + const res = await request(app) + .get('/api/system-notices/active') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined(); + }); + + it('SN-COLLISION-3 — hidden when collision flag is explicitly false', async () => { + const user = setupCollisionAdmin(); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'false')").run(); + + const res = await request(app) + .get('/api/system-notices/active') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined(); + }); + + it('SN-COLLISION-4 — hidden for non-admin user even when collision flag is set', async () => { + const { user } = createUser(testDb); + testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); + + const res = await request(app) + .get('/api/system-notices/active') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined(); + }); + + it('SN-COLLISION-5 — hidden for user whose first_seen_version is >= 3.0.14 (new account)', async () => { + const { user } = createAdmin(testDb); + testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.14', user.id); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); + + const res = await request(app) + .get('/api/system-notices/active') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined(); + }); + + it('SN-COLLISION-6 — hidden when app version is below 3.0.14', async () => { + process.env.APP_VERSION = '3.0.13'; + const user = setupCollisionAdmin(); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); + + const res = await request(app) + .get('/api/system-notices/active') + .set('Cookie', authCookie(user.id)); + + expect(res.status).toBe(200); + expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined(); + }); + + it('SN-COLLISION-7 — hidden after admin dismisses it', async () => { + const user = setupCollisionAdmin(); + testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run(); + + const before = await request(app) + .get('/api/system-notices/active') + .set('Cookie', authCookie(user.id)); + expect(before.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeDefined(); + + const dismiss = await request(app) + .post(`/api/system-notices/${NOTICE_ID}/dismiss`) + .set('Cookie', authCookie(user.id)); + expect(dismiss.status).toBe(204); + + const after = await request(app) + .get('/api/system-notices/active') + .set('Cookie', authCookie(user.id)); + expect(after.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined(); + }); +}); diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts index 01c718ee..487407cb 100644 --- a/server/tests/integration/trips.test.ts +++ b/server/tests/integration/trips.test.ts @@ -677,6 +677,20 @@ describe('Trip members', () => { expect(res.body.error).toMatch(/already/i); }); + it('TRIP-013 — Adding a member by whitespace-padded username resolves correctly → 201', async () => { + const { user: owner } = createUser(testDb); + const { user: invitee } = createUser(testDb, { username: 'paddeduser' }); + const trip = createTrip(testDb, owner.id, { title: 'Padded Trip' }); + + const res = await request(app) + .post(`/api/trips/${trip.id}/members`) + .set('Cookie', authCookie(owner.id)) + .send({ identifier: ' paddeduser ' }); + + expect(res.status).toBe(201); + expect(res.body.member.id).toBe(invitee.id); + }); + it('TRIP-014 — DELETE /api/trips/:id/members/:userId removes a member → 200', async () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); diff --git a/server/tests/unit/services/trimUserWhitespace.test.ts b/server/tests/unit/services/trimUserWhitespace.test.ts new file mode 100644 index 00000000..0ac5afde --- /dev/null +++ b/server/tests/unit/services/trimUserWhitespace.test.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for trimUserWhitespace — the backfill migration that normalises + * leading/trailing whitespace in stored usernames and emails. + * Tests TRIM-MIG-001 through TRIM-MIG-010. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import { trimUserWhitespace } from '../../../src/db/migrations'; + +function makeDb() { + const db = new Database(':memory:'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL DEFAULT 'x', + role TEXT NOT NULL DEFAULT 'user' + ) + `); + return db; +} + +function insert(db: Database.Database, username: string, email: string): number { + const r = db.prepare('INSERT INTO users (username, email) VALUES (?, ?)').run(username, email); + return Number(r.lastInsertRowid); +} + +function row(db: Database.Database, id: number) { + return db.prepare('SELECT username, email FROM users WHERE id = ?').get(id) as { username: string; email: string }; +} + +describe('trimUserWhitespace — clean data (no-op)', () => { + it('TRIM-MIG-001 — leaves already-clean rows untouched', () => { + const db = makeDb(); + const id = insert(db, 'alice', 'alice@example.com'); + trimUserWhitespace(db); + expect(row(db, id)).toEqual({ username: 'alice', email: 'alice@example.com' }); + }); +}); + +describe('trimUserWhitespace — non-colliding dirty rows', () => { + it('TRIM-MIG-002 — trims trailing whitespace from username', () => { + const db = makeDb(); + const id = insert(db, 'alice ', 'alice@example.com'); + trimUserWhitespace(db); + expect(row(db, id).username).toBe('alice'); + }); + + it('TRIM-MIG-003 — trims leading whitespace from username', () => { + const db = makeDb(); + const id = insert(db, ' alice', 'alice@example.com'); + trimUserWhitespace(db); + expect(row(db, id).username).toBe('alice'); + }); + + it('TRIM-MIG-004 — trims surrounding whitespace from email', () => { + const db = makeDb(); + const id = insert(db, 'alice', ' alice@example.com '); + trimUserWhitespace(db); + expect(row(db, id).email).toBe('alice@example.com'); + }); + + it('TRIM-MIG-005 — emits a console.warn for each trimmed row', () => { + const db = makeDb(); + insert(db, 'bob ', 'bob@example.com'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + trimUserWhitespace(db); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[migration] Trimmed username')); + warn.mockRestore(); + }); +}); + +describe('trimUserWhitespace — username collision handling', () => { + it('TRIM-MIG-006 — renames the dirty row to __migrated_ on collision', () => { + const db = makeDb(); + insert(db, 'carol', 'carol@example.com'); + const dirtyId = insert(db, 'carol ', 'carol2@example.com'); + trimUserWhitespace(db); + expect(row(db, dirtyId).username).toBe(`carol__migrated_${dirtyId}`); + }); + + it('TRIM-MIG-007 — emits a WHITESPACE COLLISION warning for username collision', () => { + const db = makeDb(); + insert(db, 'dan', 'dan@example.com'); + insert(db, 'dan ', 'dan2@example.com'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + trimUserWhitespace(db); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('WHITESPACE COLLISION username')); + warn.mockRestore(); + }); + + it('TRIM-MIG-008 — the renamed value does not conflict with the existing clean row', () => { + const db = makeDb(); + const cleanId = insert(db, 'eve', 'eve@example.com'); + const dirtyId = insert(db, 'eve ', 'eve2@example.com'); + trimUserWhitespace(db); + expect(row(db, cleanId).username).toBe('eve'); + expect(row(db, dirtyId).username).toBe(`eve__migrated_${dirtyId}`); + }); +}); + +describe('trimUserWhitespace — email collision handling', () => { + it('TRIM-MIG-009 — renames dirty email as __migrated_@ on collision', () => { + const db = makeDb(); + insert(db, 'frank', 'frank@example.com'); + const dirtyId = insert(db, 'frank2', ' frank@example.com '); + trimUserWhitespace(db); + expect(row(db, dirtyId).email).toBe(`frank__migrated_${dirtyId}@example.com`); + }); + + it('TRIM-MIG-010 — emits a WHITESPACE COLLISION warning for email collision', () => { + const db = makeDb(); + insert(db, 'grace', 'grace@example.com'); + insert(db, 'grace2', 'grace@example.com '); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + trimUserWhitespace(db); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('WHITESPACE COLLISION email')); + warn.mockRestore(); + }); +}); diff --git a/wiki/Internal-Network-Access.md b/wiki/Internal-Network-Access.md index dc93e134..981ae63d 100644 --- a/wiki/Internal-Network-Access.md +++ b/wiki/Internal-Network-Access.md @@ -17,13 +17,9 @@ These ranges are blocked regardless of any setting: | `169.254.0.0/16`, `fe80::/10` | Link-local / cloud metadata endpoints | | `::ffff:127.x.x.x`, `::ffff:169.254.x.x` | IPv4-mapped loopback and link-local | -In addition, hostnames ending in `.local` or `.internal` are always blocked regardless of `ALLOW_INTERNAL_NETWORK`. These suffixes are readily abused for hostname-based bypasses. - -The hostname `localhost` is not blocked at the hostname stage, but it resolves to `127.0.0.1` which is caught by the loopback rule above and is therefore always blocked. - ## Blocked unless `ALLOW_INTERNAL_NETWORK=true` -| Range | Description | +| Range / Hostname | Description | |---|---| | `10.0.0.0/8` | RFC-1918 private | | `172.16.0.0/12` | RFC-1918 private | @@ -31,6 +27,11 @@ The hostname `localhost` is not blocked at the hostname stage, but it resolves t | `100.64.0.0/10` | CGNAT / Tailscale shared address space | | `fc00::/7` | IPv6 ULA | | IPv4-mapped RFC-1918 variants | e.g. `::ffff:10.x`, `::ffff:192.168.x` | +| `*.local`, `*.internal` hostnames | mDNS / internal DNS suffixes (e.g. Docker service names, LAN hosts) | + +The hostname `localhost` is not blocked at the hostname stage, but it resolves to `127.0.0.1` which is caught by the loopback rule above and is therefore always blocked. + +`*.local` and `*.internal` hostnames are permitted when `ALLOW_INTERNAL_NETWORK=true` — the guard still resolves them to an IP and enforces all IP-level rules, so any such hostname that resolves to a loopback or link-local address remains blocked regardless. ## When to enable