feat: oauth proxy for mal/anilist auth
This commit is contained in:
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "Åbn ${service} for at aktivere",
|
||||
"waitingForAuthorization": "Venter på godkendelse…",
|
||||
"codeCopied": "Kode kopieret"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "Log ind på ${service}",
|
||||
"body": "Scan denne QR-kode med din telefon, eller åbn URL'en nedenfor på en enhed med browser.",
|
||||
"openToSignIn": "Åbn ${service} for at logge ind",
|
||||
"urlCopied": "URL kopieret"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "${service} zum Aktivieren öffnen",
|
||||
"waitingForAuthorization": "Warte auf Autorisierung…",
|
||||
"codeCopied": "Code kopiert"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "Bei ${service} anmelden",
|
||||
"body": "Scanne diesen QR-Code mit deinem Handy oder öffne die URL unten auf einem Gerät mit Browser.",
|
||||
"openToSignIn": "${service} zum Anmelden öffnen",
|
||||
"urlCopied": "URL kopiert"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "Open ${service} to activate",
|
||||
"waitingForAuthorization": "Waiting for authorization…",
|
||||
"codeCopied": "Code copied"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "Sign in to ${service}",
|
||||
"body": "Scan this QR code with your phone, or open the URL below on any device with a browser.",
|
||||
"openToSignIn": "Open ${service} to sign in",
|
||||
"urlCopied": "URL copied"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "Abrir ${service} para activar",
|
||||
"waitingForAuthorization": "Esperando autorización…",
|
||||
"codeCopied": "Código copiado"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "Inicia sesión en ${service}",
|
||||
"body": "Escanea este código QR con tu teléfono, o abre la URL de abajo en cualquier dispositivo con navegador.",
|
||||
"openToSignIn": "Abrir ${service} para iniciar sesión",
|
||||
"urlCopied": "URL copiada"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "Ouvrir ${service} pour activer",
|
||||
"waitingForAuthorization": "En attente d'autorisation…",
|
||||
"codeCopied": "Code copié"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "Se connecter à ${service}",
|
||||
"body": "Scannez ce code QR avec votre téléphone, ou ouvrez l'URL ci-dessous sur un appareil équipé d'un navigateur.",
|
||||
"openToSignIn": "Ouvrir ${service} pour se connecter",
|
||||
"urlCopied": "URL copiée"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "Apri ${service} per attivare",
|
||||
"waitingForAuthorization": "In attesa di autorizzazione…",
|
||||
"codeCopied": "Codice copiato"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "Accedi a ${service}",
|
||||
"body": "Scansiona questo codice QR con il telefono oppure apri l'URL sottostante su un dispositivo con browser.",
|
||||
"openToSignIn": "Apri ${service} per accedere",
|
||||
"urlCopied": "URL copiato"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "${service} を開いて有効化",
|
||||
"waitingForAuthorization": "認証を待っています…",
|
||||
"codeCopied": "コードをコピーしました"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "${service} にサインイン",
|
||||
"body": "このQRコードをスマートフォンで読み取るか、下のURLをブラウザで開いてください。",
|
||||
"openToSignIn": "${service} を開いてサインイン",
|
||||
"urlCopied": "URLをコピーしました"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "활성화하려면 ${service} 열기",
|
||||
"waitingForAuthorization": "인증을 기다리는 중…",
|
||||
"codeCopied": "코드가 복사되었습니다"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "${service}에 로그인",
|
||||
"body": "휴대전화로 이 QR 코드를 스캔하거나 아래 URL을 브라우저가 있는 기기에서 여세요.",
|
||||
"openToSignIn": "로그인하려면 ${service} 열기",
|
||||
"urlCopied": "URL이 복사되었습니다"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "Åpne ${service} for å aktivere",
|
||||
"waitingForAuthorization": "Venter på godkjenning…",
|
||||
"codeCopied": "Kode kopiert"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "Logg inn på ${service}",
|
||||
"body": "Skann denne QR-koden med telefonen din, eller åpne URL-en under på en enhet med nettleser.",
|
||||
"openToSignIn": "Åpne ${service} for å logge inn",
|
||||
"urlCopied": "URL kopiert"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "Open ${service} om te activeren",
|
||||
"waitingForAuthorization": "Wachten op autorisatie…",
|
||||
"codeCopied": "Code gekopieerd"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "Aanmelden bij ${service}",
|
||||
"body": "Scan deze QR-code met je telefoon, of open onderstaande URL op een apparaat met browser.",
|
||||
"openToSignIn": "${service} openen om aan te melden",
|
||||
"urlCopied": "URL gekopieerd"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "Otwórz ${service}, aby aktywować",
|
||||
"waitingForAuthorization": "Oczekiwanie na autoryzację…",
|
||||
"codeCopied": "Kod skopiowany"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "Zaloguj się do ${service}",
|
||||
"body": "Zeskanuj ten kod QR telefonem lub otwórz poniższy adres URL na dowolnym urządzeniu z przeglądarką.",
|
||||
"openToSignIn": "Otwórz ${service}, aby się zalogować",
|
||||
"urlCopied": "URL skopiowany"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "Abrir ${service} para ativar",
|
||||
"waitingForAuthorization": "Aguardando autorização…",
|
||||
"codeCopied": "Código copiado"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "Entrar no ${service}",
|
||||
"body": "Escaneie este código QR com o celular, ou abra o URL abaixo em qualquer dispositivo com navegador.",
|
||||
"openToSignIn": "Abrir ${service} para entrar",
|
||||
"urlCopied": "URL copiado"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "Открыть ${service} для активации",
|
||||
"waitingForAuthorization": "Ожидание авторизации…",
|
||||
"codeCopied": "Код скопирован"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "Войти в ${service}",
|
||||
"body": "Отсканируйте этот QR-код телефоном или откройте ссылку ниже на любом устройстве с браузером.",
|
||||
"openToSignIn": "Открыть ${service} для входа",
|
||||
"urlCopied": "URL скопирован"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 15
|
||||
/// Strings: 14145 (943 per locale)
|
||||
/// Strings: 14205 (947 per locale)
|
||||
///
|
||||
/// Built on 2026-04-24 at 06:22 UTC
|
||||
/// Built on 2026-04-24 at 07:00 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersDa implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => 'Kunne ikke forbinde til ${service}. Prøv igen.';
|
||||
@override late final _TranslationsTrackersServicesDa services = _TranslationsTrackersServicesDa._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodeDa deviceCode = _TranslationsTrackersDeviceCodeDa._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyDa oauthProxy = _TranslationsTrackersOauthProxyDa._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeDa implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => 'Kode kopieret';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyDa implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyDa._(this._root);
|
||||
|
||||
final TranslationsDa _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => 'Log ind på ${service}';
|
||||
@override String get body => 'Scan denne QR-kode med din telefon, eller åbn URL\'en nedenfor på en enhed med browser.';
|
||||
@override String openToSignIn({required Object service}) => 'Åbn ${service} for at logge ind';
|
||||
@override String get urlCopied => 'URL kopieret';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <da>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsDa {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Åbn ${service} for at aktivere',
|
||||
'trackers.deviceCode.waitingForAuthorization' => 'Venter på godkendelse…',
|
||||
'trackers.deviceCode.codeCopied' => 'Kode kopieret',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => 'Log ind på ${service}',
|
||||
'trackers.oauthProxy.body' => 'Scan denne QR-kode med din telefon, eller åbn URL\'en nedenfor på en enhed med browser.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Åbn ${service} for at logge ind',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL kopieret',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersDe implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => 'Verbindung zu ${service} fehlgeschlagen. Versuche es erneut.';
|
||||
@override late final _TranslationsTrackersServicesDe services = _TranslationsTrackersServicesDe._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodeDe deviceCode = _TranslationsTrackersDeviceCodeDe._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyDe oauthProxy = _TranslationsTrackersOauthProxyDe._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeDe implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => 'Code kopiert';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyDe implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyDe._(this._root);
|
||||
|
||||
final TranslationsDe _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => 'Bei ${service} anmelden';
|
||||
@override String get body => 'Scanne diesen QR-Code mit deinem Handy oder öffne die URL unten auf einem Gerät mit Browser.';
|
||||
@override String openToSignIn({required Object service}) => '${service} zum Anmelden öffnen';
|
||||
@override String get urlCopied => 'URL kopiert';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <de>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsDe {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => '${service} zum Aktivieren öffnen',
|
||||
'trackers.deviceCode.waitingForAuthorization' => 'Warte auf Autorisierung…',
|
||||
'trackers.deviceCode.codeCopied' => 'Code kopiert',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => 'Bei ${service} anmelden',
|
||||
'trackers.oauthProxy.body' => 'Scanne diesen QR-Code mit deinem Handy oder öffne die URL unten auf einem Gerät mit Browser.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => '${service} zum Anmelden öffnen',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL kopiert',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2990,6 +2990,7 @@ class TranslationsTrackersEn {
|
||||
|
||||
late final TranslationsTrackersServicesEn services = TranslationsTrackersServicesEn._(_root);
|
||||
late final TranslationsTrackersDeviceCodeEn deviceCode = TranslationsTrackersDeviceCodeEn._(_root);
|
||||
late final TranslationsTrackersOauthProxyEn oauthProxy = TranslationsTrackersOauthProxyEn._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -3376,6 +3377,27 @@ class TranslationsTrackersDeviceCodeEn {
|
||||
String get codeCopied => 'Code copied';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class TranslationsTrackersOauthProxyEn {
|
||||
TranslationsTrackersOauthProxyEn._(this._root);
|
||||
|
||||
final Translations _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
|
||||
/// en: 'Sign in to ${service}'
|
||||
String title({required Object service}) => 'Sign in to ${service}';
|
||||
|
||||
/// en: 'Scan this QR code with your phone, or open the URL below on any device with a browser.'
|
||||
String get body => 'Scan this QR code with your phone, or open the URL below on any device with a browser.';
|
||||
|
||||
/// en: 'Open ${service} to sign in'
|
||||
String openToSignIn({required Object service}) => 'Open ${service} to sign in';
|
||||
|
||||
/// en: 'URL copied'
|
||||
String get urlCopied => 'URL copied';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <en>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -4329,6 +4351,10 @@ extension on Translations {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} to activate',
|
||||
'trackers.deviceCode.waitingForAuthorization' => 'Waiting for authorization…',
|
||||
'trackers.deviceCode.codeCopied' => 'Code copied',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => 'Sign in to ${service}',
|
||||
'trackers.oauthProxy.body' => 'Scan this QR code with your phone, or open the URL below on any device with a browser.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Open ${service} to sign in',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL copied',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersEs implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => 'No se pudo conectar a ${service}. Inténtalo de nuevo.';
|
||||
@override late final _TranslationsTrackersServicesEs services = _TranslationsTrackersServicesEs._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodeEs deviceCode = _TranslationsTrackersDeviceCodeEs._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyEs oauthProxy = _TranslationsTrackersOauthProxyEs._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeEs implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => 'Código copiado';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyEs implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyEs._(this._root);
|
||||
|
||||
final TranslationsEs _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => 'Inicia sesión en ${service}';
|
||||
@override String get body => 'Escanea este código QR con tu teléfono, o abre la URL de abajo en cualquier dispositivo con navegador.';
|
||||
@override String openToSignIn({required Object service}) => 'Abrir ${service} para iniciar sesión';
|
||||
@override String get urlCopied => 'URL copiada';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <es>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsEs {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Abrir ${service} para activar',
|
||||
'trackers.deviceCode.waitingForAuthorization' => 'Esperando autorización…',
|
||||
'trackers.deviceCode.codeCopied' => 'Código copiado',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => 'Inicia sesión en ${service}',
|
||||
'trackers.oauthProxy.body' => 'Escanea este código QR con tu teléfono, o abre la URL de abajo en cualquier dispositivo con navegador.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Abrir ${service} para iniciar sesión',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL copiada',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersFr implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => 'Échec de la connexion à ${service}. Réessayez.';
|
||||
@override late final _TranslationsTrackersServicesFr services = _TranslationsTrackersServicesFr._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodeFr deviceCode = _TranslationsTrackersDeviceCodeFr._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyFr oauthProxy = _TranslationsTrackersOauthProxyFr._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeFr implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => 'Code copié';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyFr implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyFr._(this._root);
|
||||
|
||||
final TranslationsFr _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => 'Se connecter à ${service}';
|
||||
@override String get body => 'Scannez ce code QR avec votre téléphone, ou ouvrez l\'URL ci-dessous sur un appareil équipé d\'un navigateur.';
|
||||
@override String openToSignIn({required Object service}) => 'Ouvrir ${service} pour se connecter';
|
||||
@override String get urlCopied => 'URL copiée';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <fr>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsFr {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Ouvrir ${service} pour activer',
|
||||
'trackers.deviceCode.waitingForAuthorization' => 'En attente d\'autorisation…',
|
||||
'trackers.deviceCode.codeCopied' => 'Code copié',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => 'Se connecter à ${service}',
|
||||
'trackers.oauthProxy.body' => 'Scannez ce code QR avec votre téléphone, ou ouvrez l\'URL ci-dessous sur un appareil équipé d\'un navigateur.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Ouvrir ${service} pour se connecter',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL copiée',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersIt implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => 'Impossibile connettersi a ${service}. Riprova.';
|
||||
@override late final _TranslationsTrackersServicesIt services = _TranslationsTrackersServicesIt._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodeIt deviceCode = _TranslationsTrackersDeviceCodeIt._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyIt oauthProxy = _TranslationsTrackersOauthProxyIt._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeIt implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => 'Codice copiato';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyIt implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyIt._(this._root);
|
||||
|
||||
final TranslationsIt _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => 'Accedi a ${service}';
|
||||
@override String get body => 'Scansiona questo codice QR con il telefono oppure apri l\'URL sottostante su un dispositivo con browser.';
|
||||
@override String openToSignIn({required Object service}) => 'Apri ${service} per accedere';
|
||||
@override String get urlCopied => 'URL copiato';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <it>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsIt {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Apri ${service} per attivare',
|
||||
'trackers.deviceCode.waitingForAuthorization' => 'In attesa di autorizzazione…',
|
||||
'trackers.deviceCode.codeCopied' => 'Codice copiato',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => 'Accedi a ${service}',
|
||||
'trackers.oauthProxy.body' => 'Scansiona questo codice QR con il telefono oppure apri l\'URL sottostante su un dispositivo con browser.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Apri ${service} per accedere',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL copiato',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersJa implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => '${service} に接続できませんでした。もう一度お試しください。';
|
||||
@override late final _TranslationsTrackersServicesJa services = _TranslationsTrackersServicesJa._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodeJa deviceCode = _TranslationsTrackersDeviceCodeJa._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyJa oauthProxy = _TranslationsTrackersOauthProxyJa._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeJa implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => 'コードをコピーしました';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyJa implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyJa._(this._root);
|
||||
|
||||
final TranslationsJa _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => '${service} にサインイン';
|
||||
@override String get body => 'このQRコードをスマートフォンで読み取るか、下のURLをブラウザで開いてください。';
|
||||
@override String openToSignIn({required Object service}) => '${service} を開いてサインイン';
|
||||
@override String get urlCopied => 'URLをコピーしました';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <ja>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsJa {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => '${service} を開いて有効化',
|
||||
'trackers.deviceCode.waitingForAuthorization' => '認証を待っています…',
|
||||
'trackers.deviceCode.codeCopied' => 'コードをコピーしました',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => '${service} にサインイン',
|
||||
'trackers.oauthProxy.body' => 'このQRコードをスマートフォンで読み取るか、下のURLをブラウザで開いてください。',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => '${service} を開いてサインイン',
|
||||
'trackers.oauthProxy.urlCopied' => 'URLをコピーしました',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersKo implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => '${service}에 연결할 수 없습니다. 다시 시도하세요.';
|
||||
@override late final _TranslationsTrackersServicesKo services = _TranslationsTrackersServicesKo._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodeKo deviceCode = _TranslationsTrackersDeviceCodeKo._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyKo oauthProxy = _TranslationsTrackersOauthProxyKo._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeKo implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => '코드가 복사되었습니다';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyKo implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyKo._(this._root);
|
||||
|
||||
final TranslationsKo _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => '${service}에 로그인';
|
||||
@override String get body => '휴대전화로 이 QR 코드를 스캔하거나 아래 URL을 브라우저가 있는 기기에서 여세요.';
|
||||
@override String openToSignIn({required Object service}) => '로그인하려면 ${service} 열기';
|
||||
@override String get urlCopied => 'URL이 복사되었습니다';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <ko>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsKo {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => '활성화하려면 ${service} 열기',
|
||||
'trackers.deviceCode.waitingForAuthorization' => '인증을 기다리는 중…',
|
||||
'trackers.deviceCode.codeCopied' => '코드가 복사되었습니다',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => '${service}에 로그인',
|
||||
'trackers.oauthProxy.body' => '휴대전화로 이 QR 코드를 스캔하거나 아래 URL을 브라우저가 있는 기기에서 여세요.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => '로그인하려면 ${service} 열기',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL이 복사되었습니다',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersNb implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => 'Kunne ikke koble til ${service}. Prøv igjen.';
|
||||
@override late final _TranslationsTrackersServicesNb services = _TranslationsTrackersServicesNb._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodeNb deviceCode = _TranslationsTrackersDeviceCodeNb._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyNb oauthProxy = _TranslationsTrackersOauthProxyNb._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeNb implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => 'Kode kopiert';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyNb implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyNb._(this._root);
|
||||
|
||||
final TranslationsNb _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => 'Logg inn på ${service}';
|
||||
@override String get body => 'Skann denne QR-koden med telefonen din, eller åpne URL-en under på en enhet med nettleser.';
|
||||
@override String openToSignIn({required Object service}) => 'Åpne ${service} for å logge inn';
|
||||
@override String get urlCopied => 'URL kopiert';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <nb>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsNb {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Åpne ${service} for å aktivere',
|
||||
'trackers.deviceCode.waitingForAuthorization' => 'Venter på godkjenning…',
|
||||
'trackers.deviceCode.codeCopied' => 'Kode kopiert',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => 'Logg inn på ${service}',
|
||||
'trackers.oauthProxy.body' => 'Skann denne QR-koden med telefonen din, eller åpne URL-en under på en enhet med nettleser.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Åpne ${service} for å logge inn',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL kopiert',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersNl implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => 'Kan niet verbinden met ${service}. Probeer opnieuw.';
|
||||
@override late final _TranslationsTrackersServicesNl services = _TranslationsTrackersServicesNl._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodeNl deviceCode = _TranslationsTrackersDeviceCodeNl._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyNl oauthProxy = _TranslationsTrackersOauthProxyNl._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeNl implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => 'Code gekopieerd';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyNl implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyNl._(this._root);
|
||||
|
||||
final TranslationsNl _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => 'Aanmelden bij ${service}';
|
||||
@override String get body => 'Scan deze QR-code met je telefoon, of open onderstaande URL op een apparaat met browser.';
|
||||
@override String openToSignIn({required Object service}) => '${service} openen om aan te melden';
|
||||
@override String get urlCopied => 'URL gekopieerd';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <nl>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsNl {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Open ${service} om te activeren',
|
||||
'trackers.deviceCode.waitingForAuthorization' => 'Wachten op autorisatie…',
|
||||
'trackers.deviceCode.codeCopied' => 'Code gekopieerd',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => 'Aanmelden bij ${service}',
|
||||
'trackers.oauthProxy.body' => 'Scan deze QR-code met je telefoon, of open onderstaande URL op een apparaat met browser.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => '${service} openen om aan te melden',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL gekopieerd',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersPl implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => 'Nie udało się połączyć z ${service}. Spróbuj ponownie.';
|
||||
@override late final _TranslationsTrackersServicesPl services = _TranslationsTrackersServicesPl._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodePl deviceCode = _TranslationsTrackersDeviceCodePl._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyPl oauthProxy = _TranslationsTrackersOauthProxyPl._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodePl implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => 'Kod skopiowany';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyPl implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyPl._(this._root);
|
||||
|
||||
final TranslationsPl _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => 'Zaloguj się do ${service}';
|
||||
@override String get body => 'Zeskanuj ten kod QR telefonem lub otwórz poniższy adres URL na dowolnym urządzeniu z przeglądarką.';
|
||||
@override String openToSignIn({required Object service}) => 'Otwórz ${service}, aby się zalogować';
|
||||
@override String get urlCopied => 'URL skopiowany';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <pl>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsPl {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Otwórz ${service}, aby aktywować',
|
||||
'trackers.deviceCode.waitingForAuthorization' => 'Oczekiwanie na autoryzację…',
|
||||
'trackers.deviceCode.codeCopied' => 'Kod skopiowany',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => 'Zaloguj się do ${service}',
|
||||
'trackers.oauthProxy.body' => 'Zeskanuj ten kod QR telefonem lub otwórz poniższy adres URL na dowolnym urządzeniu z przeglądarką.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Otwórz ${service}, aby się zalogować',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL skopiowany',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersPt implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => 'Não foi possível conectar ao ${service}. Tente novamente.';
|
||||
@override late final _TranslationsTrackersServicesPt services = _TranslationsTrackersServicesPt._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodePt deviceCode = _TranslationsTrackersDeviceCodePt._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyPt oauthProxy = _TranslationsTrackersOauthProxyPt._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodePt implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => 'Código copiado';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyPt implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyPt._(this._root);
|
||||
|
||||
final TranslationsPt _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => 'Entrar no ${service}';
|
||||
@override String get body => 'Escaneie este código QR com o celular, ou abra o URL abaixo em qualquer dispositivo com navegador.';
|
||||
@override String openToSignIn({required Object service}) => 'Abrir ${service} para entrar';
|
||||
@override String get urlCopied => 'URL copiado';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <pt>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsPt {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Abrir ${service} para ativar',
|
||||
'trackers.deviceCode.waitingForAuthorization' => 'Aguardando autorização…',
|
||||
'trackers.deviceCode.codeCopied' => 'Código copiado',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => 'Entrar no ${service}',
|
||||
'trackers.oauthProxy.body' => 'Escaneie este código QR com o celular, ou abra o URL abaixo em qualquer dispositivo com navegador.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Abrir ${service} para entrar',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL copiado',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersRu implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => 'Не удалось подключиться к ${service}. Попробуйте ещё раз.';
|
||||
@override late final _TranslationsTrackersServicesRu services = _TranslationsTrackersServicesRu._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodeRu deviceCode = _TranslationsTrackersDeviceCodeRu._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyRu oauthProxy = _TranslationsTrackersOauthProxyRu._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeRu implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => 'Код скопирован';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyRu implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyRu._(this._root);
|
||||
|
||||
final TranslationsRu _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => 'Войти в ${service}';
|
||||
@override String get body => 'Отсканируйте этот QR-код телефоном или откройте ссылку ниже на любом устройстве с браузером.';
|
||||
@override String openToSignIn({required Object service}) => 'Открыть ${service} для входа';
|
||||
@override String get urlCopied => 'URL скопирован';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <ru>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsRu {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Открыть ${service} для активации',
|
||||
'trackers.deviceCode.waitingForAuthorization' => 'Ожидание авторизации…',
|
||||
'trackers.deviceCode.codeCopied' => 'Код скопирован',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => 'Войти в ${service}',
|
||||
'trackers.oauthProxy.body' => 'Отсканируйте этот QR-код телефоном или откройте ссылку ниже на любом устройстве с браузером.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Открыть ${service} для входа',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL скопирован',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersSv implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => 'Kunde inte ansluta till ${service}. Försök igen.';
|
||||
@override late final _TranslationsTrackersServicesSv services = _TranslationsTrackersServicesSv._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodeSv deviceCode = _TranslationsTrackersDeviceCodeSv._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxySv oauthProxy = _TranslationsTrackersOauthProxySv._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeSv implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => 'Kod kopierad';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxySv implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxySv._(this._root);
|
||||
|
||||
final TranslationsSv _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => 'Logga in på ${service}';
|
||||
@override String get body => 'Skanna den här QR-koden med din telefon, eller öppna URL:en nedan på en enhet med webbläsare.';
|
||||
@override String openToSignIn({required Object service}) => 'Öppna ${service} för att logga in';
|
||||
@override String get urlCopied => 'URL kopierad';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <sv>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsSv {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => 'Öppna ${service} för att aktivera',
|
||||
'trackers.deviceCode.waitingForAuthorization' => 'Väntar på auktorisering…',
|
||||
'trackers.deviceCode.codeCopied' => 'Kod kopierad',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => 'Logga in på ${service}',
|
||||
'trackers.oauthProxy.body' => 'Skanna den här QR-koden med din telefon, eller öppna URL:en nedan på en enhet med webbläsare.',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => 'Öppna ${service} för att logga in',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL kopierad',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1298,6 +1298,7 @@ class _TranslationsTrackersZh implements TranslationsTrackersEn {
|
||||
@override String connectFailed({required Object service}) => '无法连接到 ${service}。请重试。';
|
||||
@override late final _TranslationsTrackersServicesZh services = _TranslationsTrackersServicesZh._(_root);
|
||||
@override late final _TranslationsTrackersDeviceCodeZh deviceCode = _TranslationsTrackersDeviceCodeZh._(_root);
|
||||
@override late final _TranslationsTrackersOauthProxyZh oauthProxy = _TranslationsTrackersOauthProxyZh._(_root);
|
||||
}
|
||||
|
||||
// Path: hotkeys.actions
|
||||
@@ -1482,6 +1483,19 @@ class _TranslationsTrackersDeviceCodeZh implements TranslationsTrackersDeviceCod
|
||||
@override String get codeCopied => '代码已复制';
|
||||
}
|
||||
|
||||
// Path: trackers.oauthProxy
|
||||
class _TranslationsTrackersOauthProxyZh implements TranslationsTrackersOauthProxyEn {
|
||||
_TranslationsTrackersOauthProxyZh._(this._root);
|
||||
|
||||
final TranslationsZh _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String title({required Object service}) => '登录 ${service}';
|
||||
@override String get body => '用手机扫描此二维码,或在任何带浏览器的设备上打开下方 URL。';
|
||||
@override String openToSignIn({required Object service}) => '打开 ${service} 以登录';
|
||||
@override String get urlCopied => 'URL 已复制';
|
||||
}
|
||||
|
||||
/// The flat map containing all translations for locale <zh>.
|
||||
/// Only for edge cases! For simple maps, use the map function of this library.
|
||||
///
|
||||
@@ -2435,6 +2449,10 @@ extension on TranslationsZh {
|
||||
'trackers.deviceCode.openToActivate' => ({required Object service}) => '打开 ${service} 以激活',
|
||||
'trackers.deviceCode.waitingForAuthorization' => '等待授权…',
|
||||
'trackers.deviceCode.codeCopied' => '代码已复制',
|
||||
'trackers.oauthProxy.title' => ({required Object service}) => '登录 ${service}',
|
||||
'trackers.oauthProxy.body' => '用手机扫描此二维码,或在任何带浏览器的设备上打开下方 URL。',
|
||||
'trackers.oauthProxy.openToSignIn' => ({required Object service}) => '打开 ${service} 以登录',
|
||||
'trackers.oauthProxy.urlCopied' => 'URL 已复制',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "Öppna ${service} för att aktivera",
|
||||
"waitingForAuthorization": "Väntar på auktorisering…",
|
||||
"codeCopied": "Kod kopierad"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "Logga in på ${service}",
|
||||
"body": "Skanna den här QR-koden med din telefon, eller öppna URL:en nedan på en enhet med webbläsare.",
|
||||
"openToSignIn": "Öppna ${service} för att logga in",
|
||||
"urlCopied": "URL kopierad"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1040,6 +1040,12 @@
|
||||
"openToActivate": "打开 ${service} 以激活",
|
||||
"waitingForAuthorization": "等待授权…",
|
||||
"codeCopied": "代码已复制"
|
||||
},
|
||||
"oauthProxy": {
|
||||
"title": "登录 ${service}",
|
||||
"body": "用手机扫描此二维码,或在任何带浏览器的设备上打开下方 URL。",
|
||||
"openToSignIn": "打开 ${service} 以登录",
|
||||
"urlCopied": "URL 已复制"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../services/trackers/mal/mal_auth_service.dart';
|
||||
import '../services/trackers/mal/mal_client.dart';
|
||||
import '../services/trackers/mal/mal_session.dart';
|
||||
import '../services/trackers/mal/mal_tracker.dart';
|
||||
import '../services/trackers/oauth_proxy_client.dart';
|
||||
import '../services/trackers/simkl/simkl_account_store.dart';
|
||||
import '../services/trackers/simkl/simkl_auth_service.dart';
|
||||
import '../services/trackers/simkl/simkl_client.dart';
|
||||
@@ -31,6 +32,7 @@ enum TrackerService { mal, anilist, simkl }
|
||||
/// sessions from their stores and pushes them to their trackers.
|
||||
class TrackersProvider extends ChangeNotifier {
|
||||
final MalAuthService _malAuth = MalAuthService();
|
||||
final AnilistAuthService _anilistAuth = AnilistAuthService();
|
||||
final SimklAuthService _simklAuth = SimklAuthService();
|
||||
|
||||
MalSession? _mal;
|
||||
@@ -55,9 +57,9 @@ class TrackersProvider extends ChangeNotifier {
|
||||
|
||||
bool isConnecting(TrackerService service) => _connecting == service;
|
||||
|
||||
/// Cancel an in-flight device-code poll. Currently only Simkl is
|
||||
/// cancellable — MAL/AniList are OS-browser driven, the user dismisses the
|
||||
/// browser to abort.
|
||||
/// Cancel an in-flight connect. Supported for all three services — Simkl's
|
||||
/// device-code poll and MAL/AniList's OAuth-proxy long-poll both honor the
|
||||
/// flag on their next tick.
|
||||
void cancelConnect() {
|
||||
_cancelRequested = true;
|
||||
}
|
||||
@@ -82,10 +84,13 @@ class TrackersProvider extends ChangeNotifier {
|
||||
|
||||
// ───── Connect / disconnect ─────
|
||||
|
||||
Future<bool> connectMal() => _runConnect<MalSession>(
|
||||
Future<bool> connectMal({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect<MalSession>(
|
||||
service: TrackerService.mal,
|
||||
alreadyConnected: isMalConnected,
|
||||
authorize: _malAuth.authorize,
|
||||
authorize: () => _malAuth.authorize(
|
||||
onCodeReady: onCodeReady,
|
||||
shouldCancel: () => _cancelRequested,
|
||||
),
|
||||
enrich: _enrichMal,
|
||||
store: malAccountStore,
|
||||
assign: (s) {
|
||||
@@ -99,10 +104,13 @@ class TrackersProvider extends ChangeNotifier {
|
||||
_rebindMal();
|
||||
});
|
||||
|
||||
Future<bool> connectAnilist() => _runConnect<AnilistSession>(
|
||||
Future<bool> connectAnilist({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect<AnilistSession>(
|
||||
service: TrackerService.anilist,
|
||||
alreadyConnected: isAnilistConnected,
|
||||
authorize: AnilistAuthService().authorize,
|
||||
authorize: () => _anilistAuth.authorize(
|
||||
onCodeReady: onCodeReady,
|
||||
shouldCancel: () => _cancelRequested,
|
||||
),
|
||||
enrich: _enrichAnilist,
|
||||
store: anilistAccountStore,
|
||||
assign: (s) {
|
||||
@@ -271,6 +279,7 @@ class TrackersProvider extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
_malAuth.dispose();
|
||||
_anilistAuth.dispose();
|
||||
_simklAuth.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../models/trackers/device_code.dart';
|
||||
import '../../providers/trackers_provider.dart';
|
||||
import '../../services/trackers/anilist/anilist_tracker.dart';
|
||||
import '../../services/trackers/mal/mal_tracker.dart';
|
||||
import '../../services/trackers/oauth_proxy_client.dart';
|
||||
import '../../services/trackers/simkl/simkl_tracker.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
@@ -18,73 +20,97 @@ import '../../utils/snackbar_helper.dart';
|
||||
import '../../widgets/app_icon.dart';
|
||||
import '../../widgets/device_code_dialog.dart';
|
||||
import '../../widgets/focused_scroll_scaffold.dart';
|
||||
import '../../widgets/oauth_proxy_dialog.dart';
|
||||
import '../../widgets/settings_section.dart';
|
||||
|
||||
/// Start MAL's loopback-OAuth flow from anywhere. The user's browser opens to
|
||||
/// complete the flow; no in-app dialog is needed. Shows a snackbar on failure.
|
||||
Future<void> startMalConnection(BuildContext context) async {
|
||||
/// Shared dialog-driven connect flow used by all three trackers. Handles the
|
||||
/// guard, auto-launch-on-pointer, dialog lifecycle, and failure snackbar —
|
||||
/// service-specific pieces are delegated via [connect], [buildDialog], and
|
||||
/// [urlFor].
|
||||
Future<void> _startConnectionWithDialog<T>(
|
||||
BuildContext context, {
|
||||
required TrackerService service,
|
||||
required bool alreadyConnected,
|
||||
required String serviceName,
|
||||
required Future<bool> Function(void Function(T)) connect,
|
||||
required Widget Function(T payload, VoidCallback onCancel) buildDialog,
|
||||
required String Function(T payload) urlFor,
|
||||
}) async {
|
||||
final account = context.read<TrackersProvider>();
|
||||
if (account.isConnecting(TrackerService.mal) || account.isMalConnected) return;
|
||||
final ok = await account.connectMal();
|
||||
if (!context.mounted) return;
|
||||
if (!ok && !account.isMalConnected) {
|
||||
showAppSnackBar(context, t.trackers.connectFailed(service: t.trackers.services.mal));
|
||||
}
|
||||
}
|
||||
|
||||
/// Start AniList's loopback-OAuth flow from anywhere.
|
||||
Future<void> startAnilistConnection(BuildContext context) async {
|
||||
final account = context.read<TrackersProvider>();
|
||||
if (account.isConnecting(TrackerService.anilist) || account.isAnilistConnected) return;
|
||||
final ok = await account.connectAnilist();
|
||||
if (!context.mounted) return;
|
||||
if (!ok && !account.isAnilistConnected) {
|
||||
showAppSnackBar(context, t.trackers.connectFailed(service: t.trackers.services.anilist));
|
||||
}
|
||||
}
|
||||
|
||||
/// Start Simkl's device-code flow. Mirrors `startTraktConnection` — shows the
|
||||
/// PIN dialog, polls, auto-launches the browser on pointer-driven platforms.
|
||||
Future<void> startSimklConnection(BuildContext context) async {
|
||||
final account = context.read<TrackersProvider>();
|
||||
if (account.isConnecting(TrackerService.simkl) || account.isSimklConnected) return;
|
||||
if (account.isConnecting(service) || alreadyConnected) return;
|
||||
|
||||
final autoLaunchBrowser = !InputModeTracker.isKeyboardMode(context);
|
||||
var dialogOpen = false;
|
||||
|
||||
final ok = await account.connectSimkl(
|
||||
onCodeReady: (code) {
|
||||
if (!context.mounted) return;
|
||||
dialogOpen = true;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => DeviceCodeDialog(
|
||||
code: code,
|
||||
serviceName: t.trackers.services.simkl,
|
||||
onCancel: account.cancelConnect,
|
||||
),
|
||||
).whenComplete(() => dialogOpen = false);
|
||||
if (autoLaunchBrowser) {
|
||||
unawaited(
|
||||
launchUrl(Uri.parse(code.verificationUrl), mode: LaunchMode.externalApplication).catchError((Object e) {
|
||||
appLogger.d('Simkl: failed to auto-launch browser', error: e);
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
final ok = await connect((payload) {
|
||||
if (!context.mounted) return;
|
||||
dialogOpen = true;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => buildDialog(payload, account.cancelConnect),
|
||||
).whenComplete(() => dialogOpen = false);
|
||||
if (autoLaunchBrowser) {
|
||||
unawaited(
|
||||
launchUrl(Uri.parse(urlFor(payload)), mode: LaunchMode.externalApplication).catchError((Object e) {
|
||||
appLogger.d('${service.name}: failed to auto-launch browser', error: e);
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (!context.mounted) return;
|
||||
if (dialogOpen) {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
}
|
||||
if (!ok && !account.isSimklConnected) {
|
||||
showAppSnackBar(context, t.trackers.connectFailed(service: t.trackers.services.simkl));
|
||||
if (!ok) {
|
||||
showAppSnackBar(context, t.trackers.connectFailed(service: serviceName));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startMalConnection(BuildContext context) {
|
||||
final account = context.read<TrackersProvider>();
|
||||
final name = t.trackers.services.mal;
|
||||
return _startConnectionWithDialog<OAuthProxyStart>(
|
||||
context,
|
||||
service: TrackerService.mal,
|
||||
alreadyConnected: account.isMalConnected,
|
||||
serviceName: name,
|
||||
connect: (cb) => account.connectMal(onCodeReady: cb),
|
||||
buildDialog: (p, cancel) => OAuthProxyDialog(start: p, serviceName: name, onCancel: cancel),
|
||||
urlFor: (p) => p.url,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> startAnilistConnection(BuildContext context) {
|
||||
final account = context.read<TrackersProvider>();
|
||||
final name = t.trackers.services.anilist;
|
||||
return _startConnectionWithDialog<OAuthProxyStart>(
|
||||
context,
|
||||
service: TrackerService.anilist,
|
||||
alreadyConnected: account.isAnilistConnected,
|
||||
serviceName: name,
|
||||
connect: (cb) => account.connectAnilist(onCodeReady: cb),
|
||||
buildDialog: (p, cancel) => OAuthProxyDialog(start: p, serviceName: name, onCancel: cancel),
|
||||
urlFor: (p) => p.url,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> startSimklConnection(BuildContext context) {
|
||||
final account = context.read<TrackersProvider>();
|
||||
final name = t.trackers.services.simkl;
|
||||
return _startConnectionWithDialog<DeviceCode>(
|
||||
context,
|
||||
service: TrackerService.simkl,
|
||||
alreadyConnected: account.isSimklConnected,
|
||||
serviceName: name,
|
||||
connect: (cb) => account.connectSimkl(onCodeReady: cb),
|
||||
buildDialog: (p, cancel) => DeviceCodeDialog(code: p, serviceName: name, onCancel: cancel),
|
||||
urlFor: (p) => p.verificationUrlComplete ?? p.verificationUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-service wiring for [TrackerSettingsScreen]. Keeps tracker-specific
|
||||
/// method names out of the shared screen body.
|
||||
class TrackerConfig {
|
||||
|
||||
@@ -1,46 +1,29 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../loopback_auth_server.dart';
|
||||
import 'anilist_constants.dart';
|
||||
import '../oauth_proxy_client.dart';
|
||||
import 'anilist_session.dart';
|
||||
|
||||
/// AniList OAuth 2.0 implicit grant via RFC 8252 loopback redirect.
|
||||
/// AniList authentication via the Plezy relay's OAuth proxy.
|
||||
///
|
||||
/// AniList returns the access token in the URL fragment, which browsers
|
||||
/// don't send to servers. [LoopbackAuthServer] serves a tiny HTML page that
|
||||
/// rewrites `location.hash` into a query string and reloads — the second
|
||||
/// request is then captured normally.
|
||||
/// We use AniList's authorization-code grant (not implicit), exchanged
|
||||
/// server-side so the device never sees the fragment. The proxy handles both
|
||||
/// state + client_secret; the device just gets the bearer token.
|
||||
class AnilistAuthService {
|
||||
static const String _callbackPath = '/anilist-oauth';
|
||||
final OAuthProxyClient _proxy;
|
||||
|
||||
/// Drive the full flow. Returns `null` if the user closes the browser
|
||||
/// before completing.
|
||||
Future<AnilistSession?> authorize() async {
|
||||
// AniList's implicit grant rejects the authorize request when
|
||||
// `redirect_uri` is present — MAL-Sync omits it too. The redirect URL
|
||||
// registered for the client at anilist.co is used automatically.
|
||||
final authorizeUri = Uri.parse(AnilistConstants.oauthAuthorizeUrl).replace(
|
||||
queryParameters: {'client_id': AnilistConstants.clientId, 'response_type': 'token'},
|
||||
);
|
||||
AnilistAuthService({OAuthProxyClient? proxy}) : _proxy = proxy ?? OAuthProxyClient();
|
||||
|
||||
final callback = await LoopbackAuthServer.launchAndWait(authorizeUri, path: _callbackPath);
|
||||
if (callback == null) return null;
|
||||
void dispose() => _proxy.dispose();
|
||||
|
||||
final params = callback.queryParameters;
|
||||
final token = params['access_token'];
|
||||
if (token == null) {
|
||||
throw AnilistAuthFlowException('AniList redirect missing access_token: $callback');
|
||||
}
|
||||
|
||||
final expiresIn = int.tryParse(params['expires_in'] ?? '') ?? (365 * 24 * 60 * 60);
|
||||
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
return AnilistSession(accessToken: token, expiresAt: createdAt + expiresIn, createdAt: createdAt);
|
||||
/// Drive the full flow. Returns null on user cancel.
|
||||
Future<AnilistSession?> authorize({
|
||||
required void Function(OAuthProxyStart) onCodeReady,
|
||||
bool Function()? shouldCancel,
|
||||
}) async {
|
||||
final start = await _proxy.start('anilist');
|
||||
onCodeReady(start);
|
||||
final result = await _proxy.poll(start.session, shouldCancel: shouldCancel);
|
||||
if (result == null) return null;
|
||||
return AnilistSession.fromProxyResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
class AnilistAuthFlowException implements Exception {
|
||||
final String message;
|
||||
const AnilistAuthFlowException(this.message);
|
||||
@override
|
||||
String toString() => 'AnilistAuthFlowException: $message';
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
/// Bundled AniList API credentials and endpoints.
|
||||
/// Bundled AniList API endpoint.
|
||||
///
|
||||
/// Register at https://anilist.co/settings/developer — redirect URL must be
|
||||
/// `http://127.0.0.1:53682/anilist-oauth` (RFC 8252 loopback). AniList uses
|
||||
/// OAuth 2.0 Implicit Grant; access tokens are valid for 1 year and have no
|
||||
/// refresh — the user must re-auth on expiry.
|
||||
/// Auth is driven entirely by the Plezy relay's OAuth proxy — the device
|
||||
/// never needs the AniList authorize URL, client ID, or client secret.
|
||||
/// Tokens are valid for 1 year and have no refresh; users re-auth on expiry.
|
||||
class AnilistConstants {
|
||||
AnilistConstants._();
|
||||
|
||||
static const String clientId = '39867';
|
||||
|
||||
static const String apiBase = 'https://graphql.anilist.co';
|
||||
static const String oauthAuthorizeUrl = 'https://anilist.co/api/v2/oauth/authorize';
|
||||
|
||||
static Map<String, String> headers({String? accessToken}) => {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../oauth_proxy_client.dart';
|
||||
|
||||
/// Immutable AniList OAuth session.
|
||||
///
|
||||
/// Implicit grant — no refresh token. Tokens are valid for 1 year; on expiry
|
||||
@@ -42,6 +44,19 @@ class AnilistSession {
|
||||
createdAt: (json['created_at'] as num).toInt(),
|
||||
);
|
||||
|
||||
/// Build a session from the OAuth-proxy result. AniList tokens last 1 year
|
||||
/// and have no refresh; when the proxy doesn't echo an explicit expiry we
|
||||
/// default to the documented year.
|
||||
factory AnilistSession.fromProxyResult(OAuthProxyResult r) {
|
||||
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final expiresIn = r.expiresIn ?? 365 * 24 * 60 * 60;
|
||||
return AnilistSession(
|
||||
accessToken: r.accessToken,
|
||||
expiresAt: createdAt + expiresIn,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
String encode() => json.encode(toJson());
|
||||
static AnilistSession decode(String raw) => AnilistSession.fromJson(json.decode(raw) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
/// One-shot HTTP server on `127.0.0.1:[port]` that captures an OAuth redirect
|
||||
/// (RFC 8252 Loopback Interface Redirection).
|
||||
///
|
||||
/// Replaces the custom `plezy://` URL scheme used by `flutter_web_auth_2` —
|
||||
/// no platform manifest registration needed.
|
||||
///
|
||||
/// **iOS caveat**: when the app opens an external browser, iOS may suspend
|
||||
/// the app after ~30 seconds, which silently kills this server. Users who
|
||||
/// linger in 2FA / password managers may see "localhost refused to connect"
|
||||
/// on the redirect. If this turns out to be common, fall back to the
|
||||
/// custom-scheme approach on mobile only.
|
||||
class LoopbackAuthServer {
|
||||
/// Fixed port — must be registered as the redirect URI with each OAuth
|
||||
/// provider. AniList requires exact URI match, so we can't pick dynamically.
|
||||
static const int port = 53682;
|
||||
static const String host = '127.0.0.1';
|
||||
|
||||
/// Listen for one request at `http://$host:$port$path` and return the
|
||||
/// captured URI.
|
||||
///
|
||||
/// Handles both query-param redirects (RFC 6749 authorization_code grant)
|
||||
/// and fragment redirects (implicit grant): fragments stay client-side, so
|
||||
/// we serve a tiny HTML page that rewrites `location.hash` into query
|
||||
/// params and reloads — the second request is then captured normally.
|
||||
static Future<Uri> listenOnce({
|
||||
required String path,
|
||||
Duration timeout = const Duration(minutes: 5),
|
||||
}) async {
|
||||
final HttpServer server;
|
||||
try {
|
||||
server = await HttpServer.bind(host, port);
|
||||
} on SocketException catch (e) {
|
||||
throw LoopbackBindException('Could not bind $host:$port: ${e.message}');
|
||||
}
|
||||
|
||||
final completer = Completer<Uri>();
|
||||
|
||||
late StreamSubscription<HttpRequest> sub;
|
||||
sub = server.listen((req) async {
|
||||
if (req.uri.path != path) {
|
||||
req.response.statusCode = HttpStatus.notFound;
|
||||
await req.response.close();
|
||||
return;
|
||||
}
|
||||
// Flush the response BEFORE completing the completer — the caller's
|
||||
// `finally` block forcibly tears down the socket, which would
|
||||
// otherwise truncate the response body and leave the browser showing
|
||||
// a broken page despite auth succeeding.
|
||||
await _respondHtml(req.response, _pageHtml);
|
||||
if (req.uri.queryParameters.isNotEmpty && !completer.isCompleted) {
|
||||
completer.complete(req.uri);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
return await completer.future.timeout(timeout);
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
await server.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the redirect URI callers should register with the OAuth provider.
|
||||
static String redirectUri(String path) => 'http://$host:$port$path';
|
||||
|
||||
/// Open [authorizeUri] in the external browser and wait for the OAuth
|
||||
/// provider's redirect to hit the loopback server. Returns `null` if the
|
||||
/// redirect doesn't arrive within [timeout] (usually because the user
|
||||
/// closed the browser).
|
||||
///
|
||||
/// Starts the listener BEFORE launching the browser so a fast redirect
|
||||
/// can't race the bind.
|
||||
static Future<Uri?> launchAndWait(
|
||||
Uri authorizeUri, {
|
||||
required String path,
|
||||
Duration timeout = const Duration(minutes: 5),
|
||||
}) async {
|
||||
final callbackFuture = listenOnce(path: path, timeout: timeout);
|
||||
await launchUrl(authorizeUri, mode: LaunchMode.externalApplication);
|
||||
try {
|
||||
return await callbackFuture;
|
||||
} on TimeoutException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _respondHtml(HttpResponse res, String html) async {
|
||||
try {
|
||||
res
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.html
|
||||
..write(html);
|
||||
await res.close();
|
||||
} catch (e) {
|
||||
appLogger.d('LoopbackAuthServer: response write failed', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// One page for all three flows. Code-grant and polling flows (MAL, Simkl)
|
||||
/// render it as a plain success page. Implicit-grant flows (AniList) put
|
||||
/// the access token in the URL fragment — the inline script in `<head>`
|
||||
/// rewrites that into a query string and redirects before the body paints,
|
||||
/// so users never see a success page flash before the real capture.
|
||||
static const String _pageHtml = '<!doctype html>'
|
||||
'<meta charset="utf-8">'
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
'<title>Signed in</title>'
|
||||
'<script>'
|
||||
'if(location.hash&&location.hash.length>1){'
|
||||
'location.replace(location.pathname+"?"+location.hash.substring(1));'
|
||||
'}'
|
||||
'</script>'
|
||||
'<style>'
|
||||
'html,body{margin:0;height:100%}'
|
||||
'body{display:flex;flex-direction:column;align-items:center;justify-content:center;'
|
||||
'font-family:-apple-system,system-ui,sans-serif;background:#fff;color:#1a1a1a;text-align:center;padding:1em;box-sizing:border-box}'
|
||||
'@media(prefers-color-scheme:dark){body{background:#0f0f0f;color:#f5f5f5}}'
|
||||
'.check{width:72px;height:72px;margin-bottom:20px}'
|
||||
'h2{margin:0 0 8px;font-weight:600;font-size:1.25rem}'
|
||||
'p{margin:0;opacity:.7;font-size:.95rem}'
|
||||
'</style>'
|
||||
'<body>'
|
||||
'<svg class="check" viewBox="0 0 24 24">'
|
||||
'<circle cx="12" cy="12" r="10" fill="#22c55e"/>'
|
||||
'<path d="M7 12.5l3 3 7-7" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
'</svg>'
|
||||
'<h2>Signed in to Plezy</h2>'
|
||||
'<p>You can close this tab and return to the app.</p>'
|
||||
'</body>';
|
||||
}
|
||||
|
||||
class LoopbackBindException implements Exception {
|
||||
final String message;
|
||||
const LoopbackBindException(this.message);
|
||||
@override
|
||||
String toString() => 'LoopbackBindException: $message';
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
@@ -8,71 +7,39 @@ import '../../../utils/app_logger.dart';
|
||||
import '../../../utils/platform_http_client_stub.dart'
|
||||
if (dart.library.io) '../../../utils/platform_http_client_io.dart'
|
||||
as platform;
|
||||
import '../loopback_auth_server.dart';
|
||||
import '../oauth_proxy_client.dart';
|
||||
import 'mal_constants.dart';
|
||||
import 'mal_session.dart';
|
||||
|
||||
/// MyAnimeList OAuth 2.0 PKCE flow via RFC 8252 loopback redirect.
|
||||
/// MyAnimeList authentication.
|
||||
///
|
||||
/// **Quirk**: MAL requires `code_challenge_method=plain` — it rejects `S256`
|
||||
/// despite RFC 7636.
|
||||
/// New sessions come from the Plezy relay's OAuth proxy (PKCE is server-side).
|
||||
/// Refreshes are direct public-client calls against MAL's token endpoint —
|
||||
/// no proxy needed because refresh requires no redirect.
|
||||
class MalAuthService {
|
||||
static const String _callbackPath = '/mal-oauth';
|
||||
|
||||
final OAuthProxyClient _proxy;
|
||||
final http.Client _http;
|
||||
|
||||
MalAuthService({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient();
|
||||
MalAuthService({OAuthProxyClient? proxy, http.Client? httpClient})
|
||||
: _proxy = proxy ?? OAuthProxyClient(),
|
||||
_http = httpClient ?? platform.createPlatformClient();
|
||||
|
||||
void dispose() => _http.close();
|
||||
void dispose() {
|
||||
_proxy.dispose();
|
||||
_http.close();
|
||||
}
|
||||
|
||||
/// Drive the full flow: build the authorize URL, start a loopback server
|
||||
/// for the redirect, launch the browser, exchange the code for tokens.
|
||||
/// Returns `null` if the user closes the browser before completing.
|
||||
Future<MalSession?> authorize() async {
|
||||
final verifier = _randomVerifier();
|
||||
final state = _randomVerifier(length: 16);
|
||||
final redirectUri = LoopbackAuthServer.redirectUri(_callbackPath);
|
||||
|
||||
final authorizeUri = Uri.parse(MalConstants.authorizeUrl).replace(
|
||||
queryParameters: {
|
||||
'response_type': 'code',
|
||||
'client_id': MalConstants.clientId,
|
||||
'code_challenge': verifier, // plain method → challenge == verifier
|
||||
'code_challenge_method': 'plain',
|
||||
'redirect_uri': redirectUri,
|
||||
'state': state,
|
||||
},
|
||||
);
|
||||
|
||||
final callback = await LoopbackAuthServer.launchAndWait(authorizeUri, path: _callbackPath);
|
||||
if (callback == null) return null;
|
||||
|
||||
final code = callback.queryParameters['code'];
|
||||
final returnedState = callback.queryParameters['state'];
|
||||
if (code == null) {
|
||||
throw MalAuthFlowException('MAL redirect missing code: $callback');
|
||||
}
|
||||
if (returnedState != state) {
|
||||
throw const MalAuthFlowException('MAL state mismatch (possible CSRF)');
|
||||
}
|
||||
|
||||
final res = await _http
|
||||
.post(
|
||||
Uri.parse(MalConstants.tokenUrl),
|
||||
body: {
|
||||
'client_id': MalConstants.clientId,
|
||||
'code': code,
|
||||
'code_verifier': verifier,
|
||||
'grant_type': 'authorization_code',
|
||||
'redirect_uri': redirectUri,
|
||||
},
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
|
||||
if (res.statusCode != 200) {
|
||||
throw MalAuthFlowException('Token exchange failed: HTTP ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
return MalSession.fromTokenResponse(json.decode(res.body) as Map<String, dynamic>);
|
||||
/// Drive the full flow. Invokes [onCodeReady] with the QR URL once the
|
||||
/// session is created, then long-polls for tokens. Returns null on cancel.
|
||||
Future<MalSession?> authorize({
|
||||
required void Function(OAuthProxyStart) onCodeReady,
|
||||
bool Function()? shouldCancel,
|
||||
}) async {
|
||||
final start = await _proxy.start('mal');
|
||||
onCodeReady(start);
|
||||
final result = await _proxy.poll(start.session, shouldCancel: shouldCancel);
|
||||
if (result == null) return null;
|
||||
return MalSession.fromProxyResult(result);
|
||||
}
|
||||
|
||||
Future<MalSession> refresh(MalSession current) async {
|
||||
@@ -94,13 +61,6 @@ class MalAuthService {
|
||||
final fresh = MalSession.fromTokenResponse(json.decode(res.body) as Map<String, dynamic>);
|
||||
return fresh.copyWith(username: current.username);
|
||||
}
|
||||
|
||||
/// MAL requires 43–128 chars from the unreserved URL-safe set.
|
||||
String _randomVerifier({int length = 64}) {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
final rand = Random.secure();
|
||||
return List.generate(length, (_) => alphabet[rand.nextInt(alphabet.length)]).join();
|
||||
}
|
||||
}
|
||||
|
||||
class MalAuthFlowException implements Exception {
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
/// Bundled MyAnimeList API credentials and endpoints.
|
||||
/// Bundled MyAnimeList API endpoints and public client ID.
|
||||
///
|
||||
/// Register at https://myanimelist.net/apiconfig — "App type: Other", redirect
|
||||
/// URI `http://127.0.0.1:53682/mal-oauth` (RFC 8252 loopback). PKCE-only
|
||||
/// (no client secret).
|
||||
///
|
||||
/// **MAL quirk**: `code_challenge_method` must be `plain` — MAL rejects `S256`
|
||||
/// despite RFC 7636. See [MalAuthService.authorize].
|
||||
/// The authorize flow lives in the Plezy relay's OAuth proxy; see
|
||||
/// `lib/services/trackers/oauth_proxy_client.dart`. Only the refresh path
|
||||
/// (public-client, no redirect) calls MAL directly from the device.
|
||||
class MalConstants {
|
||||
MalConstants._();
|
||||
|
||||
static const String clientId = '463b1c92992505e4bdfcef6aab3aedbe';
|
||||
|
||||
static const String apiBase = 'https://api.myanimelist.net/v2';
|
||||
static const String oauthBase = 'https://myanimelist.net/v1/oauth2';
|
||||
|
||||
static const String authorizeUrl = '$oauthBase/authorize';
|
||||
static const String tokenUrl = '$oauthBase/token';
|
||||
static const String tokenUrl = 'https://myanimelist.net/v1/oauth2/token';
|
||||
|
||||
static Map<String, String> headers({String? accessToken}) => {
|
||||
'Accept': 'application/json',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../oauth_proxy_client.dart';
|
||||
|
||||
/// Immutable MyAnimeList OAuth session.
|
||||
///
|
||||
/// Access tokens expire in ~31 days. Refresh token rotates with each refresh
|
||||
@@ -60,6 +62,19 @@ class MalSession {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a session from an OAuth-proxy result. MAL's refresh_token is
|
||||
/// required for the 31-day refresh loop.
|
||||
factory MalSession.fromProxyResult(OAuthProxyResult r) {
|
||||
final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final expiresIn = r.expiresIn ?? 31 * 24 * 60 * 60;
|
||||
return MalSession(
|
||||
accessToken: r.accessToken,
|
||||
refreshToken: r.refreshToken ?? '',
|
||||
expiresAt: createdAt + expiresIn,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
String encode() => json.encode(toJson());
|
||||
static MalSession decode(String raw) => MalSession.fromJson(json.decode(raw) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/platform_http_client_stub.dart'
|
||||
if (dart.library.io) '../../utils/platform_http_client_io.dart'
|
||||
as platform;
|
||||
import '../../watch_together/services/watch_together_peer_service.dart';
|
||||
|
||||
/// Client for the Plezy relay's `/auth/*` OAuth proxy.
|
||||
///
|
||||
/// The proxy drives the full authorization-code flow server-side: device calls
|
||||
/// [start] to get a QR URL, user scans on a phone to complete auth, device
|
||||
/// long-polls [poll] until tokens arrive. No local HTTP listener or custom URL
|
||||
/// scheme is required — works identically on TVs without a browser.
|
||||
class OAuthProxyClient {
|
||||
/// Public base URL of the Plezy relay; colocated with Watch Together.
|
||||
static String get baseUrl => WatchTogetherPeerService.defaultBaseUrl;
|
||||
|
||||
final http.Client _http;
|
||||
|
||||
OAuthProxyClient({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient();
|
||||
|
||||
void dispose() => _http.close();
|
||||
|
||||
/// POST /auth/start — register a new session. Returns a handle including the
|
||||
/// URL to display as a QR code for the phone scan.
|
||||
Future<OAuthProxyStart> start(String service) async {
|
||||
final res = await _http
|
||||
.post(
|
||||
Uri.parse('$baseUrl/auth/start'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'service': service}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
if (res.statusCode != 200) {
|
||||
throw OAuthProxyException('start failed: HTTP ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
final body = json.decode(res.body) as Map<String, dynamic>;
|
||||
return OAuthProxyStart(
|
||||
session: body['session'] as String,
|
||||
url: body['url'] as String,
|
||||
expiresIn: (body['expiresIn'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Long-poll /auth/result?session=X until a completion event arrives.
|
||||
///
|
||||
/// Returns null if [shouldCancel] flips true before a result arrives. Throws
|
||||
/// [OAuthProxyException] on unrecoverable errors (session gone, upstream
|
||||
/// failure). The server holds each request for up to 50 s; 204 responses are
|
||||
/// retried transparently.
|
||||
Future<OAuthProxyResult?> poll(String session, {bool Function()? shouldCancel}) async {
|
||||
final uri = Uri.parse('$baseUrl/auth/result').replace(queryParameters: {'session': session});
|
||||
while (true) {
|
||||
if (shouldCancel?.call() ?? false) return null;
|
||||
|
||||
final http.Response res;
|
||||
try {
|
||||
res = await _http.get(uri).timeout(const Duration(seconds: 65));
|
||||
} on TimeoutException {
|
||||
continue;
|
||||
} catch (e) {
|
||||
appLogger.d('oauth proxy: poll transient error', error: e);
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (res.statusCode == 204) continue; // server-side timeout, retry
|
||||
if (res.statusCode == 410) {
|
||||
throw const OAuthProxyException('Session expired or already used');
|
||||
}
|
||||
if (res.statusCode != 200) {
|
||||
throw OAuthProxyException('poll failed: HTTP ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
final body = json.decode(res.body) as Map<String, dynamic>;
|
||||
if (body['error'] != null) {
|
||||
final err = body['error'] as String;
|
||||
if (err == 'access_denied') return null; // user cancelled in browser
|
||||
throw OAuthProxyException('Upstream auth failed: $err');
|
||||
}
|
||||
return OAuthProxyResult(
|
||||
accessToken: body['accessToken'] as String,
|
||||
refreshToken: body['refreshToken'] as String?,
|
||||
expiresIn: (body['expiresIn'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OAuthProxyStart {
|
||||
/// Opaque session token. Include in subsequent polls.
|
||||
final String session;
|
||||
|
||||
/// URL to render as a QR code and open in a browser. The phone scans it,
|
||||
/// triggering the upstream OAuth flow.
|
||||
final String url;
|
||||
|
||||
/// Session TTL in seconds. After this, polls will 410 and the user must
|
||||
/// restart.
|
||||
final int expiresIn;
|
||||
|
||||
const OAuthProxyStart({required this.session, required this.url, required this.expiresIn});
|
||||
}
|
||||
|
||||
class OAuthProxyResult {
|
||||
final String accessToken;
|
||||
final String? refreshToken;
|
||||
final int? expiresIn;
|
||||
|
||||
const OAuthProxyResult({required this.accessToken, this.refreshToken, this.expiresIn});
|
||||
}
|
||||
|
||||
class OAuthProxyException implements Exception {
|
||||
final String message;
|
||||
const OAuthProxyException(this.message);
|
||||
@override
|
||||
String toString() => 'OAuthProxyException: $message';
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -9,21 +8,16 @@ import '../../../utils/platform_http_client_stub.dart'
|
||||
if (dart.library.io) '../../../utils/platform_http_client_io.dart'
|
||||
as platform;
|
||||
import '../device_code_poller.dart' as poller;
|
||||
import '../loopback_auth_server.dart';
|
||||
import '../oauth_proxy_client.dart';
|
||||
import 'simkl_constants.dart';
|
||||
|
||||
/// Simkl OAuth PIN (device-code) flow.
|
||||
///
|
||||
/// `GET /oauth/pin?client_id=...&redirect=http://127.0.0.1:53682/simkl-oauth`
|
||||
/// returns a PIN the user enters at https://simkl.com/pin. After entry Simkl
|
||||
/// redirects the browser to our loopback callback (where we show a friendly
|
||||
/// "close this tab" page). The app polls `/oauth/pin/<user_code>?client_id=...`
|
||||
/// until `result == "OK"`.
|
||||
/// `GET /oauth/pin?client_id=...&redirect=<success page>` returns a PIN the
|
||||
/// user enters at https://simkl.com/pin. After entry Simkl redirects the
|
||||
/// browser to the relay's static "signed in" page. The app polls
|
||||
/// `/oauth/pin/<user_code>?client_id=...` until `result == "OK"`.
|
||||
class SimklAuthService {
|
||||
/// Redirect path Simkl bounces the browser to after PIN entry. Must match
|
||||
/// the URL registered at simkl.com/settings/developer for this client.
|
||||
static const String _callbackPath = '/simkl-oauth';
|
||||
|
||||
final http.Client _http;
|
||||
|
||||
SimklAuthService({http.Client? httpClient}) : _http = httpClient ?? platform.createPlatformClient();
|
||||
@@ -34,7 +28,7 @@ class SimklAuthService {
|
||||
final uri = Uri.parse(SimklConstants.pinUrl).replace(
|
||||
queryParameters: {
|
||||
'client_id': SimklConstants.clientId,
|
||||
'redirect': LoopbackAuthServer.redirectUri(_callbackPath),
|
||||
'redirect': '${OAuthProxyClient.baseUrl}/auth/done',
|
||||
},
|
||||
);
|
||||
final res = await _http.get(uri, headers: SimklConstants.headers()).timeout(const Duration(seconds: 15));
|
||||
@@ -42,25 +36,13 @@ class SimklAuthService {
|
||||
throw SimklAuthFlowException('PIN request failed: HTTP ${res.statusCode}: ${res.body}');
|
||||
}
|
||||
final body = json.decode(res.body) as Map<String, dynamic>;
|
||||
final expiresIn = (body['expires_in'] as num?)?.toInt() ?? 900;
|
||||
|
||||
// Serve the "close this tab" success page when Simkl redirects the
|
||||
// browser back after PIN entry. Fire-and-forget — the PIN poll captures
|
||||
// the token; this listener is purely cosmetic.
|
||||
unawaited(
|
||||
LoopbackAuthServer.listenOnce(
|
||||
path: _callbackPath,
|
||||
timeout: Duration(seconds: expiresIn),
|
||||
).catchError((Object _) => Uri()),
|
||||
);
|
||||
|
||||
return DeviceCode(
|
||||
deviceCode: body['device_code'] as String,
|
||||
userCode: body['user_code'] as String,
|
||||
verificationUrl: body['verification_url'] as String? ?? SimklConstants.verificationUrl,
|
||||
// Simkl doesn't expose a prefilled URL; the user manually enters the code.
|
||||
verificationUrlComplete: null,
|
||||
expiresIn: expiresIn,
|
||||
expiresIn: (body['expires_in'] as num?)?.toInt() ?? 900,
|
||||
interval: (body['interval'] as num?)?.toInt() ?? 5,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/trackers/oauth_proxy_client.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'dialog_action_button.dart';
|
||||
|
||||
/// Sign-in dialog for OAuth-proxy flows (MAL, AniList).
|
||||
///
|
||||
/// Shows a QR code plus a "open in browser" button — works uniformly on
|
||||
/// phones (user taps the button), desktops (same), and TVs without a browser
|
||||
/// (user scans the QR with a phone).
|
||||
class OAuthProxyDialog extends StatelessWidget {
|
||||
final OAuthProxyStart start;
|
||||
final String serviceName;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const OAuthProxyDialog({
|
||||
super.key,
|
||||
required this.start,
|
||||
required this.serviceName,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
Future<void> _open() async {
|
||||
await launchUrl(Uri.parse(start.url), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<void> _copyUrl(BuildContext context) async {
|
||||
await Clipboard.setData(ClipboardData(text: start.url));
|
||||
if (!context.mounted) return;
|
||||
showAppSnackBar(context, t.trackers.oauthProxy.urlCopied);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(t.trackers.oauthProxy.title(service: serviceName)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(t.trackers.oauthProxy.body, style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 16),
|
||||
// QrImageView doesn't support intrinsic sizing; wrap in SizedBox so
|
||||
// AlertDialog's IntrinsicWidth walk sees a concrete width.
|
||||
Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 220,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: QrImageView(
|
||||
data: start.url,
|
||||
size: 220,
|
||||
version: QrVersions.auto,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InkWell(
|
||||
onTap: () => _copyUrl(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Text(
|
||||
start.url,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: Text(t.trackers.oauthProxy.openToSignIn(service: serviceName)),
|
||||
onPressed: _open,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(t.trackers.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(
|
||||
onPressed: () {
|
||||
onCancel();
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
label: t.common.cancel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -2,10 +2,11 @@ FROM golang:1.22-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY main.go .
|
||||
COPY *.go .
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /relay .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=build /relay /relay
|
||||
VOLUME /data
|
||||
EXPOSE 8080
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REMOTE="root@212.132.75.249"
|
||||
SSH_KEY="$HOME/.ssh/id_edde"
|
||||
REMOTE_DIR="/opt/plezy-relay"
|
||||
SSH="ssh -i $SSH_KEY $REMOTE"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "Syncing files to $REMOTE:$REMOTE_DIR..."
|
||||
rsync -avz --exclude='plezy-relay' --exclude='plezy-server-linux-amd64' \
|
||||
-e "ssh -i $SSH_KEY" \
|
||||
. "$REMOTE:$REMOTE_DIR/"
|
||||
|
||||
echo "Deploying..."
|
||||
$SSH "cd $REMOTE_DIR && docker compose up -d --build"
|
||||
|
||||
echo "Done."
|
||||
@@ -8,6 +8,11 @@ services:
|
||||
- logs:/data
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
OAUTH_BASE_URL: https://ice.plezy.app
|
||||
MAL_CLIENT_ID: ${MAL_CLIENT_ID:-}
|
||||
ANILIST_CLIENT_ID: ${ANILIST_CLIENT_ID:-}
|
||||
ANILIST_CLIENT_SECRET: ${ANILIST_CLIENT_SECRET:-}
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
|
||||
@@ -97,6 +97,14 @@ func (rl *rateLimiter) allow() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// stale reports whether a limiter hasn't been touched in over 10 minutes —
|
||||
// safe to GC from a per-IP map.
|
||||
func (rl *rateLimiter) stale(now time.Time) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
return now.Sub(rl.lastTime) > 10*time.Minute
|
||||
}
|
||||
|
||||
// --- Connection tracker (per-IP limits) ---
|
||||
|
||||
type connTracker struct {
|
||||
@@ -547,11 +555,16 @@ type Server struct {
|
||||
logs *logStore
|
||||
conns *connTracker
|
||||
snap *snapshotter
|
||||
oauth *oauthProxy // nil when OAUTH_BASE_URL is unset
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newServer(logDir, stateFile string) *Server {
|
||||
s := &Server{rooms: make(map[string]*Room), logs: newLogStore(logDir), conns: newConnTracker()}
|
||||
if p, ok := oauthConfigFromEnv(); ok {
|
||||
s.oauth = p
|
||||
log.Printf("oauth: proxy enabled (base=%s, services=%d)", p.baseURL, len(p.services))
|
||||
}
|
||||
s.snap = newSnapshotter(stateFile, s.buildSnapshot)
|
||||
if err := s.loadSnapshot(stateFile); err != nil {
|
||||
log.Printf("snapshot: load error: %v", err)
|
||||
@@ -669,6 +682,9 @@ func (s *Server) runCleanupStep(now time.Time) {
|
||||
}
|
||||
s.logs.cleanup()
|
||||
s.conns.cleanup()
|
||||
if s.oauth != nil {
|
||||
s.oauth.cleanup()
|
||||
}
|
||||
|
||||
s.conns.mu.Lock()
|
||||
log.Printf("stats: conns=%d ips=%d rooms=%d",
|
||||
@@ -996,6 +1012,7 @@ func main() {
|
||||
})
|
||||
mux.HandleFunc("/logs", srv.handlePostLogs)
|
||||
mux.HandleFunc("/logs/", srv.handleGetLogs)
|
||||
registerOAuthRoutes(mux, srv.oauth)
|
||||
|
||||
httpSrv := &http.Server{Addr: *addr, Handler: mux}
|
||||
|
||||
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
package main
|
||||
|
||||
// OAuth proxy: relays MAL + AniList authorization-code flows for devices that
|
||||
// can't listen on localhost (TVs) or lack a browser (headless set-top boxes
|
||||
// pair via a phone QR scan). Sessions live in memory for 10 minutes; AniList's
|
||||
// client secret lives only in env vars. Access tokens transit the server
|
||||
// briefly during code→token exchange and are never logged or persisted.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
oauthSessionTTL = 10 * time.Minute
|
||||
oauthResultWait = 50 * time.Second
|
||||
oauthMaxSessions = 5000
|
||||
oauthStartBurst = 3
|
||||
oauthStartRateSustained = 1
|
||||
oauthSessionIDBytes = 18 // 144 bits → 24 base64url chars
|
||||
oauthPKCEVerifierLen = 64
|
||||
oauthUpstreamTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// oauthServiceConfig describes a single upstream OAuth provider. Populated from
|
||||
// env vars in oauthConfigFromEnv. A service with an empty ClientID is disabled.
|
||||
type oauthServiceConfig struct {
|
||||
ClientID string
|
||||
ClientSecret string // empty ⇒ provider doesn't issue/require one (MAL w/ PKCE)
|
||||
AuthorizeURL string
|
||||
TokenURL string
|
||||
Scopes string
|
||||
UsePKCE bool
|
||||
PKCEMethod string // "plain" or "S256"
|
||||
}
|
||||
|
||||
type oauthTokenResult struct {
|
||||
AccessToken string `json:"accessToken,omitempty"`
|
||||
RefreshToken string `json:"refreshToken,omitempty"`
|
||||
ExpiresIn int `json:"expiresIn,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// oauthSession is created by /auth/start and lives until it's consumed by a
|
||||
// successful /auth/result (which deletes the map entry) or GC'd after
|
||||
// oauthSessionTTL. The `done` channel is closed exactly once (by complete) and
|
||||
// unblocks waiters.
|
||||
type oauthSession struct {
|
||||
id string
|
||||
service string
|
||||
codeVerifier string // MAL PKCE; empty for AniList. Cleared after token exchange.
|
||||
createdAt time.Time
|
||||
done chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
result *oauthTokenResult
|
||||
}
|
||||
|
||||
func (s *oauthSession) complete(r oauthTokenResult) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.result != nil {
|
||||
return
|
||||
}
|
||||
s.result = &r
|
||||
s.codeVerifier = "" // Secret, not needed after exchange.
|
||||
close(s.done)
|
||||
}
|
||||
|
||||
// wait blocks until the session is completed or ctx is cancelled.
|
||||
func (s *oauthSession) wait(ctx context.Context) (*oauthTokenResult, error) {
|
||||
select {
|
||||
case <-s.done:
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.result, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
type oauthProxy struct {
|
||||
baseURL string // e.g. https://ice.plezy.app
|
||||
services map[string]oauthServiceConfig
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]*oauthSession
|
||||
|
||||
ipMu sync.Mutex
|
||||
ipRate map[string]*rateLimiter
|
||||
}
|
||||
|
||||
func newOAuthProxy(baseURL string, services map[string]oauthServiceConfig) *oauthProxy {
|
||||
return &oauthProxy{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
services: services,
|
||||
client: &http.Client{Timeout: oauthUpstreamTimeout},
|
||||
sessions: make(map[string]*oauthSession),
|
||||
ipRate: make(map[string]*rateLimiter),
|
||||
}
|
||||
}
|
||||
|
||||
// oauthConfigFromEnv reads the public base URL and per-service creds from the
|
||||
// environment. Returns (nil, false) if OAUTH_BASE_URL is unset — the caller
|
||||
// wires this as "OAuth disabled, endpoints return 503".
|
||||
func oauthConfigFromEnv() (*oauthProxy, bool) {
|
||||
base := os.Getenv("OAUTH_BASE_URL")
|
||||
if base == "" {
|
||||
return nil, false
|
||||
}
|
||||
services := map[string]oauthServiceConfig{}
|
||||
if id := os.Getenv("MAL_CLIENT_ID"); id != "" {
|
||||
services["mal"] = oauthServiceConfig{
|
||||
ClientID: id,
|
||||
AuthorizeURL: "https://myanimelist.net/v1/oauth2/authorize",
|
||||
TokenURL: "https://myanimelist.net/v1/oauth2/token",
|
||||
UsePKCE: true,
|
||||
PKCEMethod: "plain", // MAL rejects S256 despite RFC 7636
|
||||
}
|
||||
}
|
||||
if id := os.Getenv("ANILIST_CLIENT_ID"); id != "" {
|
||||
services["anilist"] = oauthServiceConfig{
|
||||
ClientID: id,
|
||||
ClientSecret: os.Getenv("ANILIST_CLIENT_SECRET"),
|
||||
AuthorizeURL: "https://anilist.co/api/v2/oauth/authorize",
|
||||
TokenURL: "https://anilist.co/api/v2/oauth/token",
|
||||
}
|
||||
}
|
||||
return newOAuthProxy(base, services), true
|
||||
}
|
||||
|
||||
// registerOAuthRoutes registers all /auth/* handlers. If p is nil (no env
|
||||
// config), all paths 503 so the integration page clearly says "not configured".
|
||||
func registerOAuthRoutes(mux *http.ServeMux, p *oauthProxy) {
|
||||
if p == nil {
|
||||
mux.HandleFunc("/auth/", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "OAuth proxy not configured", http.StatusServiceUnavailable)
|
||||
})
|
||||
return
|
||||
}
|
||||
mux.HandleFunc("/auth/start", p.handleStart)
|
||||
mux.HandleFunc("/auth/result", p.handleResult)
|
||||
mux.HandleFunc("/auth/done", p.handleDone)
|
||||
mux.HandleFunc("/auth/", p.handleAuthRoot)
|
||||
}
|
||||
|
||||
// handleAuthRoot dispatches /auth/... paths that aren't served by their own
|
||||
// registered handler. Covers /auth/:service and /auth/:service/callback.
|
||||
func (p *oauthProxy) handleAuthRoot(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/auth/")
|
||||
parts := strings.SplitN(rest, "/", 2)
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
service := parts[0]
|
||||
if len(parts) == 1 {
|
||||
p.handleAuthorize(w, r, service)
|
||||
return
|
||||
}
|
||||
if parts[1] == "callback" {
|
||||
p.handleCallback(w, r, service)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// POST /auth/start body={"service":"mal"|"anilist"}
|
||||
// Response: {"session":"...","url":"https://.../auth/:service?session=...","expiresIn":600}
|
||||
func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ip := clientIP(r)
|
||||
if !p.ipAllow(ip) {
|
||||
http.Error(w, "Rate limited", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Service string `json:"service"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 512)).Decode(&body); err != nil {
|
||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cfg, ok := p.services[body.Service]
|
||||
if !ok {
|
||||
http.Error(w, "Unknown service", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate tokens outside the map lock — crypto/rand syscalls would
|
||||
// otherwise serialize concurrent /auth/start calls.
|
||||
sess := &oauthSession{
|
||||
id: randToken(oauthSessionIDBytes),
|
||||
service: body.Service,
|
||||
createdAt: time.Now(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
if cfg.UsePKCE {
|
||||
sess.codeVerifier = randPKCEVerifier()
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
if len(p.sessions) >= oauthMaxSessions {
|
||||
p.mu.Unlock()
|
||||
http.Error(w, "Server busy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
p.sessions[sess.id] = sess
|
||||
p.mu.Unlock()
|
||||
|
||||
resp := map[string]any{
|
||||
"session": sess.id,
|
||||
"url": fmt.Sprintf("%s/auth/%s?session=%s", p.baseURL, url.PathEscape(body.Service), url.QueryEscape(sess.id)),
|
||||
"expiresIn": int(oauthSessionTTL.Seconds()),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /auth/:service?session=X → 302 upstream authorize URL
|
||||
func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, service string) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
cfg, ok := p.services[service]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
sessionID := r.URL.Query().Get("session")
|
||||
p.mu.Lock()
|
||||
sess := p.sessions[sessionID]
|
||||
p.mu.Unlock()
|
||||
if sess == nil || sess.service != service {
|
||||
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
|
||||
return
|
||||
}
|
||||
|
||||
q := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {cfg.ClientID},
|
||||
"redirect_uri": {p.redirectURI(service)},
|
||||
"state": {sess.id},
|
||||
}
|
||||
if cfg.Scopes != "" {
|
||||
q.Set("scope", cfg.Scopes)
|
||||
}
|
||||
if cfg.UsePKCE {
|
||||
q.Set("code_challenge", sess.codeVerifier) // plain method ⇒ challenge == verifier
|
||||
q.Set("code_challenge_method", cfg.PKCEMethod)
|
||||
}
|
||||
http.Redirect(w, r, cfg.AuthorizeURL+"?"+q.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
// GET /auth/:service/callback?code=...&state=... → exchange, park, render page
|
||||
func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, service string) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
cfg, ok := p.services[service]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
state := q.Get("state")
|
||||
p.mu.Lock()
|
||||
sess := p.sessions[state]
|
||||
p.mu.Unlock()
|
||||
if sess == nil || sess.service != service {
|
||||
renderErrorPage(w, http.StatusNotFound, "This sign-in link is no longer valid. Start again from Plezy.")
|
||||
return
|
||||
}
|
||||
|
||||
if upstreamErr := q.Get("error"); upstreamErr != "" {
|
||||
sess.complete(oauthTokenResult{Error: upstreamErr})
|
||||
renderErrorPage(w, http.StatusOK, "Sign-in was cancelled.")
|
||||
return
|
||||
}
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
sess.complete(oauthTokenResult{Error: "missing_code"})
|
||||
renderErrorPage(w, http.StatusBadRequest, "Sign-in response was incomplete. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
tok, err := p.exchangeCode(r.Context(), cfg, service, sess, code)
|
||||
if err != nil {
|
||||
log.Printf("oauth: %s token exchange failed: %v", service, err)
|
||||
sess.complete(oauthTokenResult{Error: "exchange_failed"})
|
||||
renderErrorPage(w, http.StatusBadGateway, "Couldn't complete sign-in. Please try again.")
|
||||
return
|
||||
}
|
||||
sess.complete(tok)
|
||||
renderSuccessPage(w)
|
||||
}
|
||||
|
||||
// GET /auth/result?session=X → long-poll, returns tokens on success
|
||||
func (p *oauthProxy) handleResult(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
sessionID := r.URL.Query().Get("session")
|
||||
p.mu.Lock()
|
||||
sess := p.sessions[sessionID]
|
||||
p.mu.Unlock()
|
||||
if sess == nil {
|
||||
http.Error(w, "Session not found", http.StatusGone)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), oauthResultWait)
|
||||
defer cancel()
|
||||
result, err := sess.wait(ctx)
|
||||
if err != nil {
|
||||
// Client should retry — session may still receive its callback.
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
// Session consumed — delete so a retry sees 410 instead of racing another wait.
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, sess.id)
|
||||
p.mu.Unlock()
|
||||
|
||||
if result.Error != "" {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"error": result.Error})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GET /auth/done — static success page (Simkl's redirect target).
|
||||
func (p *oauthProxy) handleDone(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
renderSuccessPage(w)
|
||||
}
|
||||
|
||||
func (p *oauthProxy) exchangeCode(ctx context.Context, cfg oauthServiceConfig, service string, sess *oauthSession, code string) (oauthTokenResult, error) {
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"client_id": {cfg.ClientID},
|
||||
"redirect_uri": {p.redirectURI(service)},
|
||||
}
|
||||
if cfg.ClientSecret != "" {
|
||||
form.Set("client_secret", cfg.ClientSecret)
|
||||
}
|
||||
if cfg.UsePKCE {
|
||||
form.Set("code_verifier", sess.codeVerifier)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.TokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return oauthTokenResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return oauthTokenResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return oauthTokenResult{}, fmt.Errorf("upstream HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&parsed); err != nil {
|
||||
return oauthTokenResult{}, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
if parsed.AccessToken == "" {
|
||||
return oauthTokenResult{}, errors.New("missing access_token in upstream response")
|
||||
}
|
||||
return oauthTokenResult{
|
||||
AccessToken: parsed.AccessToken,
|
||||
RefreshToken: parsed.RefreshToken,
|
||||
ExpiresIn: parsed.ExpiresIn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *oauthProxy) redirectURI(service string) string {
|
||||
return fmt.Sprintf("%s/auth/%s/callback", p.baseURL, service)
|
||||
}
|
||||
|
||||
// cleanup drops sessions past oauthSessionTTL. Called by the main cleanup loop.
|
||||
func (p *oauthProxy) cleanup() {
|
||||
now := time.Now()
|
||||
p.mu.Lock()
|
||||
for id, sess := range p.sessions {
|
||||
if now.Sub(sess.createdAt) > oauthSessionTTL {
|
||||
delete(p.sessions, id)
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
p.ipMu.Lock()
|
||||
for ip, rl := range p.ipRate {
|
||||
if rl.stale(now) {
|
||||
delete(p.ipRate, ip)
|
||||
}
|
||||
}
|
||||
p.ipMu.Unlock()
|
||||
}
|
||||
|
||||
func (p *oauthProxy) ipAllow(ip string) bool {
|
||||
p.ipMu.Lock()
|
||||
defer p.ipMu.Unlock()
|
||||
rl, ok := p.ipRate[ip]
|
||||
if !ok {
|
||||
rl = newRateLimiter(oauthStartBurst, oauthStartRateSustained)
|
||||
p.ipRate[ip] = rl
|
||||
}
|
||||
return rl.allow()
|
||||
}
|
||||
|
||||
const successPageHTML = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Signed in</title><style>html,body{margin:0;height:100%}body{display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:-apple-system,system-ui,sans-serif;background:#fff;color:#1a1a1a;text-align:center;padding:1em;box-sizing:border-box}@media(prefers-color-scheme:dark){body{background:#0f0f0f;color:#f5f5f5}}.check{width:72px;height:72px;margin-bottom:20px}h2{margin:0 0 8px;font-weight:600;font-size:1.25rem}p{margin:0;opacity:.7;font-size:.95rem}</style><body><svg class="check" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#22c55e"/><path d="M7 12.5l3 3 7-7" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg><h2>Signed in to Plezy</h2><p>You can close this tab and return to the app.</p></body>`
|
||||
|
||||
// Split around the message so CSS `%` literals don't collide with Fprintf verbs.
|
||||
const errorPagePrefix = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign-in failed</title><style>html,body{margin:0;height:100%}body{display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:-apple-system,system-ui,sans-serif;background:#fff;color:#1a1a1a;text-align:center;padding:1em;box-sizing:border-box}@media(prefers-color-scheme:dark){body{background:#0f0f0f;color:#f5f5f5}}.x{width:72px;height:72px;margin-bottom:20px}h2{margin:0 0 8px;font-weight:600;font-size:1.25rem}p{margin:0;opacity:.7;font-size:.95rem}</style><body><svg class="x" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ef4444"/><path d="M8 8l8 8M16 8l-8 8" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round"/></svg><h2>Sign-in failed</h2><p>`
|
||||
const errorPageSuffix = `</p></body>`
|
||||
|
||||
func renderSuccessPage(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.WriteString(w, successPageHTML)
|
||||
}
|
||||
|
||||
func renderErrorPage(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
io.WriteString(w, errorPagePrefix)
|
||||
io.WriteString(w, html.EscapeString(message))
|
||||
io.WriteString(w, errorPageSuffix)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func randToken(numBytes int) string {
|
||||
b := make([]byte, numBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// crypto/rand failing is catastrophic; log.Fatalf matches the style
|
||||
// in newLogStore for similar unrecoverable init failures.
|
||||
log.Fatalf("crypto/rand: %v", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// randPKCEVerifier returns a 64-char string from MAL's required alphabet
|
||||
// (RFC 7636 §4.1 unreserved set).
|
||||
func randPKCEVerifier() string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
b := make([]byte, oauthPKCEVerifierLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
log.Fatalf("crypto/rand: %v", err)
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = alphabet[int(b[i])%len(alphabet)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockUpstream runs an httptest server that impersonates MAL/AniList. It
|
||||
// records the last token-exchange form submission and returns a canned
|
||||
// access_token/refresh_token response.
|
||||
type mockUpstream struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
lastForm url.Values
|
||||
tokenReply string
|
||||
tokenCode int
|
||||
}
|
||||
|
||||
// httpGet / httpPost / httpDo wrap the stdlib calls to fail the test on error.
|
||||
// Keeps test bodies one-liner without tripping `go vet`'s
|
||||
// "using resp before checking errors" rule.
|
||||
func httpGet(t *testing.T, url string) *http.Response {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", url, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func httpPost(t *testing.T, url, contentType string, body io.Reader) *http.Response {
|
||||
t.Helper()
|
||||
resp, err := http.Post(url, contentType, body)
|
||||
if err != nil {
|
||||
t.Fatalf("POST %s: %v", url, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func httpDo(t *testing.T, req *http.Request) *http.Response {
|
||||
t.Helper()
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do %s %s: %v", req.Method, req.URL, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func newMockUpstream(t *testing.T) *mockUpstream {
|
||||
t.Helper()
|
||||
m := &mockUpstream{
|
||||
tokenReply: `{"access_token":"tok-abc","refresh_token":"ref-xyz","expires_in":2678400}`,
|
||||
tokenCode: http.StatusOK,
|
||||
}
|
||||
m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/oauth/authorize":
|
||||
// Unused in tests — we assert on the 302 Location from our proxy.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/oauth/token":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.lastForm = r.PostForm
|
||||
code := m.tokenCode
|
||||
reply := m.tokenReply
|
||||
m.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
io.WriteString(w, reply)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(m.srv.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockUpstream) setReply(code int, body string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.tokenCode = code
|
||||
m.tokenReply = body
|
||||
}
|
||||
|
||||
func (m *mockUpstream) form() url.Values {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.lastForm
|
||||
}
|
||||
|
||||
// newOAuthHarness boots a relay-less httptest server that mounts /auth/* only,
|
||||
// with `mal` and `anilist` services pointed at a shared mock upstream.
|
||||
type oauthHarness struct {
|
||||
proxy *oauthProxy
|
||||
srv *httptest.Server
|
||||
base string
|
||||
upstream *mockUpstream
|
||||
}
|
||||
|
||||
func newOAuthHarness(t *testing.T) *oauthHarness {
|
||||
t.Helper()
|
||||
up := newMockUpstream(t)
|
||||
proxy := newOAuthProxy("http://placeholder", map[string]oauthServiceConfig{
|
||||
"mal": {
|
||||
ClientID: "mal-id",
|
||||
AuthorizeURL: up.srv.URL + "/oauth/authorize",
|
||||
TokenURL: up.srv.URL + "/oauth/token",
|
||||
UsePKCE: true,
|
||||
PKCEMethod: "plain",
|
||||
},
|
||||
"anilist": {
|
||||
ClientID: "anilist-id",
|
||||
ClientSecret: "anilist-secret",
|
||||
AuthorizeURL: up.srv.URL + "/oauth/authorize",
|
||||
TokenURL: up.srv.URL + "/oauth/token",
|
||||
},
|
||||
})
|
||||
mux := http.NewServeMux()
|
||||
registerOAuthRoutes(mux, proxy)
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
// Rewire baseURL to the real httptest URL so redirect_uri computes correctly.
|
||||
proxy.baseURL = srv.URL
|
||||
return &oauthHarness{proxy: proxy, srv: srv, base: srv.URL, upstream: up}
|
||||
}
|
||||
|
||||
func (h *oauthHarness) startSession(t *testing.T, service, ip string) (sessionID, qrURL string) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]string{"service": service})
|
||||
req, _ := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if ip != "" {
|
||||
req.Header.Set("X-Forwarded-For", ip)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("start status=%d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Session string `json:"session"`
|
||||
URL string `json:"url"`
|
||||
ExpiresIn int `json:"expiresIn"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if out.Session == "" || out.URL == "" {
|
||||
t.Fatalf("empty session/url: %+v", out)
|
||||
}
|
||||
return out.Session, out.URL
|
||||
}
|
||||
|
||||
// ====== /auth/start ======
|
||||
|
||||
func TestOAuthStartReturnsSessionAndURL(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
session, qr := h.startSession(t, "mal", "1.2.3.4")
|
||||
if !strings.HasPrefix(qr, h.base+"/auth/mal?session=") {
|
||||
t.Fatalf("url=%q doesn't look like the authorize start URL", qr)
|
||||
}
|
||||
if !strings.Contains(qr, url.QueryEscape(session)) {
|
||||
t.Fatalf("url=%q missing session token", qr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStartRejectsUnknownService(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
body, _ := json.Marshal(map[string]string{"service": "nope"})
|
||||
resp := httpPost(t, h.base+"/auth/start", "application/json", bytes.NewReader(body))
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStartRejectsInvalidJSON(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
resp := httpPost(t, h.base+"/auth/start", "application/json", strings.NewReader("not json"))
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStartRateLimitedPerIP(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
ip := "5.5.5.5"
|
||||
for i := 0; i < oauthStartBurst; i++ {
|
||||
h.startSession(t, "mal", ip) // should all succeed
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"service": "mal"})
|
||||
req, err := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Forwarded-For", ip)
|
||||
resp := httpDo(t, req)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
t.Fatalf("status=%d want 429", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStartMethodNotAllowed(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
resp := httpGet(t, h.base+"/auth/start")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status=%d want 405", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ====== /auth/:service (authorize redirect) ======
|
||||
|
||||
func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
sess, _ := h.startSession(t, "mal", "1.1.1.1")
|
||||
|
||||
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
resp, err := client.Get(h.base + "/auth/mal?session=" + url.QueryEscape(sess))
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("status=%d want 302", resp.StatusCode)
|
||||
}
|
||||
loc, err := url.Parse(resp.Header.Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse Location: %v", err)
|
||||
}
|
||||
q := loc.Query()
|
||||
if q.Get("client_id") != "mal-id" {
|
||||
t.Errorf("client_id=%q", q.Get("client_id"))
|
||||
}
|
||||
if q.Get("response_type") != "code" {
|
||||
t.Errorf("response_type=%q", q.Get("response_type"))
|
||||
}
|
||||
if q.Get("state") != sess {
|
||||
t.Errorf("state=%q, want session %q", q.Get("state"), sess)
|
||||
}
|
||||
if q.Get("code_challenge_method") != "plain" {
|
||||
t.Errorf("code_challenge_method=%q, want plain", q.Get("code_challenge_method"))
|
||||
}
|
||||
if q.Get("code_challenge") == "" {
|
||||
t.Error("code_challenge missing")
|
||||
}
|
||||
if !strings.HasSuffix(q.Get("redirect_uri"), "/auth/mal/callback") {
|
||||
t.Errorf("redirect_uri=%q", q.Get("redirect_uri"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthAuthorizeAnilistRedirectOmitsPKCE(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
sess, _ := h.startSession(t, "anilist", "1.1.1.2")
|
||||
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
resp, err := client.Get(h.base + "/auth/anilist?session=" + url.QueryEscape(sess))
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
loc, _ := url.Parse(resp.Header.Get("Location"))
|
||||
q := loc.Query()
|
||||
if q.Get("code_challenge") != "" {
|
||||
t.Errorf("anilist redirect should not include code_challenge, got %q", q.Get("code_challenge"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
resp := httpGet(t, h.base+"/auth/mal?session=bogus")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status=%d want 404", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "no longer valid") {
|
||||
t.Errorf("expected error page html, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
sess, _ := h.startSession(t, "mal", "1.1.1.3")
|
||||
// Try to use the MAL session against the AniList authorize endpoint.
|
||||
resp := httpGet(t, h.base+"/auth/anilist?session="+url.QueryEscape(sess))
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status=%d want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ====== /auth/:service/callback + /auth/result ======
|
||||
|
||||
func TestOAuthCallbackExchangesCodeAndResultReturnsTokens(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
sess, _ := h.startSession(t, "mal", "2.2.2.1")
|
||||
|
||||
resultCh := make(chan map[string]any, 1)
|
||||
go func() {
|
||||
resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess))
|
||||
if err != nil {
|
||||
resultCh <- map[string]any{"_err": err.Error()}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&m)
|
||||
resultCh <- m
|
||||
}()
|
||||
|
||||
// Hit the callback as the upstream browser would.
|
||||
cbURL := fmt.Sprintf("%s/auth/mal/callback?code=CODE123&state=%s", h.base, url.QueryEscape(sess))
|
||||
resp, err := http.Get(cbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("callback: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("callback status=%d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Upstream should have been called with PKCE + code.
|
||||
form := h.upstream.form()
|
||||
if form.Get("code") != "CODE123" {
|
||||
t.Errorf("upstream code=%q", form.Get("code"))
|
||||
}
|
||||
if form.Get("code_verifier") == "" {
|
||||
t.Error("upstream missing code_verifier (PKCE)")
|
||||
}
|
||||
if form.Get("grant_type") != "authorization_code" {
|
||||
t.Errorf("grant_type=%q", form.Get("grant_type"))
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-resultCh:
|
||||
if got["accessToken"] != "tok-abc" {
|
||||
t.Errorf("accessToken=%v want tok-abc", got["accessToken"])
|
||||
}
|
||||
if got["refreshToken"] != "ref-xyz" {
|
||||
t.Errorf("refreshToken=%v want ref-xyz", got["refreshToken"])
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("result never returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallbackUpstreamError(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
h.upstream.setReply(http.StatusBadRequest, `{"error":"invalid_grant"}`)
|
||||
sess, _ := h.startSession(t, "mal", "2.2.2.2")
|
||||
|
||||
resultCh := make(chan map[string]any, 1)
|
||||
go func() {
|
||||
resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess))
|
||||
if err != nil {
|
||||
resultCh <- map[string]any{"_err": err.Error()}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&m)
|
||||
resultCh <- m
|
||||
}()
|
||||
|
||||
resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?code=CODE&state=%s", h.base, url.QueryEscape(sess)))
|
||||
resp.Body.Close()
|
||||
|
||||
select {
|
||||
case got := <-resultCh:
|
||||
if got["error"] != "exchange_failed" {
|
||||
t.Errorf("expected error=exchange_failed, got %v", got)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("result never returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallbackUserCancelled(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
sess, _ := h.startSession(t, "mal", "2.2.2.3")
|
||||
|
||||
resultCh := make(chan map[string]any, 1)
|
||||
go func() {
|
||||
resp, err := http.Get(h.base + "/auth/result?session=" + url.QueryEscape(sess))
|
||||
if err != nil {
|
||||
resultCh <- map[string]any{"_err": err.Error()}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var m map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&m)
|
||||
resultCh <- m
|
||||
}()
|
||||
|
||||
resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?error=access_denied&state=%s", h.base, url.QueryEscape(sess)))
|
||||
resp.Body.Close()
|
||||
|
||||
select {
|
||||
case got := <-resultCh:
|
||||
if got["error"] != "access_denied" {
|
||||
t.Errorf("expected error=access_denied, got %v", got)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("result never returned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallbackUnknownSessionIgnored(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
resp := httpGet(t, h.base+"/auth/mal/callback?code=X&state=bogus")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status=%d want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthResultUnknownSession(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
resp := httpGet(t, h.base+"/auth/result?session=nope")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusGone {
|
||||
t.Fatalf("status=%d want 410", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthResultConsumedSecondCallIsGone(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
sess, _ := h.startSession(t, "mal", "3.3.3.1")
|
||||
|
||||
// Pre-seat the result so the first /auth/result returns immediately.
|
||||
h.proxy.mu.Lock()
|
||||
h.proxy.sessions[sess].complete(oauthTokenResult{AccessToken: "tok"})
|
||||
h.proxy.mu.Unlock()
|
||||
|
||||
r1 := httpGet(t, h.base+"/auth/result?session="+url.QueryEscape(sess))
|
||||
r1.Body.Close()
|
||||
if r1.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first result status=%d", r1.StatusCode)
|
||||
}
|
||||
// After consumption the session is deleted; second call sees unknown session.
|
||||
r2 := httpGet(t, h.base+"/auth/result?session="+url.QueryEscape(sess))
|
||||
r2.Body.Close()
|
||||
if r2.StatusCode != http.StatusGone {
|
||||
t.Fatalf("second result status=%d want 410", r2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Cleanup ======
|
||||
|
||||
func TestOAuthCleanupExpiresOldSessions(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
sess, _ := h.startSession(t, "mal", "4.4.4.1")
|
||||
|
||||
h.proxy.mu.Lock()
|
||||
h.proxy.sessions[sess].createdAt = time.Now().Add(-2 * oauthSessionTTL)
|
||||
h.proxy.mu.Unlock()
|
||||
|
||||
h.proxy.cleanup()
|
||||
|
||||
h.proxy.mu.Lock()
|
||||
_, exists := h.proxy.sessions[sess]
|
||||
h.proxy.mu.Unlock()
|
||||
if exists {
|
||||
t.Fatal("expired session should have been cleaned up")
|
||||
}
|
||||
}
|
||||
|
||||
// ====== /auth/done ======
|
||||
|
||||
func TestOAuthDoneRendersSuccessPage(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
resp := httpGet(t, h.base+"/auth/done")
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "Signed in to Plezy") {
|
||||
t.Errorf("body missing success message: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Disabled proxy returns 503 ======
|
||||
|
||||
func TestOAuthRoutesReturn503WhenDisabled(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
registerOAuthRoutes(mux, nil)
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
resp := httpGet(t, srv.URL+"/auth/start")
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("status=%d want 503", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Path dispatch ======
|
||||
|
||||
func TestOAuthAuthRootRejectsBadPaths(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
for _, path := range []string{"/auth/mal/weird", "/auth/unknown", "/auth/mal/callback/extra"} {
|
||||
resp := httpGet(t, h.base+path)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s: status=%d want 404", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Long-poll timeout ======
|
||||
|
||||
func TestOAuthResultBlocksUntilCancel(t *testing.T) {
|
||||
// Pending sessions must NOT respond immediately; the long-poll contract is
|
||||
// that /auth/result blocks until the session completes or the client
|
||||
// cancels. The 204-after-server-timeout path takes 50s so isn't asserted.
|
||||
h := newOAuthHarness(t)
|
||||
sess, _ := h.startSession(t, "mal", "5.5.5.1")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.base+"/auth/result?session="+url.QueryEscape(sess), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
t.Fatalf("expected client-side cancel, got status=%d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user