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"]
[dependencies]
block-modes = "0.8"
blowfish = "0.8"
aes = "0.7"
blowfish = "0.9"
aes = "0.8"
md-5 = "0.10"
cipher = { version = "0.4", features = ["block-padding"] }
cbc = "0.1"
hex = "0.4"
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
}
let license_token
let checkForm
let sid
let access_token
router.get('/:type/:id', async request => {
const { query } = request
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' } })
}
// 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
if (format === undefined) {
format = '320'
@ -138,22 +98,71 @@ router.get('/:type/:id', async request => {
switch (type) {
case 'track':
return await track(id, format, tagging)
return await track(id, format, tagging, request.headers.get('range'))
case 'album':
case 'playlist':
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
let json;
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 })
if (json.constructor.name === 'Response') {
return json
}
} 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}`)
json = await response.json()
if (json.error !== undefined) {
@ -176,10 +185,21 @@ async function track(id, format, tagging) {
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' } })
}
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')
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)
}
const track = await fetch(track_url)
if (track.status !== 200) {
return new Response("Couldn't get track stream", { status: 403, headers: { 'content-type': 'text/plain' } })
}
filesize = parseInt(filesize)
let title = json.SNG_TITLE
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
if (tagging) {
id3 = new ID3Writer(Buffer.alloc(0));
@ -287,9 +353,11 @@ async function track(id, format, tagging) {
}
id3.addTag();
resp_size += id3.arrayBuffer.byteLength
}
let { readable, writable } = new TransformStream()
let { readable, writable } = new FixedLengthStream(resp_size)
const writer = writable.getWriter()
if (tagging) {
@ -303,43 +371,46 @@ async function track(id, format, tagging) {
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)
const headers = {
'content-type': formats[format].mime,
'content-disposition': `inline; filename="${title.replaceAll('"', '\\"')} [${id}].${formats[format].ext}"`
return new Response(readable, resp_init)
}
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' })
let byteCount = 0
let byte_count = 0
if (fixed_range_start) {
byte_count = fixed_range_start
}
let end = false
while (!end) {
end = byteCount + 2048 > length
let minBytes
end = byte_count + 2048 >= length
let min_bytes
if (!end) {
minBytes = 2048
min_bytes = 2048
} 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
cipher.decrypt_chunk(chunk)
}
writer.write(chunk)
byteCount += 2048
if (bytes_remove_start !== 0) {
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()
}
@ -385,17 +456,6 @@ router.get('/', () => {
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 =>
event.respondWith(router.handle(event.request))
)

30
package-lock.json generated
View File

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

View File

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

View File

@ -1,20 +1,21 @@
use wasm_bindgen::prelude::*;
use aes::Aes128Enc;
use blowfish::Blowfish;
use aes::Aes128;
use block_modes::{BlockMode, Cbc, Ecb};
use block_modes::block_padding::{NoPadding, ZeroPadding};
use md5::{Md5, Digest};
use cipher::{
BlockEncrypt, BlockDecryptMut, KeyInit, KeyIvInit,
block_padding::{NoPadding, ZeroPadding}
};
type BfCbc = Cbc<Blowfish, NoPadding>;
type Aes128Ecb = Ecb<Aes128, ZeroPadding>;
type BfCbcDec = cbc::Decryptor<Blowfish>;
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];
#[wasm_bindgen]
pub struct Cipher {
cipher: BfCbc
cipher: BfCbcDec
}
#[wasm_bindgen]
@ -27,11 +28,11 @@ impl Cipher {
for i in 0..16 {
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]) {
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'],
].concat();
let cipher = Aes128Ecb::new_from_slices(&TRACK_CDN_KEY, Default::default()).unwrap();
let ciphertext = cipher.encrypt_vec(&metadata_hash);
let cipher = Aes128Enc::new_from_slice(&TRACK_CDN_KEY).unwrap();
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))
}

View File

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