added partial content support (not supported with tagging rn), added content-length header (fixes super aids), updated dependencies

This commit is contained in:
uh wot 2022-02-27 21:20:20 +01:00
parent 873e1002b9
commit ea88be5762
Signed by: uhwot
GPG Key ID: CB2454984587B781
7 changed files with 174 additions and 112 deletions

View File

@ -8,10 +8,11 @@ edition = "2021"
crate-type = ["cdylib", "rlib"] crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
block-modes = "0.8" blowfish = "0.9"
blowfish = "0.8" aes = "0.8"
aes = "0.7"
md-5 = "0.10" md-5 = "0.10"
cipher = { version = "0.4", features = ["block-padding"] }
cbc = "0.1"
hex = "0.4" hex = "0.4"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"

4
dist/worker.js vendored

File diff suppressed because one or more lines are too long

218
index.js
View File

@ -63,10 +63,6 @@ async function gw_api_call(method, params) {
return json.results return json.results
} }
let license_token
let checkForm
let sid
let access_token
router.get('/:type/:id', async request => { router.get('/:type/:id', async request => {
const { query } = request const { query } = request
let { type, id } = request.params let { type, id } = request.params
@ -80,42 +76,6 @@ router.get('/:type/:id', async request => {
return new Response("Invalid ID", { status: 400, headers: { 'content-type': 'text/plain' } }) return new Response("Invalid ID", { status: 400, headers: { 'content-type': 'text/plain' } })
} }
// cookie/token stuff isn't needed for m3u8 playlists
if (type === 'track') {
if (id >= 0) { // checks if not user-upped track
license_token = await KV.get('license_token')
checkForm = await KV.get('checkForm')
sid = await KV.get('sid')
if (license_token === null) {
const user_data = await gw_api_call('deezer.getUserData')
if (user_data.constructor.name === 'Response') {
return user_data
}
if (user_data.USER.USER_ID === 0) {
return new Response('Invalid arl', { status: 500, headers: { 'content-type': 'text/plain' } })
}
license_token = user_data.USER.OPTIONS.license_token
await KV.put('license_token', license_token, { expirationTtl: 3600 })
}
} else {
access_token = await KV.get('access_token')
if (access_token === null) {
const response = await fetch(`https://connect.deezer.com/oauth/access_token.php?grant_type=client_credentials&client_id=${client_id}&client_secret=${client_secret}&output=json`)
const json = await response.json()
if (json.error !== undefined) {
return new Response("Couldn't get access token from Deezer", { status: 500, headers: { 'content-type': 'text/plain' } })
}
access_token = json.access_token
await KV.put('access_token', access_token, { expirationTtl: parseInt(json.expires) })
}
}
}
let format = query.f let format = query.f
if (format === undefined) { if (format === undefined) {
format = '320' format = '320'
@ -138,22 +98,71 @@ router.get('/:type/:id', async request => {
switch (type) { switch (type) {
case 'track': case 'track':
return await track(id, format, tagging) return await track(id, format, tagging, request.headers.get('range'))
case 'album': case 'album':
case 'playlist': case 'playlist':
return await m3u8(type, id, format, tagging, request.headers.get('host')) return await m3u8(type, id, format, tagging, request.headers.get('host'))
} }
}) })
async function track(id, format, tagging) { let license_token
let checkForm
let sid
async function auth_gw() {
license_token = await KV.get('license_token')
checkForm = await KV.get('checkForm')
sid = await KV.get('sid')
if (license_token === null) {
const user_data = await gw_api_call('deezer.getUserData')
if (user_data.constructor.name === 'Response') {
return user_data
}
if (user_data.USER.USER_ID === 0) {
return new Response('Invalid arl', { status: 500, headers: { 'content-type': 'text/plain' } })
}
license_token = user_data.USER.OPTIONS.license_token
await KV.put('license_token', license_token, { expirationTtl: 3600 })
}
}
let access_token
async function auth_dzapi() {
access_token = await KV.get('access_token')
if (access_token === null) {
const response = await fetch(`https://connect.deezer.com/oauth/access_token.php?grant_type=client_credentials&client_id=${client_id}&client_secret=${client_secret}&output=json`)
const json = await response.json()
if (json.error !== undefined) {
return new Response("Couldn't get access token from Deezer", { status: 500, headers: { 'content-type': 'text/plain' } })
}
access_token = json.access_token
await KV.put('access_token', access_token, { expirationTtl: parseInt(json.expires) })
}
}
async function track(id, format, tagging, range_header) {
// other users' user-upped tracks cannot be downloaded with the gw-light API // other users' user-upped tracks cannot be downloaded with the gw-light API
let json; let json;
if (id >= 0) { if (id >= 0) {
let err_auth = await auth_gw()
if (err_auth) {
return err_auth
}
json = await gw_api_call('song.getData', { 'SNG_ID': id }) json = await gw_api_call('song.getData', { 'SNG_ID': id })
if (json.constructor.name === 'Response') { if (json.constructor.name === 'Response') {
return json return json
} }
} else { // user-upped track } else { // user-upped track
let err_auth = await auth_dzapi()
if (err_auth) {
return err_auth
}
const response = await fetch(`https://api.deezer.com/track/${id}?access_token=${access_token}`) const response = await fetch(`https://api.deezer.com/track/${id}?access_token=${access_token}`)
json = await response.json() json = await response.json()
if (json.error !== undefined) { if (json.error !== undefined) {
@ -176,10 +185,21 @@ async function track(id, format, tagging) {
format = 'misc' // user-upped tracks always use 'misc' as format format = 'misc' // user-upped tracks always use 'misc' as format
} }
if (json['FILESIZE_' + formats[format].gw] == false) { let filesize = json['FILESIZE_' + formats[format].gw]
if (filesize == false) {
return new Response('Format unavailable', { status: 403, headers: { 'content-type': 'text/plain' } }) return new Response('Format unavailable', { status: 403, headers: { 'content-type': 'text/plain' } })
} }
let range_start = null
let range_end = null
if (!tagging && range_header !== null) {
const range_match = range_header.match(/^bytes=(\d+)-(\d*)/)
if (range_match !== null) {
range_start = parseInt(range_match[1])
range_end = parseInt(range_match[2])
}
}
const wasm = await import('./pkg') const wasm = await import('./pkg')
let track_url let track_url
@ -230,14 +250,60 @@ async function track(id, format, tagging) {
track_url = await legacy_track_url(json, format, wasm.legacy_stream_url) track_url = await legacy_track_url(json, format, wasm.legacy_stream_url)
} }
const track = await fetch(track_url) filesize = parseInt(filesize)
if (track.status !== 200) {
return new Response("Couldn't get track stream", { status: 403, headers: { 'content-type': 'text/plain' } })
}
let title = json.SNG_TITLE let title = json.SNG_TITLE
if (json.VERSION) title += ` ${json.VERSION}` if (json.VERSION) title += ` ${json.VERSION}`
let init = {}
let resp_init = {
status: 200,
headers: {
'content-type': formats[format].mime,
'content-disposition': `inline; filename="${title.replaceAll('"', '\\"')} [${id}].${formats[format].ext}"`
}
}
if (!tagging) {
resp_init.headers['accept-ranges'] = 'bytes'
}
let resp_size = filesize
let fixed_range_start
let fixed_range_end = filesize
let bytes_remove_start = 0
let bytes_remove_end = 0
if (range_start !== null) {
if (isNaN(range_end)) {
range_end = filesize
} else {
range_end++
}
if (range_start < 0 || range_start > filesize || range_end < range_start || range_end > filesize) {
return new Response('Range invalid', { status: 416, headers: { 'content-type': 'text/plain' } })
}
bytes_remove_start = range_start % 2048
bytes_remove_end = 2048 - range_end % 2048
fixed_range_start = range_start - bytes_remove_start
fixed_range_end = range_end + bytes_remove_end
if (fixed_range_end >= filesize) {
fixed_range_end = filesize
bytes_remove_end = 0
}
init = { headers: new Headers({ range: `bytes=${fixed_range_start}-${fixed_range_end - 1}` }) }
resp_init.status = 206
resp_init.headers['content-range'] = `bytes ${range_start}-${range_end - 1}/${filesize}`
resp_size = range_end - range_start
}
const track = await fetch(track_url, init)
if (![200, 206].includes(track.status)) {
return new Response("Couldn't get track stream", { status: 403, headers: { 'content-type': 'text/plain' } })
}
let id3 let id3
if (tagging) { if (tagging) {
id3 = new ID3Writer(Buffer.alloc(0)); id3 = new ID3Writer(Buffer.alloc(0));
@ -287,9 +353,11 @@ async function track(id, format, tagging) {
} }
id3.addTag(); id3.addTag();
resp_size += id3.arrayBuffer.byteLength
} }
let { readable, writable } = new TransformStream() let { readable, writable } = new FixedLengthStream(resp_size)
const writer = writable.getWriter() const writer = writable.getWriter()
if (tagging) { if (tagging) {
@ -303,43 +371,46 @@ async function track(id, format, tagging) {
const cipher = new wasm.Cipher(String(id)) const cipher = new wasm.Cipher(String(id))
const length = parseInt(track.headers.get('Content-Length')) pipeDecryptedStream(writer, track.body, fixed_range_end, cipher, fixed_range_start, bytes_remove_start, bytes_remove_end)
pipeDecryptedStream(writer, track.body, length, cipher) return new Response(readable, resp_init)
const headers = {
'content-type': formats[format].mime,
'content-disposition': `inline; filename="${title.replaceAll('"', '\\"')} [${id}].${formats[format].ext}"`
}
return new Response(readable, { status: 200, headers })
} }
async function pipeDecryptedStream(writer, body, length, cipher) { async function pipeDecryptedStream(writer, body, length, cipher, fixed_range_start, bytes_remove_start, bytes_remove_end) {
const reader = body.getReader({ mode: 'byob' }) const reader = body.getReader({ mode: 'byob' })
let byteCount = 0 let byte_count = 0
if (fixed_range_start) {
byte_count = fixed_range_start
}
let end = false let end = false
while (!end) { while (!end) {
end = byteCount + 2048 > length end = byte_count + 2048 >= length
let minBytes let min_bytes
if (!end) { if (!end) {
minBytes = 2048 min_bytes = 2048
} else { } else {
minBytes = length - byteCount min_bytes = length - byte_count
} }
let chunk = (await reader.readAtLeast(minBytes, new Uint8Array(2048))).value let chunk = (await reader.readAtLeast(min_bytes, new Uint8Array(2048))).value
if (byteCount % 6144 === 0 && !end) { if (byte_count % 6144 === 0 && !end) {
// encrypted chunk // encrypted chunk
cipher.decrypt_chunk(chunk) cipher.decrypt_chunk(chunk)
} }
writer.write(chunk) if (bytes_remove_start !== 0) {
byteCount += 2048 chunk = chunk.slice(bytes_remove_start)
bytes_remove_start = 0
}
if (end && bytes_remove_end !== 0) {
chunk = chunk.slice(0, chunk.length - bytes_remove_end)
}
byte_count += 2048
await writer.write(chunk)
} }
reader.cancel()
writer.close() writer.close()
} }
@ -385,17 +456,6 @@ router.get('/', () => {
router.all("*", () => new Response("not found", { status: 404, headers: { 'content-type': 'text/plain' } })) router.all("*", () => new Response("not found", { status: 404, headers: { 'content-type': 'text/plain' } }))
async function handleRequest(request) {
const r = new Router()
r.get('/', () => indexHandler())
r.get('/track/-?\\d+', () => handler('track', request))
r.get('/album/\\d+', () => handler('album', request))
r.get('/playlist/\\d+', () => handler('playlist', request))
const resp = await r.route(request)
return resp
}
addEventListener('fetch', event => addEventListener('fetch', event =>
event.respondWith(router.handle(event.request)) event.respondWith(router.handle(event.request))
) )

30
package-lock.json generated
View File

@ -14,9 +14,9 @@
"serverless-cloudflare-workers": "^1.2.0" "serverless-cloudflare-workers": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@wasm-tool/wasm-pack-plugin": "^1.5.0", "@wasm-tool/wasm-pack-plugin": "^1.6.0",
"html-loader": "^1.3.2", "html-loader": "^1.3.2",
"prettier": "^1.17.0" "prettier": "^2.5.1"
} }
}, },
"node_modules/@types/eslint": { "node_modules/@types/eslint": {
@ -62,9 +62,9 @@
"peer": true "peer": true
}, },
"node_modules/@wasm-tool/wasm-pack-plugin": { "node_modules/@wasm-tool/wasm-pack-plugin": {
"version": "1.5.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@wasm-tool/wasm-pack-plugin/-/wasm-pack-plugin-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@wasm-tool/wasm-pack-plugin/-/wasm-pack-plugin-1.6.0.tgz",
"integrity": "sha512-qsGJ953zrXZdXW58cfYOh2nBXp0SYBsFhkxqh9p4JK8cXllEzHeRXoVO+qtgEB31+s1tsL8eda3Uy97W/7yOAg==", "integrity": "sha512-Iax4nEgIvVCZqrmuseJm7ln/muWpg7uT5fXMAT0crYo+k5JTuZE58DJvBQoeIAegA3IM9cZgfkcZjAOUCPsT1g==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"chalk": "^2.4.1", "chalk": "^2.4.1",
@ -3170,15 +3170,15 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "1.19.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz",
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", "integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==",
"dev": true, "dev": true,
"bin": { "bin": {
"prettier": "bin-prettier.js" "prettier": "bin-prettier.js"
}, },
"engines": { "engines": {
"node": ">=4" "node": ">=10.13.0"
} }
}, },
"node_modules/process": { "node_modules/process": {
@ -4851,9 +4851,9 @@
"peer": true "peer": true
}, },
"@wasm-tool/wasm-pack-plugin": { "@wasm-tool/wasm-pack-plugin": {
"version": "1.5.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@wasm-tool/wasm-pack-plugin/-/wasm-pack-plugin-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@wasm-tool/wasm-pack-plugin/-/wasm-pack-plugin-1.6.0.tgz",
"integrity": "sha512-qsGJ953zrXZdXW58cfYOh2nBXp0SYBsFhkxqh9p4JK8cXllEzHeRXoVO+qtgEB31+s1tsL8eda3Uy97W/7yOAg==", "integrity": "sha512-Iax4nEgIvVCZqrmuseJm7ln/muWpg7uT5fXMAT0crYo+k5JTuZE58DJvBQoeIAegA3IM9cZgfkcZjAOUCPsT1g==",
"dev": true, "dev": true,
"requires": { "requires": {
"chalk": "^2.4.1", "chalk": "^2.4.1",
@ -7458,9 +7458,9 @@
"integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=" "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs="
}, },
"prettier": { "prettier": {
"version": "1.19.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz",
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", "integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==",
"dev": true "dev": true
}, },
"process": { "process": {

View File

@ -10,9 +10,9 @@
"author": "uh_wot <uhwot@protonmail.com>", "author": "uh_wot <uhwot@protonmail.com>",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@wasm-tool/wasm-pack-plugin": "^1.5.0", "@wasm-tool/wasm-pack-plugin": "^1.6.0",
"html-loader": "^1.3.2", "html-loader": "^1.3.2",
"prettier": "^1.17.0" "prettier": "^2.5.1"
}, },
"dependencies": { "dependencies": {
"browser-id3-writer": "^4.4.0", "browser-id3-writer": "^4.4.0",

View File

@ -1,20 +1,21 @@
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use aes::Aes128Enc;
use blowfish::Blowfish; use blowfish::Blowfish;
use aes::Aes128;
use block_modes::{BlockMode, Cbc, Ecb};
use block_modes::block_padding::{NoPadding, ZeroPadding};
use md5::{Md5, Digest}; use md5::{Md5, Digest};
use cipher::{
BlockEncrypt, BlockDecryptMut, KeyInit, KeyIvInit,
block_padding::{NoPadding, ZeroPadding}
};
type BfCbc = Cbc<Blowfish, NoPadding>; type BfCbcDec = cbc::Decryptor<Blowfish>;
type Aes128Ecb = Ecb<Aes128, ZeroPadding>;
const TRACK_CDN_KEY: [u8; 16] = [106, 111, 54, 97, 101, 121, 54, 104, 97, 105, 100, 50, 84, 101, 105, 104]; const TRACK_CDN_KEY: [u8; 16] = [106, 111, 54, 97, 101, 121, 54, 104, 97, 105, 100, 50, 84, 101, 105, 104];
const BF_SECRET: [u8; 16] = [103, 52, 101, 108, 53, 56, 119, 99, 48, 122, 118, 102, 57, 110, 97, 49]; const BF_SECRET: [u8; 16] = [103, 52, 101, 108, 53, 56, 119, 99, 48, 122, 118, 102, 57, 110, 97, 49];
#[wasm_bindgen] #[wasm_bindgen]
pub struct Cipher { pub struct Cipher {
cipher: BfCbc cipher: BfCbcDec
} }
#[wasm_bindgen] #[wasm_bindgen]
@ -27,11 +28,11 @@ impl Cipher {
for i in 0..16 { for i in 0..16 {
key[i] = id_md5[i] ^ id_md5[i+16] ^ BF_SECRET[i] key[i] = id_md5[i] ^ id_md5[i+16] ^ BF_SECRET[i]
}; };
Self { cipher: BfCbc::new_from_slices(&key, &[0, 1, 2, 3, 4, 5, 6, 7]).unwrap() } Self { cipher: BfCbcDec::new_from_slices(&key, &[0, 1, 2, 3, 4, 5, 6, 7]).unwrap() }
} }
pub fn decrypt_chunk(&self, chunk: &mut [u8]) { pub fn decrypt_chunk(&self, chunk: &mut [u8]) {
self.cipher.clone().decrypt(chunk).unwrap(); self.cipher.clone().decrypt_padded_mut::<NoPadding>(chunk).unwrap();
} }
} }
@ -58,8 +59,8 @@ pub fn legacy_stream_url(md5_origin: &str, format: &str, id: &str, media_version
&[b'\xa4'], &[b'\xa4'],
].concat(); ].concat();
let cipher = Aes128Ecb::new_from_slices(&TRACK_CDN_KEY, Default::default()).unwrap(); let cipher = Aes128Enc::new_from_slice(&TRACK_CDN_KEY).unwrap();
let ciphertext = cipher.encrypt_vec(&metadata_hash); let ciphertext = cipher.encrypt_padded_vec::<ZeroPadding>(&metadata_hash);
format!("https://cdns-proxy-{}.dzcdn.net/mobile/1/{}", md5_origin.chars().next().unwrap(), hex::encode(ciphertext)) format!("https://cdns-proxy-{}.dzcdn.net/mobile/1/{}", md5_origin.chars().next().unwrap(), hex::encode(ciphertext))
} }

View File

@ -6,7 +6,7 @@ workers_dev = true
kv_namespaces = [ kv_namespaces = [
{ binding = "KV", id = "974c0967a84e415daa054bbbcc7f80c6", preview_id = "cfcc6491f3484cbca664913836635113" } { binding = "KV", id = "974c0967a84e415daa054bbbcc7f80c6", preview_id = "cfcc6491f3484cbca664913836635113" }
] ]
compatibility_date = "2022-01-23" compatibility_date = "2022-02-27"
# [secrets] # [secrets]
# ARL # ARL