Implemented account setting syncing
This commit is contained in:
@@ -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);
|
||||
}
|
||||
+103
-89
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user