recycleactor commited on
Commit
45f75e2
·
verified ·
1 Parent(s): cab277a

Upload main.rs

Browse files
Files changed (1) hide show
  1. src/main.rs +118 -15
src/main.rs CHANGED
@@ -3,7 +3,7 @@ use axum::{
3
  http::StatusCode,
4
  http::{HeaderMap, Method, header::{RANGE, CONTENT_RANGE, ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_TYPE}},
5
  response::{IntoResponse, Html},
6
- extract::{Json, State, Query, Form},
7
  Router,
8
  };
9
  use tower_http::{cors::{CorsLayer, Any}, compression::CompressionLayer, trace::TraceLayer};
@@ -1195,6 +1195,23 @@ fn gen_pair_code() -> String {
1195
  hex.chars().take(10).collect::<String>()
1196
  }
1197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1198
  #[derive(Deserialize)]
1199
  struct PairStatusQuery {
1200
  code: String,
@@ -1277,27 +1294,111 @@ struct PairPageQuery {
1277
  code: Option<String>,
1278
  }
1279
 
1280
- async fn pair_page(Query(q): Query<PairPageQuery>) -> impl IntoResponse {
1281
- let code = q.code.unwrap_or_default();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1282
  let safe_code = htmlesc(&code);
 
 
 
1283
  let html = format!(
1284
  "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
1285
  <title>Deezer Login</title>\
1286
  <style>body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:#0b0b0b;color:#fff;margin:0;padding:20px}}\
1287
  .box{{max-width:420px;margin:0 auto;background:#151515;padding:18px;border-radius:12px}}\
1288
- input{{width:100%;padding:12px 14px;margin:10px 0;border-radius:10px;border:1px solid #333;background:#0f0f0f;color:#fff;font-size:16px}}\
1289
- button{{width:100%;padding:12px 14px;border-radius:10px;border:0;background:#1db954;color:#000;font-weight:700;font-size:16px}}\
1290
- .muted{{color:#aaa;font-size:13px;line-height:1.35}}</style>\
 
 
1291
  </head><body><div class=\"box\">\
1292
- <h2 style=\"margin:0 0 6px\">Вход Deezer</h2>\
1293
- <div class=\"muted\">Код: <b>{safe_code}</b></div>\
1294
- <form method=\"POST\" action=\"/pair/complete\">\
1295
- <input type=\"hidden\" name=\"code\" value=\"{safe_code}\">\
1296
- <input name=\"email\" type=\"email\" placeholder=\"Email\" autocomplete=\"username\" required>\
1297
- <input name=\"password\" type=\"password\" placeholder=\"Пароль\" autocomplete=\"current-password\" required>\
1298
- <button type=\"submit\">Войти</button>\
1299
- </form>\
1300
- <p class=\"muted\" style=\"margin:12px 0 0\">Пароль используется только для получения токена доступа и не сохраняется.</p>\
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1301
  </div></body></html>"
1302
  );
1303
  Html(html)
@@ -1888,6 +1989,8 @@ async fn main() {
1888
  .route("/pair/start", get(pair_start))
1889
  .route("/pair/status", get(pair_status))
1890
  .route("/pair", get(pair_page))
 
 
1891
  .route("/pair/complete", post(pair_complete))
1892
  .route("/oauth/me", post(oauth_me))
1893
  .route("/oauth/playlists", post(oauth_playlists))
 
3
  http::StatusCode,
4
  http::{HeaderMap, Method, header::{RANGE, CONTENT_RANGE, ACCEPT_RANGES, CONTENT_LENGTH, CONTENT_TYPE}},
5
  response::{IntoResponse, Html},
6
+ extract::{Json, State, Query, Form, Host},
7
  Router,
8
  };
9
  use tower_http::{cors::{CorsLayer, Any}, compression::CompressionLayer, trace::TraceLayer};
 
1195
  hex.chars().take(10).collect::<String>()
1196
  }
1197
 
1198
+ fn deezer_app_id() -> String {
1199
+ std::env::var("DEEZER_APP_ID")
1200
+ .ok()
1201
+ .unwrap_or_else(|| "447462".to_string())
1202
+ .trim()
1203
+ .to_string()
1204
+ }
1205
+
1206
+ fn forwarded_proto(headers: &HeaderMap) -> String {
1207
+ headers
1208
+ .get("x-forwarded-proto")
1209
+ .and_then(|v| v.to_str().ok())
1210
+ .unwrap_or("https")
1211
+ .trim()
1212
+ .to_string()
1213
+ }
1214
+
1215
  #[derive(Deserialize)]
1216
  struct PairStatusQuery {
1217
  code: String,
 
1294
  code: Option<String>,
1295
  }
1296
 
1297
+ #[derive(Debug, Deserialize)]
1298
+ struct PairOauthReq {
1299
+ code: String,
1300
+ access_token: String,
1301
+ user_id: Option<String>,
1302
+ }
1303
+
1304
+ async fn pair_oauth(State(state): State<AppState>, Json(req): Json<PairOauthReq>) -> impl IntoResponse {
1305
+ let code = req.code.trim().to_string();
1306
+ let access_token = req.access_token.trim().to_string();
1307
+ let user_id = req
1308
+ .user_id
1309
+ .unwrap_or_default()
1310
+ .trim()
1311
+ .to_string()
1312
+ .chars()
1313
+ .filter(|c| c.is_ascii_digit())
1314
+ .collect::<String>();
1315
+ if code.is_empty() || access_token.is_empty() {
1316
+ return json_error(StatusCode::BAD_REQUEST, "code/access_token required");
1317
+ }
1318
+
1319
+ let mut map = state.pair.write().await;
1320
+ let Some(sess) = map.get_mut(&code) else {
1321
+ return json_error(StatusCode::NOT_FOUND, "no such code");
1322
+ };
1323
+ sess.access_token = Some(access_token);
1324
+ if !user_id.is_empty() {
1325
+ sess.user_id = Some(user_id);
1326
+ }
1327
+ sess.error = None;
1328
+ (StatusCode::OK, Json(json!({ "ok": true }))).into_response()
1329
+ }
1330
+
1331
+ async fn pair_channel() -> impl IntoResponse {
1332
+ let html = "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head><body></body></html>";
1333
+ (StatusCode::OK, Html(html.to_string())).into_response()
1334
+ }
1335
+
1336
+ async fn pair_page(Host(host): Host, headers: HeaderMap, Query(q): Query<PairPageQuery>) -> impl IntoResponse {
1337
+ let code = q.code.unwrap_or_default().trim().to_string();
1338
  let safe_code = htmlesc(&code);
1339
+ let scheme = forwarded_proto(&headers);
1340
+ let channel_url = htmlesc(&format!("{scheme}://{host}/pair/channel"));
1341
+ let app_id = htmlesc(&deezer_app_id());
1342
  let html = format!(
1343
  "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
1344
  <title>Deezer Login</title>\
1345
  <style>body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:#0b0b0b;color:#fff;margin:0;padding:20px}}\
1346
  .box{{max-width:420px;margin:0 auto;background:#151515;padding:18px;border-radius:12px}}\
1347
+ button{{width:100%;padding:12px 14px;border-radius:10px;border:0;background:#a238ff;color:#fff;font-weight:800;font-size:16px}}\
1348
+ .muted{{color:#aaa;font-size:13px;line-height:1.35}}\
1349
+ .ok{{color:#1db954}}\
1350
+ .err{{color:#ff5a5a}}\
1351
+ a{{color:#a238ff}}</style>\
1352
  </head><body><div class=\"box\">\
1353
+ <h2 style=\"margin:0 0 6px\">Deezer: вход для ТВ</h2>\
1354
+ <div class=\"muted\">Код пары: <b>{safe_code}</b></div>\
1355
+ <div class=\"muted\" style=\"margin:10px 0 14px\">Нажми кнопку, войди в Deezer и подтверди доступ. После успеха вернись на телевизор.</div>\
1356
+ <button id=\"dz-login\" type=\"button\">Войти через Deezer</button>\
1357
+ <div id=\"msg\" class=\"muted\" style=\"margin:12px 0 0\"></div>\
1358
+ <div id=\"dz-root\"></div>\
1359
+ <script src=\"https://cdns-files.deezer.com/js/min/dz.js\"></script>\
1360
+ <script>\
1361
+ (function(){\
1362
+ var code = \"{safe_code}\";\
1363
+ var msg = document.getElementById('msg');\
1364
+ function setMsg(cls, text){ msg.className = cls ? (cls + ' muted') : 'muted'; msg.textContent = text; }\
1365
+ if(!code){ setMsg('err','Нет кода пары. Открой ссылку заново с QR.'); return; }\
1366
+ function postToken(at, uid){\
1367
+ return fetch('/pair/oauth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({code:code,access_token:at,user_id:String(uid||'')})})\
1368
+ .then(function(r){return r.json().catch(function(){return {};}).then(function(j){return {ok:r.ok, json:j};});});\
1369
+ }\
1370
+ function initDZ(){\
1371
+ try{\
1372
+ DZ.init({appId:'{app_id}',channelUrl:'{channel_url}'});\
1373
+ return true;\
1374
+ }catch(e){\
1375
+ return false;\
1376
+ }\
1377
+ }\
1378
+ if(!initDZ()){\
1379
+ setMsg('err','Не удалось инициализировать Deezer SDK.');\
1380
+ return;\
1381
+ }\
1382
+ document.getElementById('dz-login').addEventListener('click', function(){\
1383
+ setMsg('', 'Ожидаю Deezer…');\
1384
+ DZ.login(function(resp){\
1385
+ if(resp && resp.authResponse && resp.authResponse.accessToken){\
1386
+ setMsg('', 'Сохраняю токен…');\
1387
+ postToken(resp.authResponse.accessToken, resp.authResponse.userID).then(function(r){\
1388
+ if(r.ok && !r.json.error){\
1389
+ setMsg('ok','Готово ✓ Вернись на телевизор.');\
1390
+ }else{\
1391
+ setMsg('err','Ошибка сохранения: ' + (r.json.error || 'error'));\
1392
+ }\
1393
+ }).catch(function(){ setMsg('err','Ошибка сети при сохранении'); });\
1394
+ }else{\
1395
+ setMsg('err','Вход отменён или не удалось получить токен');\
1396
+ }\
1397
+ }, {perms:'basic_access,email,manage_library,listening_history'});\
1398
+ });\
1399
+ })();\
1400
+ </script>\
1401
+ <p class=\"muted\" style=\"margin:14px 0 0\">Если Deezer пишет про неверный redirect/domain — нужно указать свой <b>DEEZER_APP_ID</b> для домена этого сервера.</p>\
1402
  </div></body></html>"
1403
  );
1404
  Html(html)
 
1989
  .route("/pair/start", get(pair_start))
1990
  .route("/pair/status", get(pair_status))
1991
  .route("/pair", get(pair_page))
1992
+ .route("/pair/channel", get(pair_channel))
1993
+ .route("/pair/oauth", post(pair_oauth))
1994
  .route("/pair/complete", post(pair_complete))
1995
  .route("/oauth/me", post(oauth_me))
1996
  .route("/oauth/playlists", post(oauth_playlists))