Implemented account setting syncing

This commit is contained in:
WardPearce
2026-02-15 21:27:34 +13:00
parent 9f59303434
commit b89800f9da
9 changed files with 528 additions and 197 deletions
+34
View File
@@ -220,6 +220,40 @@ export async function loginUserBackend(
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;
}
async function encryptWithMasterKey(
text: string
): Promise<{ nonce: string; cipher: string } | undefined> {
@@ -86,7 +86,6 @@
clearCaches();
ui('#dialog-settings');
goto(resolve('/', {}), { replaceState: true });
location.reload();
}
async function setInstance(event: Event) {
@@ -165,7 +164,7 @@
</button>
</nav>
</form>
{#if isOwnBackend()?.internalAuth}
{#if isOwnBackend()?.internalAuth && $invidiousInstanceStore}
{#if !$invidiousAuthStore}
<button onclick={goToInvidiousLogin}>
<i>link</i>
@@ -0,0 +1,128 @@
import { page } from '$app/state';
import { get } from 'svelte/store';
import { z } from 'zod';
import { env } from '$env/dynamic/public';
import { persistedStores } from './settings';
import { isOwnBackend } from '$lib/shared';
import { addOrUpdateKeyValue, getKeyValue } from '$lib/api/backend';
import { rawMasterKeyStore } from '$lib/store';
export async function syncSettingsToBackend() {
if (!isOwnBackend() || !get(rawMasterKeyStore)) return;
await Promise.all(
persistedStores.map(async (store) => {
getKeyValue(store.name).then((currentKeyValue) => {
if (currentKeyValue !== null) {
const currentKeyValueParsed = parseWithSchema(store.schema, currentKeyValue);
if (currentKeyValueParsed !== null && currentKeyValueParsed !== undefined) {
store.store.set(currentKeyValueParsed);
}
}
});
let initalLoad = true;
store.store.subscribe((value) => {
if (initalLoad) {
initalLoad = false;
return;
}
if (value === undefined) return;
return addOrUpdateKeyValue(
store.name,
store.serialize ? store.serialize(value) : value?.toString()
);
});
})
);
}
export function parseWithSchema<T>(schema: z.ZodType<T>, raw: unknown): T | undefined {
try {
if (typeof raw === 'string') {
// Try JSON first (records / objects)
try {
return schema.parse(JSON.parse(raw));
} catch {
return schema.parse(raw);
}
}
return schema.parse(raw);
} catch {
return undefined;
}
}
function setStores(toSet: Record<string, unknown>, overwriteExisting = false) {
if (!overwriteExisting) return;
for (const { name, store, schema } of persistedStores) {
const raw = toSet[name];
if (raw === undefined) continue;
const parsed = parseWithSchema(schema, raw);
if (parsed !== undefined) {
store.set(parsed);
}
}
}
export function loadSettingsFromEnv() {
if (
typeof import.meta.env.VITE_DEFAULT_SETTINGS !== 'string' &&
typeof env.PUBLIC_DEFAULT_SETTINGS !== 'string'
)
return;
let raw = import.meta.env.VITE_DEFAULT_SETTINGS || env.PUBLIC_DEFAULT_SETTINGS;
// Docker wraps env vars in quotes
if (raw.startsWith('"')) raw = raw.slice(1);
if (raw.endsWith('"')) raw = raw.slice(0, -1);
let isInitialLoad = false;
try {
if (localStorage.getItem('initialLoadState') === null) {
isInitialLoad = true;
localStorage.setItem('initialLoadState', '0');
}
} catch {
isInitialLoad = true;
}
try {
const parsed = JSON.parse(raw);
setStores(parsed, isInitialLoad);
} catch (err) {
console.error(err);
}
}
export function bookmarkletSaveToUrl(): string {
const url = new URL(location.origin);
for (const { name, store, serialize } of persistedStores) {
const value = get(store);
const encoded = serialize ? serialize(value) : value?.toString();
if (encoded !== undefined) {
url.searchParams.set(name, encoded);
}
}
return url.toString();
}
export function bookmarkletLoadFromUrl() {
const toSet: Record<string, string> = {};
page.url.searchParams.forEach((value, key) => {
toSet[key] = value;
});
setStores(toSet, true);
}
@@ -1,8 +1,5 @@
import { page } from '$app/state';
import { get, type Writable } from 'svelte/store';
import { z } from 'zod';
import { env } from '$env/dynamic/public';
import { type Writable } from 'svelte/store';
import {
darkModeStore,
@@ -40,8 +37,24 @@ import {
playerDefaultPlaybackSpeed,
playerCCByDefault,
playerMiniplayerEnabled,
sponsorBlockCategoriesStore
sponsorBlockCategoriesStore,
invidiousAuthStore,
backendInUseStore,
invidiousInstanceStore,
engineFallbacksStore,
engineMaxConcurrentChannelsStore,
engineCooldownYTStore,
engineCullYTStore,
interfaceAllowInsecureRequests,
interfaceDisableAutoUpdate,
interfaceAndroidUseNativeShare,
playerAndroidPauseOnNetworkChange,
playerAndroidLockOrientation,
playerYouTubeJsFallback,
playerYouTubeJsAlways,
interfaceSearchHistoryEnabled
} from '$lib/store';
import { isOwnBackend } from '$lib/shared';
type PersistedStore<T> = {
name: string;
@@ -50,29 +63,18 @@ type PersistedStore<T> = {
serialize?: (value: T) => string;
};
function parseWithSchema<T>(schema: z.ZodType<T>, raw: unknown): T | undefined {
try {
if (typeof raw === 'string') {
// Try JSON first (records / objects)
try {
return schema.parse(JSON.parse(raw));
} catch {
return schema.parse(raw);
}
}
return schema.parse(raw);
} catch {
return undefined;
}
}
const zBoolean = z.coerce.boolean();
const zNumber = z.coerce.number();
const zString = z.string();
const zArray = z.array(z.string());
const zChapterMode = z.enum(['automatic', 'manual', 'timeline']);
const zChapterModeRecord = z.record(z.string(), zChapterMode.optional());
const zAuth = z.object({
username: z.string(),
token: z.string()
});
const persistedStores: PersistedStore<any>[] = [
export const persistedStores: PersistedStore<any>[] = [
{
name: 'returnYTDislikesInstance',
store: returnYTDislikesInstanceStore,
@@ -128,6 +130,11 @@ const persistedStores: PersistedStore<any>[] = [
store: interfaceSearchSuggestionsStore,
schema: zBoolean
},
{
name: 'searchHistoryEnabled',
store: interfaceSearchHistoryEnabled,
schema: zBoolean
},
{
name: 'sponsorBlock',
store: sponsorBlockStore,
@@ -256,72 +263,79 @@ const persistedStores: PersistedStore<any>[] = [
}
];
function setStores(toSet: Record<string, unknown>, overwriteExisting = false) {
if (!overwriteExisting) return;
for (const { name, store, schema } of persistedStores) {
const raw = toSet[name];
if (raw === undefined) continue;
const parsed = parseWithSchema(schema, raw);
if (parsed !== undefined) {
store.set(parsed);
}
}
}
export function loadSettingsFromEnv() {
if (
typeof import.meta.env.VITE_DEFAULT_SETTINGS !== 'string' &&
typeof env.PUBLIC_DEFAULT_SETTINGS !== 'string'
)
return;
let raw = import.meta.env.VITE_DEFAULT_SETTINGS || env.PUBLIC_DEFAULT_SETTINGS;
// Docker wraps env vars in quotes
if (raw.startsWith('"')) raw = raw.slice(1);
if (raw.endsWith('"')) raw = raw.slice(0, -1);
let isInitialLoad = false;
try {
if (localStorage.getItem('initialLoadState') === null) {
isInitialLoad = true;
localStorage.setItem('initialLoadState', '0');
}
} catch {
isInitialLoad = true;
}
try {
const parsed = JSON.parse(raw);
setStores(parsed, isInitialLoad);
} catch (err) {
console.error(err);
}
}
export function bookmarkletSaveToUrl(): string {
const url = new URL(location.origin);
for (const { name, store, serialize } of persistedStores) {
const value = get(store);
const encoded = serialize ? serialize(value) : value?.toString();
if (encoded !== undefined) {
url.searchParams.set(name, encoded);
}
}
return url.toString();
}
export function bookmarkletLoadFromUrl() {
const toSet: Record<string, string> = {};
page.url.searchParams.forEach((value, key) => {
toSet[key] = value;
// If using own backend with can support more externalSettings.
if (isOwnBackend()) {
persistedStores.push({
name: 'authToken',
store: invidiousAuthStore,
schema: zAuth,
serialize: JSON.stringify
});
persistedStores.push({
name: 'backendInUse',
store: backendInUseStore,
schema: zString
});
persistedStores.push({
name: 'invidiousInstance',
store: invidiousInstanceStore,
schema: zString
});
persistedStores.push({
name: 'engineFallbacks',
store: engineFallbacksStore,
schema: zArray
});
persistedStores.push({
name: 'engineMaxConcurrentChannels',
store: engineMaxConcurrentChannelsStore,
schema: zNumber
});
persistedStores.push({
name: 'engineCooldownYT',
store: engineCooldownYTStore,
schema: zNumber
});
persistedStores.push({
name: 'engineCullYT',
store: engineCullYTStore,
schema: zNumber
});
persistedStores.push({
name: 'allowInsecureRequests',
store: interfaceAllowInsecureRequests,
schema: zString
});
persistedStores.push({
name: 'disableAutoUpdate',
store: interfaceDisableAutoUpdate,
schema: zString
});
persistedStores.push({
name: 'androidUseNativeShare',
store: interfaceAndroidUseNativeShare,
schema: zString
});
persistedStores.push({
name: 'pauseOnNetworkChange',
store: playerAndroidPauseOnNetworkChange,
schema: zBoolean
});
persistedStores.push({
name: 'androidLockOrientation',
store: playerAndroidLockOrientation,
schema: zBoolean
});
persistedStores.push({
name: 'youTubeJsFallback',
store: playerYouTubeJsFallback,
schema: zBoolean
});
persistedStores.push({
name: 'youTubeJsAlways',
store: playerYouTubeJsAlways,
schema: zBoolean
});
setStores(toSet, true);
}
export const persistedStoreKeys = persistedStores.map((store) => store.name);
+40 -3
View File
@@ -4,6 +4,7 @@ import { env } from '$env/dynamic/private';
let UserTable: ModelCtor<Model<any, any>>;
let ChannelSubscriptionTable: ModelCtor<Model<any, any>>;
let CaptchaTable: ModelCtor<Model<any, any>>;
let UserKeyValueTable: ModelCtor<Model<any, any>>;
let sequelizeInstance: Sequelize | null = null;
@@ -13,13 +14,15 @@ export function getSequelize(): {
UserTable: ModelCtor<Model<any, any>>;
ChannelSubscriptionTable: ModelCtor<Model<any, any>>;
CaptchaTable: ModelCtor<Model<any, any>>;
UserKeyValueTable: ModelCtor<Model<any, any>>;
} {
if (sequelizeInstance) {
return {
sequelize: sequelizeInstance,
UserTable,
ChannelSubscriptionTable,
CaptchaTable
CaptchaTable,
UserKeyValueTable
};
}
@@ -78,6 +81,32 @@ export function getSequelize(): {
}
);
UserKeyValueTable = sequelizeInstance.define(
'UserKeyValue',
{
key: {
type: DataTypes.STRING,
allowNull: false
},
valueCipher: {
type: DataTypes.STRING,
allowNull: false
},
valueNonce: {
type: DataTypes.STRING,
allowNull: false
}
},
{
indexes: [
{
unique: true,
fields: ['UserId', 'key']
}
]
}
);
ChannelSubscriptionTable = sequelizeInstance.define('Subscriptions', {
id: {
type: DataTypes.STRING,
@@ -119,12 +148,14 @@ export function getSequelize(): {
});
UserTable.hasMany(ChannelSubscriptionTable);
UserTable.hasMany(UserKeyValueTable);
return {
sequelize: sequelizeInstance,
UserTable,
ChannelSubscriptionTable,
CaptchaTable
CaptchaTable,
UserKeyValueTable
};
}
@@ -138,7 +169,7 @@ export interface ChannelSubscriptionModel {
userId: string;
}
export interface UserTableModel extends Model {
export interface UserTableModel {
id: string;
username: string;
passwordHash: string;
@@ -148,3 +179,9 @@ export interface UserTableModel extends Model {
masterKeyCipher: string;
masterKeyNonce: string;
}
export interface UserKeyStoreModel {
key: string;
valueCipher: string;
valueNonce: string;
}
+59 -1
View File
@@ -1,4 +1,9 @@
import { getSequelize, type ChannelSubscriptionModel, type UserTableModel } from './database';
import {
getSequelize,
type ChannelSubscriptionModel,
type UserKeyStoreModel,
type UserTableModel
} from './database';
import { Op } from 'sequelize';
import crypto from 'crypto';
import { error } from '@sveltejs/kit';
@@ -81,6 +86,59 @@ export class User {
return subscriptions as unknown as ChannelSubscriptionModel[];
}
async getKeyValue(key: string): Promise<UserKeyStoreModel> {
const keyStore = await getSequelize().UserKeyValueTable.findOne({
where: {
UserId: this.id,
key
}
});
if (!keyStore) {
throw error(404);
}
const keyStoreModel = keyStore as unknown as UserKeyStoreModel;
return {
key: keyStoreModel.key,
valueCipher: keyStoreModel.valueCipher,
valueNonce: keyStoreModel.valueNonce
};
}
async addOrUpdateKeyValue(key: string, valueCipher: string, valueNonce: string) {
const keyStore = await getSequelize().UserKeyValueTable.findOne({
where: {
UserId: this.id,
key
}
});
if (keyStore) {
await keyStore.update({
valueCipher,
valueNonce
});
} else {
await getSequelize().UserKeyValueTable.create({
key,
valueCipher,
valueNonce,
UserId: this.id
});
}
}
async deleteKeyValue(key: string) {
await getSequelize().UserKeyValueTable.destroy({
where: {
UserId: this.id,
key
}
});
}
}
export type CreateUser = {
@@ -3,7 +3,6 @@
import { goto } from '$app/navigation';
import { navigating, page } from '$app/stores';
import colorTheme, { convertToHexColorCode } from '$lib/android/plugins/colorTheme';
import { getFeed, notificationsMarkAsRead } from '$lib/api/index';
import type { Notification } from '$lib/api/model';
import Logo from '$lib/components/Logo.svelte';
@@ -14,13 +13,10 @@
import Thumbnail from '$lib/components/Thumbnail.svelte';
import Player from '$lib/components/Player.svelte';
import '$lib/css/global.css';
import { bookmarkletLoadFromUrl, loadSettingsFromEnv } from '$lib/externalSettings';
import { getPages } from '$lib/navPages';
import {
invidiousAuthStore,
darkModeStore,
invidiousInstanceStore,
interfaceAmoledTheme,
interfaceDefaultPage,
isAndroidTvStore,
playerState,
@@ -29,17 +25,12 @@
syncPartyPeerStore,
themeColorStore
} from '$lib/store';
import { setAmoledTheme, setStatusBarColor, setTheme } from '$lib/theme';
import { App } from '@capacitor/app';
import { Browser } from '@capacitor/browser';
import { Capacitor } from '@capacitor/core';
import 'beercss';
import ui from 'beercss';
import 'material-dynamic-colors';
import { onMount } from 'svelte';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { pwaInfo } from 'virtual:pwa-info';
import { goToInvidiousLogin, isYTBackend, logout, truncate } from '$lib/misc';
import Author from '$lib/components/Author.svelte';
import Toast from '$lib/components/Toast.svelte';
@@ -50,17 +41,13 @@
const showLogin = !isYTBackend() || isOwnBackend()?.internalAuth;
let mobileSearchShow = $state(false);
let notifications: Notification[] = $state([]);
let playerIsPip: boolean = $state(false);
let pages = $state(getPages());
invidiousAuthStore.subscribe(() => {
pages = getPages();
});
rawMasterKeyStore.subscribe(() => {
pages = getPages();
});
@@ -74,52 +61,6 @@
requestAnimationFrame(() => resetScroll());
});
interfaceAmoledTheme.subscribe(async () => {
setAmoledTheme();
await setStatusBarColor();
});
darkModeStore.subscribe(async () => {
setTheme();
setAmoledTheme();
await setStatusBarColor();
});
App.addListener('appUrlOpen', (data) => {
const url = new URL(data.url);
// Handle youtube deeplinks
if (url.protocol !== 'materialious-auth:') {
let videoId = url.searchParams.get('v');
if (!videoId) {
videoId = url.pathname.split('/')[1];
}
if (videoId === 'shorts') {
videoId = url.pathname.split('/')[2];
}
if (!videoId) {
return;
}
goto(resolve(`/watch/[videoId]`, { videoId: videoId }));
} else {
// Auth response handling for Mobile
const username = url.searchParams.get('username');
const token = url.searchParams.get('token');
if (username && token) {
invidiousAuthStore.set({
username: username,
token: token
});
}
}
});
async function login() {
if (isOwnBackend()?.internalAuth) {
goto(resolve('/internal/login', {}));
@@ -196,41 +137,6 @@
}
onMount(async () => {
ui();
let themeHex = get(themeColorStore);
if (Capacitor.getPlatform() === 'android' && !themeHex) {
try {
const colorPalette = await colorTheme.getColorPalette();
themeHex = convertToHexColorCode(colorPalette.primary);
await ui('theme', themeHex);
} catch {
// Continue regardless of error
}
}
if (Capacitor.getPlatform() === 'android') {
document.addEventListener('click', async (event: MouseEvent) => {
// Handles opening links in browser for android.
const link = (event.target as HTMLElement).closest('a');
if (link && link.href && link.href.startsWith('http') && link.target === '_blank') {
event.preventDefault();
await Browser.open({ url: link.href });
}
});
}
loadSettingsFromEnv();
// Should always be loaded after env settings
// So user preferences overwrite instance preferences.
bookmarkletLoadFromUrl();
await setStatusBarColor();
setTheme();
setAmoledTheme();
if ($invidiousAuthStore && !isYTBackend()) {
loadNotifications().catch(() => logout());
}
@@ -245,15 +151,8 @@
resetScroll();
});
let webManifestLink = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
</script>
<svelte:head>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html webManifestLink}
</svelte:head>
<div>
<nav
class="left m l surface-container"
+118 -1
View File
@@ -1,10 +1,46 @@
<script lang="ts">
import { isAndroidTvStore } from '$lib/store';
import {
darkModeStore,
interfaceAmoledTheme,
invidiousAuthStore,
isAndroidTvStore,
rawMasterKeyStore,
themeColorStore
} from '$lib/store';
import ui from 'beercss';
import { App } from '@capacitor/app';
import '$lib/fetchProxy';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { setAmoledTheme, setStatusBarColor, setTheme } from '$lib/theme';
import { pwaInfo } from 'virtual:pwa-info';
import { onMount } from 'svelte';
import { Capacitor } from '@capacitor/core';
import colorTheme, { convertToHexColorCode } from '$lib/android/plugins/colorTheme';
import { Browser } from '@capacitor/browser';
import {
bookmarkletLoadFromUrl,
loadSettingsFromEnv,
syncSettingsToBackend
} from '$lib/externalSettings';
import { timeout } from '$lib/misc';
let { children } = $props();
interfaceAmoledTheme.subscribe(async () => {
setAmoledTheme();
await setStatusBarColor();
});
darkModeStore.subscribe(async () => {
setTheme();
setAmoledTheme();
await setStatusBarColor();
});
App.addListener('backButton', async (data) => {
if (data.canGoBack) {
window.history.back();
@@ -12,9 +48,90 @@
await App.exitApp();
}
});
App.addListener('appUrlOpen', (data) => {
const url = new URL(data.url);
// Handle youtube deeplinks
if (url.protocol !== 'materialious-auth:') {
let videoId = url.searchParams.get('v');
if (!videoId) {
videoId = url.pathname.split('/')[1];
}
if (videoId === 'shorts') {
videoId = url.pathname.split('/')[2];
}
if (!videoId) {
return;
}
goto(resolve(`/watch/[videoId]`, { videoId: videoId }));
} else {
// Auth response handling for Mobile
const username = url.searchParams.get('username');
const token = url.searchParams.get('token');
if (username && token) {
invidiousAuthStore.set({
username: username,
token: token
});
}
}
});
onMount(async () => {
ui();
if (Capacitor.getPlatform() === 'android' && $themeColorStore) {
try {
const colorPalette = await colorTheme.getColorPalette();
themeColorStore.set(convertToHexColorCode(colorPalette.primary));
await ui('theme', $themeColorStore);
} catch {
// Continue regardless of error
}
}
if (Capacitor.getPlatform() === 'android') {
document.addEventListener('click', async (event: MouseEvent) => {
// Handles opening links in browser for android.
const link = (event.target as HTMLElement).closest('a');
if (link && link.href && link.href.startsWith('http') && link.target === '_blank') {
event.preventDefault();
await Browser.open({ url: link.href });
}
});
}
loadSettingsFromEnv();
// Should always be loaded after env settings
// So user preferences overwrite instance preferences.
bookmarkletLoadFromUrl();
await setStatusBarColor();
setTheme();
setAmoledTheme();
});
let syncToSettingsInitialized = false;
rawMasterKeyStore.subscribe((value) => {
if (value === undefined || syncToSettingsInitialized) return;
syncToSettingsInitialized = true;
syncSettingsToBackend();
});
let webManifestLink = $derived(pwaInfo ? pwaInfo.webManifest.linkTag : '');
</script>
<svelte:head>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html webManifestLink}
{#if $isAndroidTvStore}
<style>
:focus {
@@ -0,0 +1,45 @@
import { persistedStoreKeys } from '$lib/externalSettings/settings.js';
import { getUser } from '$lib/server/user.js';
import { error, json } from '@sveltejs/kit';
import z from 'zod';
async function keyAllowed(key: string) {
if (!persistedStoreKeys.includes(key)) throw error(400);
}
export async function GET({ locals, params }) {
keyAllowed(params.key);
const user = await getUser(locals.userId);
return json(await user.getKeyValue(params.key));
}
export async function DELETE({ locals, params }) {
keyAllowed(params.key);
const user = await getUser(locals.userId);
await user.deleteKeyValue(params.key);
return new Response();
}
const zUpdateKeyStore = z.object({
valueCipher: z.string().max(1000),
valueNonce: z.string().max(255)
});
export async function POST({ locals, params, request }) {
keyAllowed(params.key);
const user = await getUser(locals.userId);
const keyValue = zUpdateKeyStore.safeParse(await request.json());
if (!keyValue.success) throw error(400);
await user.addOrUpdateKeyValue(params.key, keyValue.data.valueCipher, keyValue.data.valueNonce);
return new Response();
}