feat: import ekey from Android db (#20)

This commit is contained in:
鲁树人 2023-06-11 16:21:10 +01:00
parent ec27a6f699
commit b78399eddb
16 changed files with 1080 additions and 108 deletions

View File

@ -31,8 +31,12 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-icons": "^4.9.0", "react-icons": "^4.9.0",
"react-markdown": "^8.0.7",
"react-promise-suspense": "^0.3.4", "react-promise-suspense": "^0.3.4",
"react-redux": "^8.0.5" "react-redux": "^8.0.5",
"remark-gfm": "^3.0.1",
"sass": "^1.63.3",
"sql.js": "^1.8.0"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-replace": "^5.0.2", "@rollup/plugin-replace": "^5.0.2",
@ -42,6 +46,7 @@
"@types/node": "^20.2.5", "@types/node": "^20.2.5",
"@types/react": "^18.2.7", "@types/react": "^18.2.7",
"@types/react-dom": "^18.2.4", "@types/react-dom": "^18.2.4",
"@types/sql.js": "^1.4.4",
"@types/testing-library__jest-dom": "^5.14.6", "@types/testing-library__jest-dom": "^5.14.6",
"@typescript-eslint/eslint-plugin": "^5.59.7", "@typescript-eslint/eslint-plugin": "^5.59.7",
"@typescript-eslint/parser": "^5.59.7", "@typescript-eslint/parser": "^5.59.7",
@ -71,5 +76,10 @@
"singleQuote": true, "singleQuote": true,
"printWidth": 120, "printWidth": 120,
"tabWidth": 2 "tabWidth": 2
},
"pnpm": {
"patchedDependencies": {
"sql.js@1.8.0": "patches/sql.js@1.8.0.patch"
}
} }
} }

View File

@ -0,0 +1,12 @@
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
index e0da60ba096433d9af1c7025d2ffb9c521f190ed..89a5da6af23e1a644106d38dafe7cfa85500a8c4 100644
--- a/dist/sql-wasm.js
+++ b/dist/sql-wasm.js
@@ -192,3 +192,7 @@ else if (typeof define === 'function' && define['amd']) {
else if (typeof exports === 'object'){
exports["Module"] = initSqlJs;
}
+
+var module;
+export default initSqlJs;
+

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
import { useDropzone } from 'react-dropzone';
import { Box } from '@chakra-ui/react';
export interface FileInputProps {
onReceiveFiles: (files: File[]) => void;
multiple?: boolean;
children: React.ReactNode;
}
export function FileInput({ children, onReceiveFiles }: FileInputProps) {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
multiple: true,
onDropAccepted: onReceiveFiles,
});
return (
<Box
{...getRootProps()}
w="100%"
maxW={480}
borderWidth="1px"
borderRadius="lg"
transitionDuration="0.5s"
p="6"
cursor="pointer"
display="flex"
flexDir="column"
alignItems="center"
_hover={{
borderColor: 'gray.400',
bg: 'gray.50',
}}
{...(isDragActive && {
bg: 'blue.50',
borderColor: 'blue.700',
})}
>
<input {...getInputProps()} />
{children}
</Box>
);
}

View File

@ -0,0 +1,26 @@
.markdown {
h3 {
font-weight: bold;
font-size: 1.25em;
}
h4 {
font-weight: bold;
font-size: 1.15em;
margin-top: 0.5em;
}
p,
pre {
margin-block-start: 1em;
margin-block-end: 1em;
margin-inline-start: 0px;
margin-inline-end: 0px;
}
pre {
white-space: pre-wrap;
padding-left: 0.5em;
border-left: 2px solid #ddd;
}
}

View File

@ -0,0 +1,11 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import MarkdownContentClass from './MarkdownContent.module.scss';
export function MarkdownContent({ children }: { children: string }) {
return (
<div className={MarkdownContentClass.markdown}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
</div>
);
}

View File

