mirror of
https://github.com/movie-web/native-app.git
synced 2025-09-13 08:03:26 +00:00
feat: additional api package stuff
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
"@movie-web/eslint-config": "workspace:^0.2.0",
|
||||
"@movie-web/prettier-config": "workspace:^0.1.0",
|
||||
"@movie-web/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"eslint": "^8.56.0",
|
||||
"prettier": "^3.1.1",
|
||||
"typescript": "^5.4.3"
|
||||
@@ -29,6 +30,9 @@
|
||||
},
|
||||
"prettier": "@movie-web/prettier-config",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@scure/bip39": "^1.3.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"ofetch": "^1.3.4"
|
||||
}
|
||||
}
|
||||
|
134
packages/api/src/crypto.ts
Normal file
134
packages/api/src/crypto.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { pbkdf2Async } from "@noble/hashes/pbkdf2";
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
import { generateMnemonic, validateMnemonic } from "@scure/bip39";
|
||||
import { wordlist } from "@scure/bip39/wordlists/english";
|
||||
import forge from "node-forge";
|
||||
|
||||
interface Keys {
|
||||
privateKey: Uint8Array;
|
||||
publicKey: Uint8Array;
|
||||
seed: Uint8Array;
|
||||
}
|
||||
|
||||
async function seedFromMnemonic(mnemonic: string) {
|
||||
return pbkdf2Async(sha256, mnemonic, "mnemonic", {
|
||||
c: 2048,
|
||||
dkLen: 32,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyValidMnemonic(mnemonic: string) {
|
||||
return validateMnemonic(mnemonic, wordlist);
|
||||
}
|
||||
|
||||
export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
|
||||
const seed = await seedFromMnemonic(mnemonic);
|
||||
|
||||
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
|
||||
seed,
|
||||
});
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
publicKey,
|
||||
seed,
|
||||
};
|
||||
}
|
||||
|
||||
export function genMnemonic(): string {
|
||||
return generateMnemonic(wordlist);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
export async function signCode(
|
||||
code: string,
|
||||
privateKey: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
return forge.pki.ed25519.sign({
|
||||
encoding: "utf8",
|
||||
message: code,
|
||||
privateKey,
|
||||
});
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array) {
|
||||
return forge.util.encode64(String.fromCodePoint(...bytes));
|
||||
}
|
||||
|
||||
export function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
return bytesToBase64(bytes)
|
||||
.replace(/\//g, "_")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export async function signChallenge(keys: Keys, challengeCode: string) {
|
||||
const signature = await signCode(challengeCode, keys.privateKey);
|
||||
return bytesToBase64Url(signature);
|
||||
}
|
||||
|
||||
export function base64ToBuffer(data: string) {
|
||||
return forge.util.binary.base64.decode(data);
|
||||
}
|
||||
|
||||
export function base64ToStringBuffer(data: string) {
|
||||
return forge.util.createBuffer(base64ToBuffer(data));
|
||||
}
|
||||
|
||||
export function stringBufferToBase64(buffer: forge.util.ByteStringBuffer) {
|
||||
return forge.util.encode64(buffer.getBytes());
|
||||
}
|
||||
|
||||
export async function encryptData(data: string, secret: Uint8Array) {
|
||||
if (secret.byteLength !== 32)
|
||||
throw new Error("Secret must be at least 256-bit");
|
||||
|
||||
const iv = await new Promise<string>((resolve, reject) => {
|
||||
forge.random.getBytes(16, (err, bytes) => {
|
||||
if (err) reject(err);
|
||||
resolve(bytes);
|
||||
});
|
||||
});
|
||||
|
||||
const cipher = forge.cipher.createCipher(
|
||||
"AES-GCM",
|
||||
forge.util.createBuffer(secret),
|
||||
);
|
||||
cipher.start({
|
||||
iv,
|
||||
tagLength: 128,
|
||||
});
|
||||
cipher.update(forge.util.createBuffer(data, "utf8"));
|
||||
cipher.finish();
|
||||
|
||||
const encryptedData = cipher.output;
|
||||
const tag = cipher.mode.tag;
|
||||
|
||||
return `${forge.util.encode64(iv)}.${stringBufferToBase64(
|
||||
encryptedData,
|
||||
)}.${stringBufferToBase64(tag)}` as const;
|
||||
}
|
||||
|
||||
export function decryptData(data: string, secret: Uint8Array) {
|
||||
if (secret.byteLength !== 32) throw new Error("Secret must be 256-bit");
|
||||
|
||||
const [iv, encryptedData, tag] = data.split(".");
|
||||
if (!iv || !encryptedData || !tag)
|
||||
throw new Error("Invalid encrypted data format");
|
||||
|
||||
const decipher = forge.cipher.createDecipher(
|
||||
"AES-GCM",
|
||||
forge.util.createBuffer(secret),
|
||||
);
|
||||
decipher.start({
|
||||
iv: base64ToStringBuffer(iv),
|
||||
tag: base64ToStringBuffer(tag),
|
||||
tagLength: 128,
|
||||
});
|
||||
decipher.update(base64ToStringBuffer(encryptedData));
|
||||
const pass = decipher.finish();
|
||||
|
||||
if (!pass) throw new Error("Error decrypting data");
|
||||
|
||||
return decipher.output.toString();
|
||||
}
|
@@ -1,2 +1,4 @@
|
||||
export const name = "api";
|
||||
export * from "./auth";
|
||||
export * from "./crypto";
|
||||
|
||||
|
48
packages/api/src/login.ts
Normal file
48
packages/api/src/login.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import type { SessionResponse } from "./auth";
|
||||
|
||||
export interface ChallengeTokenResponse {
|
||||
challenge: string;
|
||||
}
|
||||
|
||||
export async function getLoginChallengeToken(
|
||||
url: string,
|
||||
publicKey: string,
|
||||
): Promise<ChallengeTokenResponse> {
|
||||
return ofetch<ChallengeTokenResponse>("/auth/login/start", {
|
||||
method: "POST",
|
||||
body: {
|
||||
publicKey,
|
||||
},
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
session: SessionResponse;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface LoginInput {
|
||||
publicKey: string;
|
||||
challenge: {
|
||||
code: string;
|
||||
signature: string;
|
||||
};
|
||||
device: string;
|
||||
}
|
||||
|
||||
export async function loginAccount(
|
||||
url: string,
|
||||
data: LoginInput,
|
||||
): Promise<LoginResponse> {
|
||||
return ofetch<LoginResponse>("/auth/login/complete", {
|
||||
method: "POST",
|
||||
body: {
|
||||
namespace: "movie-web",
|
||||
...data,
|
||||
},
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
15
packages/api/src/meta.ts
Normal file
15
packages/api/src/meta.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
export interface MetaResponse {
|
||||
version: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
hasCaptcha: boolean;
|
||||
captchaClientKey?: string;
|
||||
}
|
||||
|
||||
export async function getBackendMeta(url: string): Promise<MetaResponse> {
|
||||
return ofetch<MetaResponse>("/meta", {
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
64
packages/api/src/sessions.ts
Normal file
64
packages/api/src/sessions.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import { getAuthHeaders } from "./auth";
|
||||
|
||||
export interface SessionResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
accessedAt: string;
|
||||
device: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
export interface SessionUpdate {
|
||||
deviceName: string;
|
||||
}
|
||||
|
||||
interface Account {
|
||||
profile: {
|
||||
colorA: string;
|
||||
colorB: string;
|
||||
icon: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type AccountWithToken = Account & {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
token: string;
|
||||
seed: string;
|
||||
deviceName: string;
|
||||
};
|
||||
|
||||
export async function getSessions(url: string, account: AccountWithToken) {
|
||||
return ofetch<SessionResponse[]>(`/users/${account.userId}/sessions`, {
|
||||
headers: getAuthHeaders(account.token),
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSession(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
update: SessionUpdate,
|
||||
) {
|
||||
return ofetch<SessionResponse[]>(`/sessions/${account.sessionId}`, {
|
||||
method: "PATCH",
|
||||
headers: getAuthHeaders(account.token),
|
||||
body: update,
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeSession(
|
||||
url: string,
|
||||
token: string,
|
||||
sessionId: string,
|
||||
) {
|
||||
return ofetch<SessionResponse[]>(`/sessions/${sessionId}`, {
|
||||
method: "DELETE",
|
||||
headers: getAuthHeaders(token),
|
||||
baseURL: url,
|
||||
});
|
||||
}
|
39
packages/api/src/settings.ts
Normal file
39
packages/api/src/settings.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ofetch } from "ofetch";
|
||||
|
||||
import type { AccountWithToken } from "./sessions";
|
||||
import { getAuthHeaders } from "./auth";
|
||||
|
||||
export interface SettingsInput {
|
||||
applicationLanguage?: string;
|
||||
applicationTheme?: string | null;
|
||||
defaultSubtitleLanguage?: string;
|
||||
proxyUrls?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
applicationTheme?: string | null;
|
||||
applicationLanguage?: string | null;
|
||||
defaultSubtitleLanguage?: string | null;
|
||||
proxyUrls?: string[] | null;
|
||||
}
|
||||
|
||||
export function updateSettings(
|
||||
url: string,
|
||||
account: AccountWithToken,
|
||||
settings: SettingsInput,
|
||||
) {
|
||||
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
|
||||
method: "PUT",
|
||||
body: settings,
|
||||
baseURL: url,
|
||||
headers: getAuthHeaders(account.token),
|
||||
});
|
||||
}
|
||||
|
||||
export function getSettings(url: string, account: AccountWithToken) {
|
||||
return ofetch<SettingsResponse>(`/users/${account.userId}/settings`, {
|
||||
method: "GET",
|
||||
baseURL: url,
|
||||
headers: getAuthHeaders(account.token),
|
||||
});
|
||||
}
|
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
@@ -243,6 +243,15 @@ importers:
|
||||
|
||||
packages/api:
|
||||
dependencies:
|
||||
'@noble/hashes':
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
'@scure/bip39':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
node-forge:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
ofetch:
|
||||
specifier: ^1.3.4
|
||||
version: 1.3.4
|
||||
@@ -256,6 +265,9 @@ importers:
|
||||
'@movie-web/tsconfig':
|
||||
specifier: workspace:^0.1.0
|
||||
version: link:../../tooling/typescript
|
||||
'@types/node-forge':
|
||||
specifier: ^1.3.11
|
||||
version: 1.3.11
|
||||
eslint:
|
||||
specifier: ^8.56.0
|
||||
version: 8.56.0
|
||||
@@ -2905,6 +2917,11 @@ packages:
|
||||
unpacker: 1.0.1
|
||||
dev: false
|
||||
|
||||
/@noble/hashes@1.4.0:
|
||||
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
|
||||
engines: {node: '>= 16'}
|
||||
dev: false
|
||||
|
||||
/@nodelib/fs.scandir@2.1.5:
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -3592,6 +3609,17 @@ packages:
|
||||
react-native-video: 5.2.1
|
||||
dev: false
|
||||
|
||||
/@scure/base@1.1.6:
|
||||
resolution: {integrity: sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==}
|
||||
dev: false
|
||||
|
||||
/@scure/bip39@1.3.0:
|
||||
resolution: {integrity: sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==}
|
||||
dependencies:
|
||||
'@noble/hashes': 1.4.0
|
||||
'@scure/base': 1.1.6
|
||||
dev: false
|
||||
|
||||
/@segment/loosely-validate-event@2.0.0:
|
||||
resolution: {integrity: sha512-ZMCSfztDBqwotkl848ODgVcAmN4OItEWDCkshcKz0/W6gGSQayuuCtWV/MlodFivAZD793d6UgANd6wCXUfrIw==}
|
||||
dependencies:
|
||||
@@ -5260,6 +5288,12 @@ packages:
|
||||
resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==}
|
||||
dev: true
|
||||
|
||||
/@types/node-forge@1.3.11:
|
||||
resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==}
|
||||
dependencies:
|
||||
'@types/node': 20.12.7
|
||||
dev: true
|
||||
|
||||
/@types/node@17.0.45:
|
||||
resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==}
|
||||
dev: false
|
||||
|
Reference in New Issue
Block a user