From 747fee0889e1721a5ab45dd0756ab8e7b92bfb5a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:09:30 +0100 Subject: [PATCH] feat: log uploading --- lib/i18n/de.i18n.json | 6 +- lib/i18n/en.i18n.json | 6 +- lib/i18n/es.i18n.json | 6 +- lib/i18n/fr.i18n.json | 6 +- lib/i18n/it.i18n.json | 6 +- lib/i18n/ko.i18n.json | 6 +- lib/i18n/nl.i18n.json | 6 +- lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_de.g.dart | 12 ++- lib/i18n/strings_en.g.dart | 20 +++- lib/i18n/strings_es.g.dart | 12 ++- lib/i18n/strings_fr.g.dart | 12 ++- lib/i18n/strings_it.g.dart | 12 ++- lib/i18n/strings_ko.g.dart | 12 ++- lib/i18n/strings_nl.g.dart | 12 ++- lib/i18n/strings_sv.g.dart | 12 ++- lib/i18n/strings_zh.g.dart | 12 ++- lib/i18n/sv.i18n.json | 6 +- lib/i18n/zh.i18n.json | 6 +- lib/screens/settings/logs_screen.dart | 71 ++++++++++++- lib/services/plex_auth_service.dart | 16 ++- lib/services/plex_client.dart | 26 ++++- server/main.go | 142 +++++++++++++++++++++++++- 23 files changed, 393 insertions(+), 36 deletions(-) diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 31cae4d9..34467559 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -351,7 +351,10 @@ "noItemsAvailable": "Keine Elemente verfügbar", "failedToCreatePlayQueueNoItems": "Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente", "failedPlayback": "Wiedergabe für ${action} fehlgeschlagen: ${error}", - "switchingToCompatiblePlayer": "Wechsle zu kompatiblem Player..." + "switchingToCompatiblePlayer": "Wechsle zu kompatiblem Player...", + "logsUploaded": "Logs uploaded", + "logsUploadFailed": "Failed to upload logs", + "logId": "Log ID" }, "subtitlingStyling": { "stylingOptions": "Stiloptionen", @@ -500,6 +503,7 @@ "logs": { "clearLogs": "Protokolle löschen", "copyLogs": "Protokolle kopieren", + "uploadLogs": "Upload Logs", "error": "Fehler:", "stackTrace": "Stacktrace:" }, diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 37aa5a06..13c2e681 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -351,7 +351,10 @@ "noItemsAvailable": "No items available", "failedToCreatePlayQueueNoItems": "Failed to create play queue - no items", "failedPlayback": "Failed to ${action}: ${error}", - "switchingToCompatiblePlayer": "Switching to compatible player..." + "switchingToCompatiblePlayer": "Switching to compatible player...", + "logsUploaded": "Logs uploaded", + "logsUploadFailed": "Failed to upload logs", + "logId": "Log ID" }, "subtitlingStyling": { "stylingOptions": "Styling Options", @@ -500,6 +503,7 @@ "logs": { "clearLogs": "Clear Logs", "copyLogs": "Copy Logs", + "uploadLogs": "Upload Logs", "error": "Error:", "stackTrace": "Stack Trace:" }, diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 00d3050c..d74e5f42 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -351,7 +351,10 @@ "noItemsAvailable": "No hay elementos disponibles", "failedToCreatePlayQueueNoItems": "Error al crear la cola de reproducción - no hay elementos", "failedPlayback": "Error al ${action}: ${error}", - "switchingToCompatiblePlayer": "Cambiando a reproductor compatible..." + "switchingToCompatiblePlayer": "Cambiando a reproductor compatible...", + "logsUploaded": "Logs uploaded", + "logsUploadFailed": "Failed to upload logs", + "logId": "Log ID" }, "subtitlingStyling": { "stylingOptions": "Opciones de Estilo", @@ -500,6 +503,7 @@ "logs": { "clearLogs": "Borrar Logs", "copyLogs": "Copiar Logs", + "uploadLogs": "Upload Logs", "error": "Error:", "stackTrace": "Traza de la pila (Stack Trace):" }, diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 4e2b4b87..a3f7700f 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -351,7 +351,10 @@ "noItemsAvailable": "Aucun élément disponible", "failedToCreatePlayQueueNoItems": "Échec de la création de la file d'attente de lecture - aucun élément", "failedPlayback": "Echec de ${action}: ${error}", - "switchingToCompatiblePlayer": "Passage au lecteur compatible..." + "switchingToCompatiblePlayer": "Passage au lecteur compatible...", + "logsUploaded": "Logs uploaded", + "logsUploadFailed": "Failed to upload logs", + "logId": "Log ID" }, "subtitlingStyling": { "stylingOptions": "Options de style", @@ -500,6 +503,7 @@ "logs": { "clearLogs": "Effacer les logs", "copyLogs": "Copier les logs", + "uploadLogs": "Upload Logs", "error": "Erreur:", "stackTrace": "Liste des appels:" }, diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 7dbadb75..d9502c4d 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -351,7 +351,10 @@ "noItemsAvailable": "Nessun elemento disponibile", "failedToCreatePlayQueueNoItems": "Impossibile creare la coda di riproduzione - nessun elemento", "failedPlayback": "Impossibile ${action}: ${error}", - "switchingToCompatiblePlayer": "Passaggio al lettore compatibile..." + "switchingToCompatiblePlayer": "Passaggio al lettore compatibile...", + "logsUploaded": "Logs uploaded", + "logsUploadFailed": "Failed to upload logs", + "logId": "Log ID" }, "subtitlingStyling": { "stylingOptions": "Opzioni stile", @@ -500,6 +503,7 @@ "logs": { "clearLogs": "Cancella log", "copyLogs": "Copia log", + "uploadLogs": "Upload Logs", "error": "Errore:", "stackTrace": "Traccia dello stack:" }, diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index bc087fb0..7dc3a577 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -351,7 +351,10 @@ "noItemsAvailable": "사용 가능한 항목이 없습니다", "failedToCreatePlayQueueNoItems": "재생 대기열 생성 실패 - 항목 없음", "failedPlayback": "${action}을(를) 수행할 수 없습니다: ${error}", - "switchingToCompatiblePlayer": "호환되는 플레이어로 전환 중..." + "switchingToCompatiblePlayer": "호환되는 플레이어로 전환 중...", + "logsUploaded": "Logs uploaded", + "logsUploadFailed": "Failed to upload logs", + "logId": "Log ID" }, "subtitlingStyling": { "stylingOptions": "스타일 옵션", @@ -500,6 +503,7 @@ "logs": { "clearLogs": "로그 지우기", "copyLogs": "로그 복사", + "uploadLogs": "Upload Logs", "error": "오류:", "stackTrace": "스택 추적 (Stack Trace):" }, diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 3a28137a..44d36e86 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -351,7 +351,10 @@ "noItemsAvailable": "Geen items beschikbaar", "failedToCreatePlayQueueNoItems": "Kan afspeelwachtrij niet maken - geen items", "failedPlayback": "Afspelen van ${action} mislukt: ${error}", - "switchingToCompatiblePlayer": "Overschakelen naar compatibele speler..." + "switchingToCompatiblePlayer": "Overschakelen naar compatibele speler...", + "logsUploaded": "Logs uploaded", + "logsUploadFailed": "Failed to upload logs", + "logId": "Log ID" }, "subtitlingStyling": { "stylingOptions": "Opmaak opties", @@ -500,6 +503,7 @@ "logs": { "clearLogs": "Wis logs", "copyLogs": "Kopieer logs", + "uploadLogs": "Upload Logs", "error": "Fout:", "stackTrace": "Stacktracering:" }, diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index 3df43f31..f5759b1b 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 9 -/// Strings: 5067 (563 per locale) +/// Strings: 5103 (567 per locale) /// -/// Built on 2026-02-04 at 11:29 UTC +/// Built on 2026-02-07 at 18:57 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 6875626b..38fbf84b 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -511,6 +511,9 @@ class _TranslationsMessagesDe implements TranslationsMessagesEn { @override String get failedToCreatePlayQueueNoItems => 'Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente'; @override String failedPlayback({required Object action, required Object error}) => 'Wiedergabe für ${action} fehlgeschlagen: ${error}'; @override String get switchingToCompatiblePlayer => 'Wechsle zu kompatiblem Player...'; + @override String get logsUploaded => 'Logs uploaded'; + @override String get logsUploadFailed => 'Failed to upload logs'; + @override String get logId => 'Log ID'; } // Path: subtitlingStyling @@ -717,6 +720,7 @@ class _TranslationsLogsDe implements TranslationsLogsEn { // Translations @override String get clearLogs => 'Protokolle löschen'; @override String get copyLogs => 'Protokolle kopieren'; + @override String get uploadLogs => 'Upload Logs'; @override String get error => 'Fehler:'; @override String get stackTrace => 'Stacktrace:'; } @@ -1298,6 +1302,9 @@ extension on TranslationsDe { 'messages.failedToCreatePlayQueueNoItems' => 'Wiedergabewarteschlange konnte nicht erstellt werden – keine Elemente', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Wiedergabe für ${action} fehlgeschlagen: ${error}', 'messages.switchingToCompatiblePlayer' => 'Wechsle zu kompatiblem Player...', + 'messages.logsUploaded' => 'Logs uploaded', + 'messages.logsUploadFailed' => 'Failed to upload logs', + 'messages.logId' => 'Log ID', 'subtitlingStyling.stylingOptions' => 'Stiloptionen', 'subtitlingStyling.fontSize' => 'Schriftgröße', 'subtitlingStyling.textColor' => 'Textfarbe', @@ -1422,6 +1429,7 @@ extension on TranslationsDe { 'hubDetail.noItemsFound' => 'Keine Elemente gefunden', 'logs.clearLogs' => 'Protokolle löschen', 'logs.copyLogs' => 'Protokolle kopieren', + 'logs.uploadLogs' => 'Upload Logs', 'logs.error' => 'Fehler:', 'logs.stackTrace' => 'Stacktrace:', 'licenses.relatedPackages' => 'Verwandte Pakete', @@ -1488,12 +1496,12 @@ extension on TranslationsDe { 'collections.collectionName' => 'Sammlungsname', 'collections.enterCollectionName' => 'Sammlungsnamen eingeben', 'collections.addedToCollection' => 'Zur Sammlung hinzugefügt', + _ => null, + } ?? switch (path) { 'collections.errorAddingToCollection' => 'Fehler beim Hinzufügen zur Sammlung', 'collections.created' => 'Sammlung erstellt', 'collections.removeFromCollection' => 'Aus Sammlung entfernen', 'collections.removeFromCollectionConfirm' => ({required Object title}) => '"${title}" aus dieser Sammlung entfernen?', - _ => null, - } ?? switch (path) { 'collections.removedFromCollection' => 'Aus Sammlung entfernt', 'collections.removeFromCollectionFailed' => 'Entfernen aus Sammlung fehlgeschlagen', 'collections.removeFromCollectionError' => ({required Object error}) => 'Fehler beim Entfernen aus der Sammlung: ${error}', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index b948782c..20669614 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -1106,6 +1106,15 @@ class TranslationsMessagesEn { /// en: 'Switching to compatible player...' String get switchingToCompatiblePlayer => 'Switching to compatible player...'; + + /// en: 'Logs uploaded' + String get logsUploaded => 'Logs uploaded'; + + /// en: 'Failed to upload logs' + String get logsUploadFailed => 'Failed to upload logs'; + + /// en: 'Log ID' + String get logId => 'Log ID'; } // Path: subtitlingStyling @@ -1542,6 +1551,9 @@ class TranslationsLogsEn { /// en: 'Copy Logs' String get copyLogs => 'Copy Logs'; + /// en: 'Upload Logs' + String get uploadLogs => 'Upload Logs'; + /// en: 'Error:' String get error => 'Error:'; @@ -2430,6 +2442,9 @@ extension on Translations { 'messages.failedToCreatePlayQueueNoItems' => 'Failed to create play queue - no items', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Failed to ${action}: ${error}', 'messages.switchingToCompatiblePlayer' => 'Switching to compatible player...', + 'messages.logsUploaded' => 'Logs uploaded', + 'messages.logsUploadFailed' => 'Failed to upload logs', + 'messages.logId' => 'Log ID', 'subtitlingStyling.stylingOptions' => 'Styling Options', 'subtitlingStyling.fontSize' => 'Font Size', 'subtitlingStyling.textColor' => 'Text Color', @@ -2554,6 +2569,7 @@ extension on Translations { 'hubDetail.noItemsFound' => 'No items found', 'logs.clearLogs' => 'Clear Logs', 'logs.copyLogs' => 'Copy Logs', + 'logs.uploadLogs' => 'Upload Logs', 'logs.error' => 'Error:', 'logs.stackTrace' => 'Stack Trace:', 'licenses.relatedPackages' => 'Related Packages', @@ -2620,12 +2636,12 @@ extension on Translations { 'watchTogether.joinSession' => 'Join Session', 'watchTogether.joining' => 'Joining...', 'watchTogether.controlMode' => 'Control Mode', + _ => null, + } ?? switch (path) { 'watchTogether.controlModeQuestion' => 'Who can control playback?', 'watchTogether.hostOnly' => 'Host Only', 'watchTogether.anyone' => 'Anyone', 'watchTogether.hostingSession' => 'Hosting Session', - _ => null, - } ?? switch (path) { 'watchTogether.inSession' => 'In Session', 'watchTogether.sessionCode' => 'Session Code', 'watchTogether.hostControlsPlayback' => 'Host controls playback', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index d63034bd..6c39d247 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -511,6 +511,9 @@ class _TranslationsMessagesEs implements TranslationsMessagesEn { @override String get failedToCreatePlayQueueNoItems => 'Error al crear la cola de reproducción - no hay elementos'; @override String failedPlayback({required Object action, required Object error}) => 'Error al ${action}: ${error}'; @override String get switchingToCompatiblePlayer => 'Cambiando a reproductor compatible...'; + @override String get logsUploaded => 'Logs uploaded'; + @override String get logsUploadFailed => 'Failed to upload logs'; + @override String get logId => 'Log ID'; } // Path: subtitlingStyling @@ -717,6 +720,7 @@ class _TranslationsLogsEs implements TranslationsLogsEn { // Translations @override String get clearLogs => 'Borrar Logs'; @override String get copyLogs => 'Copiar Logs'; + @override String get uploadLogs => 'Upload Logs'; @override String get error => 'Error:'; @override String get stackTrace => 'Traza de la pila (Stack Trace):'; } @@ -1298,6 +1302,9 @@ extension on TranslationsEs { 'messages.failedToCreatePlayQueueNoItems' => 'Error al crear la cola de reproducción - no hay elementos', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Error al ${action}: ${error}', 'messages.switchingToCompatiblePlayer' => 'Cambiando a reproductor compatible...', + 'messages.logsUploaded' => 'Logs uploaded', + 'messages.logsUploadFailed' => 'Failed to upload logs', + 'messages.logId' => 'Log ID', 'subtitlingStyling.stylingOptions' => 'Opciones de Estilo', 'subtitlingStyling.fontSize' => 'Tamaño de Fuente', 'subtitlingStyling.textColor' => 'Color de Texto', @@ -1422,6 +1429,7 @@ extension on TranslationsEs { 'hubDetail.noItemsFound' => 'No se encontraron elementos', 'logs.clearLogs' => 'Borrar Logs', 'logs.copyLogs' => 'Copiar Logs', + 'logs.uploadLogs' => 'Upload Logs', 'logs.error' => 'Error:', 'logs.stackTrace' => 'Traza de la pila (Stack Trace):', 'licenses.relatedPackages' => 'Paquetes relacionados', @@ -1488,12 +1496,12 @@ extension on TranslationsEs { 'watchTogether.joinSession' => 'Unirse a Sesión', 'watchTogether.joining' => 'Uniendo...', 'watchTogether.controlMode' => 'Modo de Control', + _ => null, + } ?? switch (path) { 'watchTogether.controlModeQuestion' => '¿Quién puede controlar la reproducción?', 'watchTogether.hostOnly' => 'Solo el Anfitrión', 'watchTogether.anyone' => 'Cualquiera', 'watchTogether.hostingSession' => 'Anfitrión de la Sesión', - _ => null, - } ?? switch (path) { 'watchTogether.inSession' => 'En Sesión', 'watchTogether.sessionCode' => 'Código de Sesión', 'watchTogether.hostControlsPlayback' => 'El anfitrión controla la reproducción', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index d3dc748b..157135c2 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -511,6 +511,9 @@ class _TranslationsMessagesFr implements TranslationsMessagesEn { @override String get failedToCreatePlayQueueNoItems => 'Échec de la création de la file d\'attente de lecture - aucun élément'; @override String failedPlayback({required Object action, required Object error}) => 'Echec de ${action}: ${error}'; @override String get switchingToCompatiblePlayer => 'Passage au lecteur compatible...'; + @override String get logsUploaded => 'Logs uploaded'; + @override String get logsUploadFailed => 'Failed to upload logs'; + @override String get logId => 'Log ID'; } // Path: subtitlingStyling @@ -717,6 +720,7 @@ class _TranslationsLogsFr implements TranslationsLogsEn { // Translations @override String get clearLogs => 'Effacer les logs'; @override String get copyLogs => 'Copier les logs'; + @override String get uploadLogs => 'Upload Logs'; @override String get error => 'Erreur:'; @override String get stackTrace => 'Liste des appels:'; } @@ -1298,6 +1302,9 @@ extension on TranslationsFr { 'messages.failedToCreatePlayQueueNoItems' => 'Échec de la création de la file d\'attente de lecture - aucun élément', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Echec de ${action}: ${error}', 'messages.switchingToCompatiblePlayer' => 'Passage au lecteur compatible...', + 'messages.logsUploaded' => 'Logs uploaded', + 'messages.logsUploadFailed' => 'Failed to upload logs', + 'messages.logId' => 'Log ID', 'subtitlingStyling.stylingOptions' => 'Options de style', 'subtitlingStyling.fontSize' => 'Taille de la police', 'subtitlingStyling.textColor' => 'Couleur du texte', @@ -1422,6 +1429,7 @@ extension on TranslationsFr { 'hubDetail.noItemsFound' => 'Aucun élément trouvé', 'logs.clearLogs' => 'Effacer les logs', 'logs.copyLogs' => 'Copier les logs', + 'logs.uploadLogs' => 'Upload Logs', 'logs.error' => 'Erreur:', 'logs.stackTrace' => 'Liste des appels:', 'licenses.relatedPackages' => 'Package associés', @@ -1488,12 +1496,12 @@ extension on TranslationsFr { 'watchTogether.joinSession' => 'Rejoindre la session', 'watchTogether.joining' => 'Rejoindre...', 'watchTogether.controlMode' => 'Mode de contrôle', + _ => null, + } ?? switch (path) { 'watchTogether.controlModeQuestion' => 'Qui peut contrôler la lecture ?', 'watchTogether.hostOnly' => 'Hôte uniquement', 'watchTogether.anyone' => 'N\'importe qui', 'watchTogether.hostingSession' => 'Session d\'hébergement', - _ => null, - } ?? switch (path) { 'watchTogether.inSession' => 'En session', 'watchTogether.sessionCode' => 'Code de session', 'watchTogether.hostControlsPlayback' => 'L\'hôte contrôle la lecture', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 89cf1723..e4f8543c 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -511,6 +511,9 @@ class _TranslationsMessagesIt implements TranslationsMessagesEn { @override String get failedToCreatePlayQueueNoItems => 'Impossibile creare la coda di riproduzione - nessun elemento'; @override String failedPlayback({required Object action, required Object error}) => 'Impossibile ${action}: ${error}'; @override String get switchingToCompatiblePlayer => 'Passaggio al lettore compatibile...'; + @override String get logsUploaded => 'Logs uploaded'; + @override String get logsUploadFailed => 'Failed to upload logs'; + @override String get logId => 'Log ID'; } // Path: subtitlingStyling @@ -717,6 +720,7 @@ class _TranslationsLogsIt implements TranslationsLogsEn { // Translations @override String get clearLogs => 'Cancella log'; @override String get copyLogs => 'Copia log'; + @override String get uploadLogs => 'Upload Logs'; @override String get error => 'Errore:'; @override String get stackTrace => 'Traccia dello stack:'; } @@ -1298,6 +1302,9 @@ extension on TranslationsIt { 'messages.failedToCreatePlayQueueNoItems' => 'Impossibile creare la coda di riproduzione - nessun elemento', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Impossibile ${action}: ${error}', 'messages.switchingToCompatiblePlayer' => 'Passaggio al lettore compatibile...', + 'messages.logsUploaded' => 'Logs uploaded', + 'messages.logsUploadFailed' => 'Failed to upload logs', + 'messages.logId' => 'Log ID', 'subtitlingStyling.stylingOptions' => 'Opzioni stile', 'subtitlingStyling.fontSize' => 'Dimensione', 'subtitlingStyling.textColor' => 'Colore testo', @@ -1422,6 +1429,7 @@ extension on TranslationsIt { 'hubDetail.noItemsFound' => 'Nessun elemento trovato', 'logs.clearLogs' => 'Cancella log', 'logs.copyLogs' => 'Copia log', + 'logs.uploadLogs' => 'Upload Logs', 'logs.error' => 'Errore:', 'logs.stackTrace' => 'Traccia dello stack:', 'licenses.relatedPackages' => 'Pacchetti correlati', @@ -1488,12 +1496,12 @@ extension on TranslationsIt { 'collections.collectionName' => 'Nome raccolta', 'collections.enterCollectionName' => 'Inserisci nome raccolta', 'collections.addedToCollection' => 'Aggiunto alla raccolta', + _ => null, + } ?? switch (path) { 'collections.errorAddingToCollection' => 'Errore nell\'aggiunta alla raccolta', 'collections.created' => 'Raccolta creata', 'collections.removeFromCollection' => 'Rimuovi dalla raccolta', 'collections.removeFromCollectionConfirm' => ({required Object title}) => 'Rimuovere "${title}" da questa raccolta?', - _ => null, - } ?? switch (path) { 'collections.removedFromCollection' => 'Rimosso dalla raccolta', 'collections.removeFromCollectionFailed' => 'Impossibile rimuovere dalla raccolta', 'collections.removeFromCollectionError' => ({required Object error}) => 'Errore durante la rimozione dalla raccolta: ${error}', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 513057af..c104f099 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -511,6 +511,9 @@ class _TranslationsMessagesKo implements TranslationsMessagesEn { @override String get failedToCreatePlayQueueNoItems => '재생 대기열 생성 실패 - 항목 없음'; @override String failedPlayback({required Object action, required Object error}) => '${action}을(를) 수행할 수 없습니다: ${error}'; @override String get switchingToCompatiblePlayer => '호환되는 플레이어로 전환 중...'; + @override String get logsUploaded => 'Logs uploaded'; + @override String get logsUploadFailed => 'Failed to upload logs'; + @override String get logId => 'Log ID'; } // Path: subtitlingStyling @@ -717,6 +720,7 @@ class _TranslationsLogsKo implements TranslationsLogsEn { // Translations @override String get clearLogs => '로그 지우기'; @override String get copyLogs => '로그 복사'; + @override String get uploadLogs => 'Upload Logs'; @override String get error => '오류:'; @override String get stackTrace => '스택 추적 (Stack Trace):'; } @@ -1298,6 +1302,9 @@ extension on TranslationsKo { 'messages.failedToCreatePlayQueueNoItems' => '재생 대기열 생성 실패 - 항목 없음', 'messages.failedPlayback' => ({required Object action, required Object error}) => '${action}을(를) 수행할 수 없습니다: ${error}', 'messages.switchingToCompatiblePlayer' => '호환되는 플레이어로 전환 중...', + 'messages.logsUploaded' => 'Logs uploaded', + 'messages.logsUploadFailed' => 'Failed to upload logs', + 'messages.logId' => 'Log ID', 'subtitlingStyling.stylingOptions' => '스타일 옵션', 'subtitlingStyling.fontSize' => '글자 크기', 'subtitlingStyling.textColor' => '텍스트 색상', @@ -1422,6 +1429,7 @@ extension on TranslationsKo { 'hubDetail.noItemsFound' => '항목이 없습니다', 'logs.clearLogs' => '로그 지우기', 'logs.copyLogs' => '로그 복사', + 'logs.uploadLogs' => 'Upload Logs', 'logs.error' => '오류:', 'logs.stackTrace' => '스택 추적 (Stack Trace):', 'licenses.relatedPackages' => '관련 소프트웨어 패키지', @@ -1488,12 +1496,12 @@ extension on TranslationsKo { 'watchTogether.joinSession' => '세션 참여', 'watchTogether.joining' => '참가 중...', 'watchTogether.controlMode' => '제어 모드', + _ => null, + } ?? switch (path) { 'watchTogether.controlModeQuestion' => '누가 재생을 제어할 수 있나요?', 'watchTogether.hostOnly' => '호스트만', 'watchTogether.anyone' => '누구나', 'watchTogether.hostingSession' => '세션 호스팅', - _ => null, - } ?? switch (path) { 'watchTogether.inSession' => '세션 중', 'watchTogether.sessionCode' => '세션 코드', 'watchTogether.hostControlsPlayback' => '호스트 재생 제어', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index 60a7c675..e7f37679 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -511,6 +511,9 @@ class _TranslationsMessagesNl implements TranslationsMessagesEn { @override String get failedToCreatePlayQueueNoItems => 'Kan afspeelwachtrij niet maken - geen items'; @override String failedPlayback({required Object action, required Object error}) => 'Afspelen van ${action} mislukt: ${error}'; @override String get switchingToCompatiblePlayer => 'Overschakelen naar compatibele speler...'; + @override String get logsUploaded => 'Logs uploaded'; + @override String get logsUploadFailed => 'Failed to upload logs'; + @override String get logId => 'Log ID'; } // Path: subtitlingStyling @@ -717,6 +720,7 @@ class _TranslationsLogsNl implements TranslationsLogsEn { // Translations @override String get clearLogs => 'Wis logs'; @override String get copyLogs => 'Kopieer logs'; + @override String get uploadLogs => 'Upload Logs'; @override String get error => 'Fout:'; @override String get stackTrace => 'Stacktracering:'; } @@ -1298,6 +1302,9 @@ extension on TranslationsNl { 'messages.failedToCreatePlayQueueNoItems' => 'Kan afspeelwachtrij niet maken - geen items', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Afspelen van ${action} mislukt: ${error}', 'messages.switchingToCompatiblePlayer' => 'Overschakelen naar compatibele speler...', + 'messages.logsUploaded' => 'Logs uploaded', + 'messages.logsUploadFailed' => 'Failed to upload logs', + 'messages.logId' => 'Log ID', 'subtitlingStyling.stylingOptions' => 'Opmaak opties', 'subtitlingStyling.fontSize' => 'Lettergrootte', 'subtitlingStyling.textColor' => 'Tekstkleur', @@ -1422,6 +1429,7 @@ extension on TranslationsNl { 'hubDetail.noItemsFound' => 'Geen items gevonden', 'logs.clearLogs' => 'Wis logs', 'logs.copyLogs' => 'Kopieer logs', + 'logs.uploadLogs' => 'Upload Logs', 'logs.error' => 'Fout:', 'logs.stackTrace' => 'Stacktracering:', 'licenses.relatedPackages' => 'Gerelateerde pakketten', @@ -1488,12 +1496,12 @@ extension on TranslationsNl { 'collections.collectionName' => 'Collectienaam', 'collections.enterCollectionName' => 'Voer collectienaam in', 'collections.addedToCollection' => 'Toegevoegd aan collectie', + _ => null, + } ?? switch (path) { 'collections.errorAddingToCollection' => 'Fout bij toevoegen aan collectie', 'collections.created' => 'Collectie gemaakt', 'collections.removeFromCollection' => 'Verwijderen uit collectie', 'collections.removeFromCollectionConfirm' => ({required Object title}) => '"${title}" uit deze collectie verwijderen?', - _ => null, - } ?? switch (path) { 'collections.removedFromCollection' => 'Uit collectie verwijderd', 'collections.removeFromCollectionFailed' => 'Verwijderen uit collectie mislukt', 'collections.removeFromCollectionError' => ({required Object error}) => 'Fout bij verwijderen uit collectie: ${error}', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index f9d264a7..14716e92 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -511,6 +511,9 @@ class _TranslationsMessagesSv implements TranslationsMessagesEn { @override String get failedToCreatePlayQueueNoItems => 'Det gick inte att skapa uppspelningskö – inga objekt'; @override String failedPlayback({required Object action, required Object error}) => 'Kunde inte ${action}: ${error}'; @override String get switchingToCompatiblePlayer => 'Byter till kompatibel spelare...'; + @override String get logsUploaded => 'Logs uploaded'; + @override String get logsUploadFailed => 'Failed to upload logs'; + @override String get logId => 'Log ID'; } // Path: subtitlingStyling @@ -717,6 +720,7 @@ class _TranslationsLogsSv implements TranslationsLogsEn { // Translations @override String get clearLogs => 'Rensa loggar'; @override String get copyLogs => 'Kopiera loggar'; + @override String get uploadLogs => 'Upload Logs'; @override String get error => 'Fel:'; @override String get stackTrace => 'Stack trace:'; } @@ -1298,6 +1302,9 @@ extension on TranslationsSv { 'messages.failedToCreatePlayQueueNoItems' => 'Det gick inte att skapa uppspelningskö – inga objekt', 'messages.failedPlayback' => ({required Object action, required Object error}) => 'Kunde inte ${action}: ${error}', 'messages.switchingToCompatiblePlayer' => 'Byter till kompatibel spelare...', + 'messages.logsUploaded' => 'Logs uploaded', + 'messages.logsUploadFailed' => 'Failed to upload logs', + 'messages.logId' => 'Log ID', 'subtitlingStyling.stylingOptions' => 'Stilalternativ', 'subtitlingStyling.fontSize' => 'Teckenstorlek', 'subtitlingStyling.textColor' => 'Textfärg', @@ -1422,6 +1429,7 @@ extension on TranslationsSv { 'hubDetail.noItemsFound' => 'Inga objekt hittades', 'logs.clearLogs' => 'Rensa loggar', 'logs.copyLogs' => 'Kopiera loggar', + 'logs.uploadLogs' => 'Upload Logs', 'logs.error' => 'Fel:', 'logs.stackTrace' => 'Stack trace:', 'licenses.relatedPackages' => 'Relaterade paket', @@ -1488,12 +1496,12 @@ extension on TranslationsSv { 'collections.collectionName' => 'Samlingsnamn', 'collections.enterCollectionName' => 'Ange samlingsnamn', 'collections.addedToCollection' => 'Tillagd i samling', + _ => null, + } ?? switch (path) { 'collections.errorAddingToCollection' => 'Fel vid tillägg i samling', 'collections.created' => 'Samling skapad', 'collections.removeFromCollection' => 'Ta bort från samling', 'collections.removeFromCollectionConfirm' => ({required Object title}) => 'Ta bort "${title}" från denna samling?', - _ => null, - } ?? switch (path) { 'collections.removedFromCollection' => 'Borttagen från samling', 'collections.removeFromCollectionFailed' => 'Misslyckades med att ta bort från samling', 'collections.removeFromCollectionError' => ({required Object error}) => 'Fel vid borttagning från samling: ${error}', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 80a68a6d..3ea256fe 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -511,6 +511,9 @@ class _TranslationsMessagesZh implements TranslationsMessagesEn { @override String get failedToCreatePlayQueueNoItems => '创建播放队列失败 - 没有项目'; @override String failedPlayback({required Object action, required Object error}) => '无法${action}: ${error}'; @override String get switchingToCompatiblePlayer => '正在切换到兼容的播放器...'; + @override String get logsUploaded => 'Logs uploaded'; + @override String get logsUploadFailed => 'Failed to upload logs'; + @override String get logId => 'Log ID'; } // Path: subtitlingStyling @@ -717,6 +720,7 @@ class _TranslationsLogsZh implements TranslationsLogsEn { // Translations @override String get clearLogs => '清除日志'; @override String get copyLogs => '复制日志'; + @override String get uploadLogs => 'Upload Logs'; @override String get error => '错误:'; @override String get stackTrace => '堆栈跟踪 (Stack Trace):'; } @@ -1298,6 +1302,9 @@ extension on TranslationsZh { 'messages.failedToCreatePlayQueueNoItems' => '创建播放队列失败 - 没有项目', 'messages.failedPlayback' => ({required Object action, required Object error}) => '无法${action}: ${error}', 'messages.switchingToCompatiblePlayer' => '正在切换到兼容的播放器...', + 'messages.logsUploaded' => 'Logs uploaded', + 'messages.logsUploadFailed' => 'Failed to upload logs', + 'messages.logId' => 'Log ID', 'subtitlingStyling.stylingOptions' => '样式选项', 'subtitlingStyling.fontSize' => '字号', 'subtitlingStyling.textColor' => '文本颜色', @@ -1422,6 +1429,7 @@ extension on TranslationsZh { 'hubDetail.noItemsFound' => '未找到项目', 'logs.clearLogs' => '清除日志', 'logs.copyLogs' => '复制日志', + 'logs.uploadLogs' => 'Upload Logs', 'logs.error' => '错误:', 'logs.stackTrace' => '堆栈跟踪 (Stack Trace):', 'licenses.relatedPackages' => '相关软件包', @@ -1488,12 +1496,12 @@ extension on TranslationsZh { 'collections.collectionName' => '合集名称', 'collections.enterCollectionName' => '输入合集名称', 'collections.addedToCollection' => '已添加到合集', + _ => null, + } ?? switch (path) { 'collections.errorAddingToCollection' => '添加到合集失败', 'collections.created' => '已创建合集', 'collections.removeFromCollection' => '从合集移除', 'collections.removeFromCollectionConfirm' => ({required Object title}) => '将“${title}”从此合集移除?', - _ => null, - } ?? switch (path) { 'collections.removedFromCollection' => '已从合集移除', 'collections.removeFromCollectionFailed' => '从合集移除失败', 'collections.removeFromCollectionError' => ({required Object error}) => '从合集移除时出错:${error}', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index b2ea9d75..61b81b37 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -351,7 +351,10 @@ "noItemsAvailable": "Inga objekt tillgängliga", "failedToCreatePlayQueueNoItems": "Det gick inte att skapa uppspelningskö – inga objekt", "failedPlayback": "Kunde inte ${action}: ${error}", - "switchingToCompatiblePlayer": "Byter till kompatibel spelare..." + "switchingToCompatiblePlayer": "Byter till kompatibel spelare...", + "logsUploaded": "Logs uploaded", + "logsUploadFailed": "Failed to upload logs", + "logId": "Log ID" }, "subtitlingStyling": { "stylingOptions": "Stilalternativ", @@ -500,6 +503,7 @@ "logs": { "clearLogs": "Rensa loggar", "copyLogs": "Kopiera loggar", + "uploadLogs": "Upload Logs", "error": "Fel:", "stackTrace": "Stack trace:" }, diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 3268603c..f14a93d4 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -351,7 +351,10 @@ "noItemsAvailable": "没有可用的项目", "failedToCreatePlayQueueNoItems": "创建播放队列失败 - 没有项目", "failedPlayback": "无法${action}: ${error}", - "switchingToCompatiblePlayer": "正在切换到兼容的播放器..." + "switchingToCompatiblePlayer": "正在切换到兼容的播放器...", + "logsUploaded": "Logs uploaded", + "logsUploadFailed": "Failed to upload logs", + "logId": "Log ID" }, "subtitlingStyling": { "stylingOptions": "样式选项", @@ -500,6 +503,7 @@ "logs": { "clearLogs": "清除日志", "copyLogs": "复制日志", + "uploadLogs": "Upload Logs", "error": "错误:", "stackTrace": "堆栈跟踪 (Stack Trace):" }, diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 009d3400..2b23109b 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -46,7 +49,7 @@ class _LogsScreenState extends State { showSuccessSnackBar(context, t.messages.logsCleared); } - void _copyAllLogs() { + String _formatAllLogs() { final buffer = StringBuffer(); bool isFirst = true; for (final log in _logs.reversed) { @@ -63,10 +66,69 @@ class _LogsScreenState extends State { buffer.write('\nStack trace:\n${log.stackTrace}'); } } - Clipboard.setData(ClipboardData(text: buffer.toString())); + return buffer.toString(); + } + + void _copyAllLogs() { + Clipboard.setData(ClipboardData(text: _formatAllLogs())); showSuccessSnackBar(context, t.messages.logsCopied); } + Future _uploadLogs() async { + final logText = _formatAllLogs(); + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + + try { + final response = await Dio().post( + 'https://ice.plezy.app/logs', + data: logText, + options: Options(contentType: 'text/plain'), + ); + + if (!mounted) return; + Navigator.of(context).pop(); // dismiss loading + + final id = + (jsonDecode(response.data is String ? response.data : jsonEncode(response.data)) + as Map)['id'] + as String; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.messages.logsUploaded), + content: Row( + children: [ + Text('${t.messages.logId}: '), + SelectableText( + id, + style: const TextStyle(fontWeight: FontWeight.bold, fontFamily: 'monospace', fontSize: 18), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.copy, size: 20), + onPressed: () { + Clipboard.setData(ClipboardData(text: id)); + showSuccessSnackBar(ctx, t.messages.logsCopied); + }, + ), + ], + ), + actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(t.common.close))], + ), + ); + } catch (_) { + if (!mounted) return; + Navigator.of(context).pop(); // dismiss loading + showErrorSnackBar(context, t.messages.logsUploadFailed); + } + } + Color _getLevelColor(Level level) { switch (level) { case Level.error: @@ -115,6 +177,11 @@ class _LogsScreenState extends State { onPressed: _loadLogs, tooltip: t.common.refresh, ), + IconButton( + icon: const AppIcon(Symbols.upload_rounded, fill: 1), + onPressed: _logs.isNotEmpty ? _uploadLogs : null, + tooltip: t.logs.uploadLogs, + ), IconButton( icon: const AppIcon(Symbols.content_copy_rounded, fill: 1), onPressed: _logs.isNotEmpty ? _copyAllLogs : null, diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 963a122a..530229c4 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -413,6 +413,16 @@ class PlexServer { PlexClient.testConnectionWithLatency(candidate.url, accessToken, timeout: raceTimeout).then((result) { completedTests++; + if (!result.success) { + appLogger.w('Connection candidate failed', error: { + 'url': candidate.url, + 'type': candidate.connection.displayType, + 'https': candidate.isHttps, + 'error': result.error, + 'latencyMs': result.latencyMs, + }); + } + if (result.success && !completer.isCompleted) { completer.complete(candidate); } @@ -425,7 +435,11 @@ class PlexServer { firstCandidate = await completer.future; if (firstCandidate == null) { - appLogger.e('No working server connections after race'); + appLogger.e('No working server connections after race', error: { + 'server': name, + 'candidateCount': totalCandidates, + 'types': candidates.map((c) => c.connection.displayType).toSet().toList(), + }); return; // No working connections found } appLogger.i( diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index a307fdbf..9c183299 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -36,8 +36,9 @@ class PlexStreamType { class ConnectionTestResult { final bool success; final int latencyMs; + final String? error; - ConnectionTestResult({required this.success, required this.latencyMs}); + ConnectionTestResult({required this.success, required this.latencyMs, this.error}); } class PlexClient { @@ -161,10 +162,29 @@ class PlexClient { stopwatch.stop(); final success = response.statusCode == 200 || response.statusCode == 401; - return ConnectionTestResult(success: success, latencyMs: stopwatch.elapsedMilliseconds); + return ConnectionTestResult( + success: success, + latencyMs: stopwatch.elapsedMilliseconds, + error: success ? null : 'HTTP ${response.statusCode}', + ); } catch (e) { stopwatch.stop(); - return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds); + String error; + if (e is DioException) { + error = e.type == DioExceptionType.connectionTimeout + ? 'Connection timeout' + : e.type == DioExceptionType.receiveTimeout + ? 'Receive timeout' + : e.type == DioExceptionType.connectionError + ? 'Connection error' + : e.type.name; + if (e.response?.statusCode != null) { + error += ' (HTTP ${e.response!.statusCode})'; + } + } else { + error = e.runtimeType.toString(); + } + return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: error); } } diff --git a/server/main.go b/server/main.go index cf3f94df..d2d90a81 100644 --- a/server/main.go +++ b/server/main.go @@ -1,10 +1,15 @@ package main import ( + "crypto/rand" "encoding/json" "flag" + "io" "log" + "math/big" + "net" "net/http" + "strings" "sync" "time" @@ -23,6 +28,10 @@ const ( pongWait = 60 * time.Second pingInterval = 30 * time.Second maxMessageSize = 64 * 1024 + maxLogSize = 1 * 1024 * 1024 // 1MB + logMaxAge = 7 * 24 * time.Hour + logIDLength = 5 + logRateInterval = 1 * time.Minute ) var upgrader = websocket.Upgrader{ @@ -138,15 +147,63 @@ func (r *Room) sendTo(targetID string, msg serverMsg) bool { return true } +// --- Log store --- + +type logEntry struct { + Text string + ExpiresAt time.Time +} + +type logStore struct { + entries map[string]logEntry + rateLimit map[string]time.Time // IP -> last upload time + mu sync.RWMutex +} + +func newLogStore() *logStore { + return &logStore{ + entries: make(map[string]logEntry), + rateLimit: make(map[string]time.Time), + } +} + +const logIDChars = "abcdefghijklmnopqrstuvwxyz0123456789" + +func generateLogID() string { + b := make([]byte, logIDLength) + for i := range b { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(logIDChars)))) + b[i] = logIDChars[n.Int64()] + } + return string(b) +} + +func (ls *logStore) cleanup() { + ls.mu.Lock() + defer ls.mu.Unlock() + now := time.Now() + for id, entry := range ls.entries { + if now.After(entry.ExpiresAt) { + delete(ls.entries, id) + } + } + for ip, lastTime := range ls.rateLimit { + if now.Sub(lastTime) > logRateInterval { + delete(ls.rateLimit, ip) + } + } +} + // --- Server --- type Server struct { rooms map[string]*Room + logs *logStore mu sync.RWMutex } func newServer() *Server { - s := &Server{rooms: make(map[string]*Room)} + s := &Server{rooms: make(map[string]*Room), logs: newLogStore()} go s.cleanupLoop() return s } @@ -169,9 +226,90 @@ func (s *Server) cleanupLoop() { } } s.mu.Unlock() + s.logs.cleanup() } } +func clientIP(r *http.Request) string { + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + return strings.SplitN(fwd, ",", 2)[0] + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func (s *Server) handlePostLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + ip := clientIP(r) + s.logs.mu.Lock() + if last, ok := s.logs.rateLimit[ip]; ok && time.Since(last) < logRateInterval { + s.logs.mu.Unlock() + http.Error(w, "Rate limited: 1 upload per minute", http.StatusTooManyRequests) + return + } + s.logs.rateLimit[ip] = time.Now() + s.logs.mu.Unlock() + + body, err := io.ReadAll(io.LimitReader(r.Body, maxLogSize+1)) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + if len(body) > maxLogSize { + http.Error(w, "Log too large (max 1MB)", http.StatusRequestEntityTooLarge) + return + } + if len(body) == 0 { + http.Error(w, "Empty body", http.StatusBadRequest) + return + } + + id := generateLogID() + s.logs.mu.Lock() + s.logs.entries[id] = logEntry{ + Text: string(body), + ExpiresAt: time.Now().Add(logMaxAge), + } + s.logs.mu.Unlock() + + log.Printf("logs: stored %s (%d bytes) from %s", id, len(body), ip) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"id": id}) +} + +func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/logs/") + if id == "" || len(id) != logIDLength { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + s.logs.mu.RLock() + entry, ok := s.logs.entries[id] + s.logs.mu.RUnlock() + + if !ok || time.Now().After(entry.ExpiresAt) { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write([]byte(entry.Text)) +} + func (s *Server) sendError(conn *websocket.Conn, code, message string) { data, _ := json.Marshal(serverMsg{Type: "error", Code: code, Message: message}) conn.SetWriteDeadline(time.Now().Add(writeWait)) @@ -359,6 +497,8 @@ func main() { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) }) + mux.HandleFunc("/logs", srv.handlePostLogs) + mux.HandleFunc("/logs/", srv.handleGetLogs) if *dev { log.Println("Starting dev server on :8080")