@ -1,16 +1,14 @@
import { useDropzone } from 'react-dropzone'; import { Box, Text } from '@chakra-ui/react';
import { chakra, Box, Text } from '@chakra-ui/react';
import { UnlockIcon } from '@chakra-ui/icons'; import { UnlockIcon } from '@chakra-ui/icons';
import { useAppDispatch } from '~/hooks'; import { useAppDispatch } from '~/hooks';
import { addNewFile, processFile } from '~/features/file-listing/fileListingSlice'; import { addNewFile, processFile } from '~/features/file-listing/fileListingSlice';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { FileInput } from './FileInput';
export function SelectFile() { export function SelectFile() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const handleFileReceived = (files: File[]) => {
multiple: true,
onDropAccepted(files, _event) {
console.debug( console.debug(
'react-dropzone/onDropAccepted(%o, %o)', 'react-dropzone/onDropAccepted(%o, %o)',
files.length, files.length,
@ -32,46 +30,23 @@ export function SelectFile() {
); );
dispatch(processFile({ fileId })); dispatch(processFile({ fileId }));
} }
}, };
});
return ( return (
<Box <FileInput multiple onReceiveFiles={handleFileReceived}>
{...getRootProps()}
w="100%"
maxW={480}
borderWidth="1px"
borderRadius="lg"
transitionDuration="0.5s"
p="6"
cursor="pointer"
display="flex"
flexDir="column"
alignItems="center"
_hover={{
borderColor: 'gray.400',
bg: 'gray.50',
}}
{...(isDragActive && {
bg: 'blue.50',
borderColor: 'blue.700',
})}
>
<input {...getInputProps()} />
<Box pb={3}> <Box pb={3}>
<UnlockIcon boxSize={8} /> <UnlockIcon boxSize={8} />
</Box> </Box>
<Text textAlign="center"> <Text as="div" textAlign="center">
<chakra.span as="span" color="teal.400"> <Text as="span" color="teal.400">
</chakra.span> </Text>
<Text fontSize="sm" opacity="50%"> <Text fontSize="sm" opacity="50%">
</Text> </Text>
</Text> </Text>
</Box> </FileInput>
); );
} }

View File

@ -24,12 +24,14 @@ import {
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { qmc2AddKey, qmc2ClearKeys, qmc2DeleteKey, qmc2UpdateKey } from '../settingsSlice'; import { qmc2AddKey, qmc2ClearKeys, qmc2DeleteKey, qmc2UpdateKey } from '../settingsSlice';
import { selectStagingQMCv2Settings } from '../settingsSelector'; import { selectStagingQMCv2Settings } from '../settingsSelector';
import React from 'react'; import React, { useState } from 'react';
import { MdAdd, MdDelete, MdDeleteForever, MdExpandMore, MdFileUpload, MdVpnKey } from 'react-icons/md'; import { MdAdd, MdDelete, MdDeleteForever, MdExpandMore, MdFileUpload, MdVpnKey } from 'react-icons/md';
import { ImportFileModal } from './QMCv2/ImportFileModal';
export function PanelQMCv2Key() { export function PanelQMCv2Key() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const qmc2Keys = useSelector(selectStagingQMCv2Settings).keys; const qmc2Keys = useSelector(selectStagingQMCv2Settings).keys;
const [showImportModal, setShowImportModal] = useState(false);
const addKey = () => dispatch(qmc2AddKey()); const addKey = () => dispatch(qmc2AddKey());
const updateKey = (prop: 'name' | 'key', id: string, e: React.ChangeEvent<HTMLInputElement>) => const updateKey = (prop: 'name' | 'key', id: string, e: React.ChangeEvent<HTMLInputElement>) =>
@ -53,12 +55,10 @@ export function PanelQMCv2Key() {
<Menu> <Menu>
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton> <MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton>
<MenuList> <MenuList>
{/* 目前的想法是弹出一个 modal给用户一些信息如期待的格式、如何导出或寻找对应的文件 */} <MenuItem onClick={() => setShowImportModal(true)} icon={<Icon as={MdFileUpload} boxSize={5} />}>
{/* 但是这样的话就不太方便放在这个分支里面做了,下次一定。 */}
<MenuItem hidden onClick={() => alert('TODO!')} icon={<Icon as={MdFileUpload} boxSize={5} />}>
</MenuItem> </MenuItem>
<MenuDivider hidden /> <MenuDivider />
<MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}> <MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
</MenuItem> </MenuItem>
@ -111,6 +111,8 @@ export function PanelQMCv2Key() {
</List> </List>
{qmc2Keys.length === 0 && <Text></Text>} {qmc2Keys.length === 0 && <Text></Text>}
</Box> </Box>
<ImportFileModal show={showImportModal} onClose={() => setShowImportModal(false)} />
</Flex> </Flex>
); );
} }

View File

