Spaces:
Paused
Paused
Upload main.rs
Browse files- 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 |
-
|
| 1281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1289 |
-
|
| 1290 |
-
.
|
|
|
|
|
|
|
| 1291 |
</head><body><div class=\"box\">\
|
| 1292 |
-
<h2 style=\"margin:0 0 6px\">
|
| 1293 |
-
<div class=\"muted\">Код: <b>{safe_code}</b></div>\
|
| 1294 |
-
<
|
| 1295 |
-
|
| 1296 |
-
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
<
|
| 1300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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))
|