Better code seperation for backend

This commit is contained in:
WardPearce
2026-02-23 19:37:47 +13:00
parent f97d7d6453
commit 11b6f0c19e
11 changed files with 359 additions and 352 deletions
-346
View File
@@ -1,346 +0,0 @@
import sodium from 'libsodium-wrappers-sumo';
import { rawMasterKeyStore } from '../store';
import { get } from 'svelte/store';
import { parseChannelRSS } from './youtubejs/subscriptions';
import { getChannelYTjs } from './youtubejs/channel';
import type { ChannelSubscriptions } from '$lib/dexie';
import type { VideoPlay } from './model';
import { vi } from 'zod/v4/locales';
import { getBestThumbnail } from '$lib/images';
async function getSecureHash(toHash: string, rawKey: Uint8Array): Promise<string> {
await sodium.ready;
return sodium.to_base64(
sodium.crypto_generichash(sodium.crypto_generichash_BYTES, toHash, rawKey)
);
}
async function getRawKey(): Promise<Uint8Array | undefined> {
const rawMasterKey = get(rawMasterKeyStore);
if (!rawMasterKey) return;
await sodium.ready;
return sodium.from_base64(rawMasterKey);
}
export async function getSubscriptionsBackend(): Promise<ChannelSubscriptions[]> {
const resp = await fetch(`/api/user/subscriptions`, {
method: 'GET',
credentials: 'same-origin'
});
if (!resp.ok) return [];
const subscriptions: ChannelSubscriptions[] = [];
const respJson = await resp.json();
for (const sub of respJson.subscriptions) {
subscriptions.push({
channelName: (await decryptWithMasterKey(sub.channelNameNonce, sub.channelNameCipher)) ?? '',
channelId: (await decryptWithMasterKey(sub.channelIdNonce, sub.channelIdCipher)) ?? '',
lastRSSFetch: new Date(sub.lastRSSFetch)
});
}
return subscriptions;
}
export async function updateRSSLastUpdated(authorId: string) {
const rawKey = await getRawKey();
if (!rawKey) return false;
const internalAuthorId = await getSecureHash(authorId, rawKey);
await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
method: 'PATCH',
credentials: 'same-origin'
});
}
export async function amSubscribedBackend(authorId: string): Promise<boolean> {
const rawKey = await getRawKey();
if (!rawKey) return false;
const internalAuthorId = await getSecureHash(authorId, rawKey);
const resp = await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
method: 'GET',
credentials: 'same-origin'
});
if (!resp.ok) return false;
const respJson = await resp.json();
return respJson.amSubscribed;
}
export async function deleteUnsubscribeBackend(authorId: string) {
const rawKey = await getRawKey();
if (!rawKey) return false;
const internalAuthorId = await getSecureHash(authorId, rawKey);
await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
method: 'DELETE',
credentials: 'same-origin'
});
}
export async function postSubscribeBackend(
authorId: string,
authorName: string | undefined = undefined
) {
const rawKey = await getRawKey();
if (!rawKey) return;
const internalAuthorId = await getSecureHash(authorId, rawKey);
if (!authorName) {
const channel = await getChannelYTjs(authorId);
authorName = channel.author;
}
const channelId = await encryptWithMasterKey(authorId);
const channelName = await encryptWithMasterKey(authorName);
const resp = await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
method: 'POST',
body: JSON.stringify({
channelIdCipher: channelId?.cipher,
channelIdNonce: channelId?.nonce,
channelNameCipher: channelName?.cipher,
channelNameNonce: channelName?.nonce
}),
credentials: 'same-origin'
});
if (resp.ok) parseChannelRSS(authorId);
}
export type DerivePassword = (rawPassword: string, passwordSalt: Uint8Array) => Promise<Uint8Array>;
export async function createUserBackend(
username: string,
rawPassword: string,
captchaPayload: string,
derivePassword: DerivePassword
): Promise<boolean> {
await sodium.ready;
const passwordSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const loginHash = await derivePassword(rawPassword, passwordSalt);
const decryptionKeySalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const rawDecryptionKey = sodium.crypto_pwhash(
32,
rawPassword,
decryptionKeySalt,
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
sodium.crypto_pwhash_ALG_DEFAULT
);
const rawDecryptionMasterKey = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
const decryptionMasterKeyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const masterKeyCipher = sodium.crypto_secretbox_easy(
rawDecryptionMasterKey,
decryptionMasterKeyNonce,
rawDecryptionKey
);
const userCreateResp = await fetch('/api/user/create', {
method: 'POST',
body: JSON.stringify({
username: username,
password: {
hash: sodium.to_base64(loginHash),
salt: sodium.to_base64(passwordSalt)
},
decryptionKeySalt: sodium.to_base64(decryptionKeySalt),
masterKey: {
cipher: sodium.to_base64(masterKeyCipher),
nonce: sodium.to_base64(decryptionMasterKeyNonce)
},
captchaPayload
}),
credentials: 'same-origin'
});
if (!userCreateResp.ok) return false;
rawMasterKeyStore.set(sodium.to_base64(rawDecryptionMasterKey));
return true;
}
export async function loginUserBackend(
username: string,
rawPassword: string,
captchaPayload: string,
derivePassword: DerivePassword
): Promise<boolean> {
await sodium.ready;
const passwordSaltsResp = await fetch(`/api/user/${username}/public`);
if (!passwordSaltsResp.ok) return false;
const passwordSalts = await passwordSaltsResp.json();
const loginHash = await derivePassword(
rawPassword,
sodium.from_base64(passwordSalts.passwordSalt)
);
const loginResp = await fetch('/api/user/login', {
method: 'POST',
body: JSON.stringify({
username,
passwordHash: sodium.to_base64(loginHash),
captchaPayload
}),
credentials: 'same-origin'
});
if (!loginResp.ok) return false;
const loginJson = await loginResp.json();
const rawDecryptionKey = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES,
rawPassword,
sodium.from_base64(passwordSalts.decryptionKeySalt),
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
sodium.crypto_pwhash_ALG_DEFAULT
);
const rawDecryptionMasterKey = sodium.crypto_secretbox_open_easy(
sodium.from_base64(loginJson.masterKeyCipher),
sodium.from_base64(loginJson.masterKeyNonce),
rawDecryptionKey
);
rawMasterKeyStore.set(sodium.to_base64(rawDecryptionMasterKey));
return true;
}
export async function addOrUpdateKeyValue(key: string, value: string) {
const valueEncrypted = await encryptWithMasterKey(value);
await fetch(`/api/user/keyValue/${key}`, {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
valueCipher: valueEncrypted?.cipher,
valueNonce: valueEncrypted?.nonce
})
});
}
export async function deleteKeyValue(key: string) {
await fetch(`/api/user/keyValue/${key}`, {
method: 'DELETE',
credentials: 'same-origin'
});
}
export type KeyValue = boolean | number | string[] | string | object | undefined;
export async function getKeyValue(key: string): Promise<KeyValue | null> {
const resp = await fetch(`/api/user/keyValue/${key}`, {
method: 'GET',
credentials: 'same-origin'
});
if (!resp.ok) return null;
const respJson = await resp.json();
return (await decryptWithMasterKey(respJson.valueNonce, respJson.valueCipher)) ?? null;
}
export async function updateWatchHistory(videoId: string, progress: number) {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
const videoHash = await getSecureHash(videoId, rawKey);
await fetch(`/api/user/history/${videoHash}`, {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
watched: new Date(),
progress
})
});
}
export async function saveWatchHistory(video: VideoPlay, progress: number = 0) {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
const videoHash = await getSecureHash(video.videoId, rawKey);
const title = await encryptWithMasterKey(video.title);
const author = await encryptWithMasterKey(video.author);
const thumbnail = await encryptWithMasterKey(getBestThumbnail(video.videoThumbnails));
const duration = await encryptWithMasterKey(video.lengthSeconds.toString());
await fetch('/api/user/history', {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
id: videoHash,
watched: new Date(),
progress: progress,
title: {
cipher: title?.cipher,
nonce: title?.nonce
},
author: {
cipher: author?.cipher,
nonce: author?.nonce
},
thumbnail: {
cipher: thumbnail?.cipher,
nonce: thumbnail?.nonce
},
duration: {
cipher: duration?.cipher,
nonce: duration?.nonce
}
})
});
}
async function encryptWithMasterKey(
text: string
): Promise<{ nonce: string; cipher: string } | undefined> {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const cipher = sodium.crypto_secretbox_easy(new TextEncoder().encode(text), nonce, rawKey);
return {
nonce: sodium.to_base64(nonce),
cipher: sodium.to_base64(cipher)
};
}
async function decryptWithMasterKey(nonce: string, cipher: string): Promise<string | undefined> {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
return new TextDecoder().decode(
sodium.crypto_secretbox_open_easy(sodium.from_base64(cipher), sodium.from_base64(nonce), rawKey)
);
}
@@ -0,0 +1,48 @@
import { rawMasterKeyStore } from '$lib/store';
import sodium from 'libsodium-wrappers-sumo';
import { get } from 'svelte/store';
export async function getSecureHash(toHash: string, rawKey: Uint8Array): Promise<string> {
await sodium.ready;
return sodium.to_base64(
sodium.crypto_generichash(sodium.crypto_generichash_BYTES, toHash, rawKey)
);
}
export async function getRawKey(): Promise<Uint8Array | undefined> {
const rawMasterKey = get(rawMasterKeyStore);
if (!rawMasterKey) return;
await sodium.ready;
return sodium.from_base64(rawMasterKey);
}
export async function encryptWithMasterKey(
text: string
): Promise<{ nonce: string; cipher: string } | undefined> {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const cipher = sodium.crypto_secretbox_easy(new TextEncoder().encode(text), nonce, rawKey);
return {
nonce: sodium.to_base64(nonce),
cipher: sodium.to_base64(cipher)
};
}
export async function decryptWithMasterKey(
nonce: string,
cipher: string
): Promise<string | undefined> {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
return new TextDecoder().decode(
sodium.crypto_secretbox_open_easy(sodium.from_base64(cipher), sodium.from_base64(nonce), rawKey)
);
}
@@ -0,0 +1,60 @@
import sodium from 'libsodium-wrappers-sumo';
import { encryptWithMasterKey, getRawKey, getSecureHash } from './encryption';
import type { VideoPlay } from '../model';
import { getBestThumbnail } from '$lib/images';
export async function updateWatchHistory(videoId: string, progress: number) {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
const videoHash = await getSecureHash(videoId, rawKey);
await fetch(`/api/user/history/${videoHash}`, {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
watched: new Date(),
progress
})
});
}
export async function saveWatchHistory(video: VideoPlay, progress: number = 0) {
await sodium.ready;
const rawKey = await getRawKey();
if (!rawKey) return;
const videoHash = await getSecureHash(video.videoId, rawKey);
const title = await encryptWithMasterKey(video.title);
const author = await encryptWithMasterKey(video.author);
const thumbnail = await encryptWithMasterKey(getBestThumbnail(video.videoThumbnails));
const duration = await encryptWithMasterKey(video.lengthSeconds.toString());
await fetch('/api/user/history', {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
id: videoHash,
watched: new Date(),
progress: progress,
title: {
cipher: title?.cipher,
nonce: title?.nonce
},
author: {
cipher: author?.cipher,
nonce: author?.nonce
},
thumbnail: {
cipher: thumbnail?.cipher,
nonce: thumbnail?.nonce
},
duration: {
cipher: duration?.cipher,
nonce: duration?.nonce
}
})
});
}
+112
View File
@@ -0,0 +1,112 @@
import sodium from 'libsodium-wrappers-sumo';
import { rawMasterKeyStore } from '$lib/store';
export type DerivePassword = (rawPassword: string, passwordSalt: Uint8Array) => Promise<Uint8Array>;
export async function createUserBackend(
username: string,
rawPassword: string,
captchaPayload: string,
derivePassword: DerivePassword
): Promise<boolean> {
await sodium.ready;
const passwordSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const loginHash = await derivePassword(rawPassword, passwordSalt);
const decryptionKeySalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const rawDecryptionKey = sodium.crypto_pwhash(
32,
rawPassword,
decryptionKeySalt,
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
sodium.crypto_pwhash_ALG_DEFAULT
);
const rawDecryptionMasterKey = sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES);
const decryptionMasterKeyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const masterKeyCipher = sodium.crypto_secretbox_easy(
rawDecryptionMasterKey,
decryptionMasterKeyNonce,
rawDecryptionKey
);
const userCreateResp = await fetch('/api/user/create', {
method: 'POST',
body: JSON.stringify({
username: username,
password: {
hash: sodium.to_base64(loginHash),
salt: sodium.to_base64(passwordSalt)
},
decryptionKeySalt: sodium.to_base64(decryptionKeySalt),
masterKey: {
cipher: sodium.to_base64(masterKeyCipher),
nonce: sodium.to_base64(decryptionMasterKeyNonce)
},
captchaPayload
}),
credentials: 'same-origin'
});
if (!userCreateResp.ok) return false;
rawMasterKeyStore.set(sodium.to_base64(rawDecryptionMasterKey));
return true;
}
export async function loginUserBackend(
username: string,
rawPassword: string,
captchaPayload: string,
derivePassword: DerivePassword
): Promise<boolean> {
await sodium.ready;
const passwordSaltsResp = await fetch(`/api/user/${username}/public`);
if (!passwordSaltsResp.ok) return false;
const passwordSalts = await passwordSaltsResp.json();
const loginHash = await derivePassword(
rawPassword,
sodium.from_base64(passwordSalts.passwordSalt)
);
const loginResp = await fetch('/api/user/login', {
method: 'POST',
body: JSON.stringify({
username,
passwordHash: sodium.to_base64(loginHash),
captchaPayload
}),
credentials: 'same-origin'
});
if (!loginResp.ok) return false;
const loginJson = await loginResp.json();
const rawDecryptionKey = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES,
rawPassword,
sodium.from_base64(passwordSalts.decryptionKeySalt),
sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
sodium.crypto_pwhash_ALG_DEFAULT
);
const rawDecryptionMasterKey = sodium.crypto_secretbox_open_easy(
sodium.from_base64(loginJson.masterKeyCipher),
sodium.from_base64(loginJson.masterKeyNonce),
rawDecryptionKey
);
rawMasterKeyStore.set(sodium.to_base64(rawDecryptionMasterKey));
return true;
}
@@ -0,0 +1,35 @@
import { decryptWithMasterKey, encryptWithMasterKey } from './encryption';
export async function addOrUpdateKeyValue(key: string, value: string) {
const valueEncrypted = await encryptWithMasterKey(value);
await fetch(`/api/user/keyValue/${key}`, {
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
valueCipher: valueEncrypted?.cipher,
valueNonce: valueEncrypted?.nonce
})
});
}
export async function deleteKeyValue(key: string) {
await fetch(`/api/user/keyValue/${key}`, {
method: 'DELETE',
credentials: 'same-origin'
});
}
export type KeyValue = boolean | number | string[] | string | object | undefined;
export async function getKeyValue(key: string): Promise<KeyValue | null> {
const resp = await fetch(`/api/user/keyValue/${key}`, {
method: 'GET',
credentials: 'same-origin'
});
if (!resp.ok) return null;
const respJson = await resp.json();
return (await decryptWithMasterKey(respJson.valueNonce, respJson.valueCipher)) ?? null;
}
@@ -0,0 +1,99 @@
import { parseChannelRSS } from '$lib/api/youtubejs/subscriptions';
import { getChannelYTjs } from '$lib/api/youtubejs/channel';
import type { ChannelSubscriptions } from '$lib/dexie';
import { decryptWithMasterKey, encryptWithMasterKey, getRawKey, getSecureHash } from './encryption';
export async function getSubscriptionsBackend(): Promise<ChannelSubscriptions[]> {
const resp = await fetch(`/api/user/subscriptions`, {
method: 'GET',
credentials: 'same-origin'
});
if (!resp.ok) return [];
const subscriptions: ChannelSubscriptions[] = [];
const respJson = await resp.json();
for (const sub of respJson.subscriptions) {
subscriptions.push({
channelName: (await decryptWithMasterKey(sub.channelNameNonce, sub.channelNameCipher)) ?? '',
channelId: (await decryptWithMasterKey(sub.channelIdNonce, sub.channelIdCipher)) ?? '',
lastRSSFetch: new Date(sub.lastRSSFetch)
});
}
return subscriptions;
}
export async function updateRSSLastUpdated(authorId: string) {
const rawKey = await getRawKey();
if (!rawKey) return false;
const internalAuthorId = await getSecureHash(authorId, rawKey);
await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
method: 'PATCH',
credentials: 'same-origin'
});
}
export async function amSubscribedBackend(authorId: string): Promise<boolean> {
const rawKey = await getRawKey();
if (!rawKey) return false;
const internalAuthorId = await getSecureHash(authorId, rawKey);
const resp = await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
method: 'GET',
credentials: 'same-origin'
});
if (!resp.ok) return false;
const respJson = await resp.json();
return respJson.amSubscribed;
}
export async function deleteUnsubscribeBackend(authorId: string) {
const rawKey = await getRawKey();
if (!rawKey) return false;
const internalAuthorId = await getSecureHash(authorId, rawKey);
await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
method: 'DELETE',
credentials: 'same-origin'
});
}
export async function postSubscribeBackend(
authorId: string,
authorName: string | undefined = undefined
) {
const rawKey = await getRawKey();
if (!rawKey) return;
const internalAuthorId = await getSecureHash(authorId, rawKey);
if (!authorName) {
const channel = await getChannelYTjs(authorId);
authorName = channel.author;
}
const channelId = await encryptWithMasterKey(authorId);
const channelName = await encryptWithMasterKey(authorName);
const resp = await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
method: 'POST',
body: JSON.stringify({
channelIdCipher: channelId?.cipher,
channelIdNonce: channelId?.nonce,
channelNameCipher: channelName?.cipher,
channelNameNonce: channelName?.nonce
}),
credentials: 'same-origin'
});
if (resp.ok) parseChannelRSS(authorId);
}
+1 -2
View File
@@ -21,7 +21,6 @@ import type {
ReturnYTDislikes,
SearchSuggestion,
Subscription,
ApiExntendedProgressModel,
Video,
VideoPlay,
SearchOptions,
@@ -51,7 +50,7 @@ import {
deleteUnsubscribeBackend,
getSubscriptionsBackend,
postSubscribeBackend
} from './backend';
} from './backend/subscriptions';
import { getUserLocale } from '$lib/i18n';
export function buildPath(path: string): URL {
@@ -10,7 +10,7 @@ import {
engineMaxConcurrentChannelsStore,
rawMasterKeyStore
} from '$lib/store';
import { getSubscriptionsBackend } from '../backend';
import { getSubscriptionsBackend } from '../backend/subscriptions';
export async function getSubscriptionsYTjs(): Promise<Subscription[]> {
const subscriptions: Subscription[] = [];
@@ -16,7 +16,7 @@
import { postSubscribeYTjs } from '$lib/api/youtubejs/subscriptions';
import { addToast } from '../Toast.svelte';
import { isOwnBackend } from '$lib/shared';
import { postSubscribeBackend } from '$lib/api/backend';
import { postSubscribeBackend } from '$lib/api/backend/subscriptions';
const engineFallbacks: EngineFallback[] = [
'Channel',
@@ -5,7 +5,7 @@ import { z } from 'zod';
import { persistedStores } from './settings';
import { isOwnBackend } from '$lib/shared';
import { addOrUpdateKeyValue, getKeyValue } from '$lib/api/backend';
import { addOrUpdateKeyValue, getKeyValue } from '$lib/api/backend/keyvalue';
import { rawMasterKeyStore } from '$lib/store';
import { getPublicEnv } from '$lib/misc';
+1 -1
View File
@@ -19,7 +19,7 @@ import { error } from '@sveltejs/kit';
import { get } from 'svelte/store';
import { _ } from './i18n';
import { isOwnBackend } from './shared';
import { saveWatchHistory } from './api/backend';
import { saveWatchHistory } from './api/backend/history';
export async function getWatchDetails(videoId: string, url: URL) {
const playerStateRetrieved = get(playerState);