@ -0,0 +1,23 @@
### 安卓端获取密钥数据库
你可能需要 root 或类似的访问权限。绝大多数情况下,这会导致你的安卓设备失去保修资格。
#### 提取文件(文件浏览器)
1. 提升到 `root` 权限,访问 `/data/data/com.tencent.qqmusic/databases/` 目录,将文件 `player_process_db`
复制到正常模式下用户可访问的目录(如下载目录)。
2. 如果你需要在电脑上进行解密操作,请将该文件复制到电脑。
3. 选择该文件。
#### 提取文件ADB
※ 目前该指令只支持 Linux & Mac
1. 打开终端并安装好依赖,并复制下述指令:
```sh
adb shell su -c "cat '/data/data/com.tencent.qqmusic/databases/player_process_db' | gzip | base64" \
| base64 -d | gzip -d player_process_db
```
2. 选择提取的这个文件即可。

View File

@ -0,0 +1,95 @@
import {
Button,
Center,
Flex,
Heading,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
} from '@chakra-ui/react';
import { FileInput } from '~/components/FileInput';
import mdHelpAndroid from './DocAndroid.md?raw';
import { MarkdownContent } from '~/components/MarkdownContent';
import { qmc2ImportKeys } from '../../settingsSlice';
import { useAppDispatch } from '~/hooks';
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
export interface ImportFileModalProps {
show: boolean;
onClose: () => void;
}
export function ImportFileModal({ onClose, show }: ImportFileModalProps) {
const dispatch = useAppDispatch();
const handleFileReceived = async (files: File[]) => {
try {
const file = files[0];
const fileBuffer = await file.arrayBuffer();
if (/[_.]db$/i.test(file.name)) {
const extractor = await DatabaseKeyExtractor.getInstance();
const qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer);
if (qmc2Keys) {
dispatch(qmc2ImportKeys(qmc2Keys));
onClose();
return;
}
alert(`不是支持的 SQLite 数据库文件。\n表名${qmc2Keys}`);
} else {
alert(`不支持的文件:${file.name}`);
}
} catch (e) {
console.error('error during import: ', e);
alert(`导入数据库时发生错误:${e}`);
}
};
return (
<Modal isOpen={show} onClose={onClose} scrollBehavior="inside" size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader></ModalHeader>
<ModalCloseButton />
<Flex as={ModalBody} gap={2} flexDir="column">
<Center>
<FileInput onReceiveFiles={handleFileReceived}></FileInput>
</Center>
<Heading as="h2" size="md" mt="4">
使
</Heading>
<Tabs variant="enclosed">
<TabList>
<Tab></Tab>
{/* <Tab>Two</Tab> */}
</TabList>
<TabPanels>
<TabPanel>
<MarkdownContent>{mdHelpAndroid}</MarkdownContent>
</TabPanel>
<TabPanel>
<p>two!</p>
</TabPanel>
</TabPanels>
</Tabs>
</Flex>
<ModalFooter>
<Button mr={3} onClick={onClose}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

View File

@ -6,9 +6,10 @@ export const selectStagingQMCv2Settings = (state: RootState) => state.settings.s
export const selectFinalQMCv2Settings = (state: RootState) => state.settings.production.qmc2; export const selectFinalQMCv2Settings = (state: RootState) => state.settings.production.qmc2;
export const selectDecryptOptionByFile = (state: RootState, name: string): DecryptCommandOptions => { export const selectDecryptOptionByFile = (state: RootState, name: string): DecryptCommandOptions => {
const normalizedName = name.normalize();
const qmc2Keys = selectFinalQMCv2Settings(state).keys; const qmc2Keys = selectFinalQMCv2Settings(state).keys;
return { return {
qmc2Key: hasOwn(qmc2Keys, name) ? qmc2Keys[name] : undefined, qmc2Key: hasOwn(qmc2Keys, normalizedName) ? qmc2Keys[normalizedName] : undefined,
}; };
}; };

View File

@ -36,7 +36,7 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
qmc2: { qmc2: {
keys: objectify( keys: objectify(
staging.qmc2.keys, staging.qmc2.keys,
(item) => item.name, (item) => item.name.normalize(),
(item) => item.key (item) => item.key
), ),
}, },
@ -61,6 +61,10 @@ export const settingsSlice = createSlice({
qmc2AddKey(state) { qmc2AddKey(state) {
state.staging.qmc2.keys.push({ id: nanoid(), name: '', key: '' }); state.staging.qmc2.keys.push({ id: nanoid(), name: '', key: '' });
}, },
qmc2ImportKeys(state, { payload }: PayloadAction<{ name: string; key: string }[]>) {
const newItems = payload.map((item) => ({ id: nanoid(), ...item }));
state.staging.qmc2.keys.push(...newItems);
},
qmc2DeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) { qmc2DeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) {
const qmc2 = state.staging.qmc2; const qmc2 = state.staging.qmc2;
qmc2.keys = qmc2.keys.filter((item) => item.id !== id); qmc2.keys = qmc2.keys.filter((item) => item.id !== id);
@ -102,6 +106,7 @@ export const {
qmc2UpdateKey, qmc2UpdateKey,
qmc2DeleteKey, qmc2DeleteKey,
qmc2ClearKeys, qmc2ClearKeys,
qmc2ImportKeys,
commitStagingChange, commitStagingChange,
discardStagingChanges, discardStagingChanges,

View File

@ -0,0 +1,44 @@
import { SQLDatabase, SQLStatic, loadSQL } from './sqlite';
export interface QMAndroidKeyEntry {
name: string;
key: string;
}
export class DatabaseKeyExtractor {
private static _instance: DatabaseKeyExtractor;
static async getInstance() {
if (!DatabaseKeyExtractor._instance) {
DatabaseKeyExtractor._instance = new DatabaseKeyExtractor(await loadSQL());
}
return DatabaseKeyExtractor._instance;
}
constructor(private SQL: SQLStatic) {}
private hasTable(db: SQLDatabase, name: string): boolean {
const tables = db.exec('SELECT name FROM sqlite_master WHERE type="table"')[0].values.map((x) => x[0]);
return tables.includes(name);
}
extractQmAndroidDbKeys(buffer: ArrayBuffer): null | QMAndroidKeyEntry[] {
let db: SQLDatabase | null = null;
try {
db = new this.SQL.Database(new Uint8Array(buffer));
if (!this.hasTable(db, 'audio_file_ekey_table')) {
return null;
}
const keys = db.exec('select file_path, ekey from `audio_file_ekey_table`')[0].values;
return keys.map(([path, key]) => ({
// strip dir name
name: String(path).replace(/.+\//, ''),
key: String(key),
}));
} finally {
db?.close();
}
}
}

15
src/util/sqlite.ts Normal file
View File

@ -0,0 +1,15 @@
import * as initSqlite from 'sql.js';
const urlWasm = new URL('@nm/sql.js/dist/sql-wasm.wasm', import.meta.url).toString();
export type SQLStatic = Awaited<ReturnType<(typeof initSqlite)['default']>>;
export type SQLDatabase = SQLStatic['Database']['prototype'];
let sqlLoaderPromise: Promise<SQLStatic> | void;
export async function loadSQL() {
if (!sqlLoaderPromise) {
sqlLoaderPromise = initSqlite.default({ locateFile: () => urlWasm });
}
return await sqlLoaderPromise;
}

View File

@ -1,15 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ESNext", "target": "ESNext",
"lib": [ "lib": ["DOM", "DOM.Iterable", "ESNext"],
"DOM", "types": ["vitest/globals", "@testing-library/jest-dom"],
"DOM.Iterable",
"ESNext"
],
"types": [
"vitest/globals",
"@testing-library/jest-dom"
],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
@ -27,14 +20,11 @@
"baseUrl": ".", "baseUrl": ".",
"esModuleInterop": true, "esModuleInterop": true,
"paths": { "paths": {
"~/*": [ "~/*": ["./src/*"],
"./src/*" "@nm/*": ["./node_modules/*"]
] }
}, },
}, "include": ["src"],
"include": [
"src"
],
"references": [ "references": [
{ {
"path": "./tsconfig.node.json" "path": "./tsconfig.node.json"

View File

@ -43,7 +43,7 @@ export default defineConfig({
}, },
base: './', base: './',
optimizeDeps: { optimizeDeps: {
exclude: ['@jixun/libparakeet'], exclude: ['@jixun/libparakeet', 'sql.js'],
}, },
plugins: [ plugins: [
replace({ replace({
@ -88,6 +88,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'~': path.resolve(__dirname, 'src'), '~': path.resolve(__dirname, 'src'),
'@nm': path.resolve(__dirname, 'node_modules'),
module: path.resolve(__dirname, 'src', 'dummy.mjs'), module: path.resolve(__dirname, 'src', 'dummy.mjs'),
}, },
}, },