Merge pull request #1414 from Materialious/update/1.15.0

Update/1.15.0
This commit is contained in:
Ward
2026-02-15 02:42:57 +13:00
committed by GitHub
83 changed files with 4708 additions and 478 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Build full web
on:
push:
branches:
- main
paths-ignore:
- "**/*.md"
- "./fastline/**"
- "./materialious/src/lib/i18n/**"
jobs:
web-build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract version from package.json
id: extract_version
run: |
VERSION=$(jq -r .version ./materialious/package.json)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./materialious
file: FullDockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: |
wardpearce/materialious-full:latest
wardpearce/materialious-full:${{ steps.extract_version.outputs.version }}
+6 -8
View File
@@ -19,7 +19,7 @@
- Use in local mode without relying on a Invidious instance.
- [Invidious companion support.](./docs/DOCKER.md#invidious-companion-support)
- [Invidious API extended integration.](https://github.com/Materialious/api-extended)
- [YouTube.js](https://github.com/LuanRT/YouTube.js) fallback if Invidious fails loading videos for Desktop & Android.
- [YouTube.js](https://github.com/LuanRT/YouTube.js) fallback if Invidious fails loading videos.
- Android TV support
- Support for disabling certificate validation for homelab users.
- Sync your watch progress between Invidious sessions.
@@ -41,14 +41,12 @@
- PWA support.
- YT path redirects (So your redirect plugins should still work!)
# Support table
| | Dash | HLS | Local video fallback | API-Extended | Dearrow | RYD | Watch Parties |
|---------|------|-----|----------------------|--------------|---------|-----|----------------|
| Web | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
| Desktop | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| Android | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
# Docker deployment
[Please read the guide here](./docs/DOCKER-FULL.md)
# Legacy Docker deployment, Invidious only.
This version of Materialious is still **fully** supported, but is purely just a Invidious frontend without any fancy bells and whistles, this version is harder to deploy...
# Deploying as a website via docker
[Please read the guide here](./docs/DOCKER.md)
# Installing as a app
-13
View File
@@ -1,13 +0,0 @@
services:
materialious:
image: wardpearce/materialious
restart: unless-stopped
ports:
- 3001:80
environment:
VITE_DEFAULT_INVIDIOUS_INSTANCE: "https://invidious.materialio.us"
VITE_DEFAULT_RETURNYTDISLIKES_INSTANCE: "https://returnyoutubedislikeapi.com"
VITE_DEFAULT_SPONSERBLOCK_INSTANCE: "https://sponsor.ajay.app"
VITE_DEFAULT_DEARROW_INSTANCE: "https://sponsor.ajay.app"
VITE_DEFAULT_DEARROW_THUMBNAIL_INSTANCE: "https://dearrow-thumb.ajay.app"
+143
View File
@@ -0,0 +1,143 @@
## Step 1: Docker
This Guide is for the docker image `wardpearce/materialious-full` **NOT** `wardpearce/materialious`
### Docker Compose
```yaml
services:
materialious:
image: wardpearce/materialious-full:latest
restart: unless-stopped
ports:
- 3000:3000
environment:
# Secure secret for signing cookies
# Not required if VITE_INTERNAL_AUTH is false
COOKIE_SECRET: ""
# Database connectiong URI
# Not required if VITE_INTERNAL_AUTH is false
# postgresql, mysql2, mariadb & sqlite supported
# guide here for URL structure https://docs.preset.io/docs/uri-connection-strings
DATABASE_CONNECTION_URI: "sqlite://materialious.db"
# Use Materialious account system.
PUBLIC_INTERNAL_AUTH: "true"
# If Auth is required to use this instance.
# Should be left as true, otherwise anyone can use Materialious proxy.
PUBLIC_REQUIRE_AUTH: "true"
# Allow anyone to register
PUBLIC_REGISTRATION_ALLOWED: "false"
# Allow any domain in proxy
# This shouldn't be used unless you KNOW what your doing
# requires VITE_REGISTRATION_ALLOWED to be false
# VITE_REQUIRE_AUTH to be true
# VITE_INTERNAL_AUTH to be true
# to take effect.
PUBLIC_DANGEROUS_ALLOW_ANY_PROXY: "false"
# Optionally set a default Invidious instance
# This will also whitelist this instance in the proxy.
PUBLIC_DEFAULT_INVIDIOUS_INSTANCE: ""
# URL TO RYD (Return YouTube Dislike / https://github.com/Anarios/return-youtube-dislike)
# Leave blank to disable completely.
# This will also whitelist this instance in the proxy.
PUBLIC_DEFAULT_RETURNYTDISLIKES_INSTANCE: "https://returnyoutubedislikeapi.com"
# URL to Sponsorblock
# Leave blank to completely disable sponsorblock.
# This will also whitelist this instance in the proxy.
PUBLIC_DEFAULT_SPONSERBLOCK_INSTANCE: "https://sponsor.ajay.app"
# URL to DeArrow
# This will also whitelist this instance in the proxy.
PUBLIC_DEFAULT_DEARROW_INSTANCE: "https://sponsor.ajay.app"
# URL to DeArrow thumbnail instance
# This will also whitelist this instance in the proxy.
PUBLIC_DEFAULT_DEARROW_THUMBNAIL_INSTANCE: "https://dearrow-thumb.ajay.app"
# Look at "Overwriting Materialious defaults" for all the accepted values.
# This will also whitelist this instance in the proxy.
PUBLIC_DEFAULT_SETTINGS: '{"themeColor": "#2596be","region": "US"}'
volumes:
- materialious-data:/materialious.db
volumes:
materialious-data:
```
### Overwriting Materialious defaults
Materialious allows you to overwrite the default values using `VITE_DEFAULT_SETTINGS`, see [SETTINGS](./SETTINGS.md) for more details.
**Please note:** These overwrites only apply on 1st load & won't replace existing configuration stored in browser local storage.
## Step 2 (Optional, but recommended): Self-host RYD-Proxy
#### With TOR (Recommended)
```yml
tor-proxy:
image: 1337kavin/alpine-tor:latest
restart: unless-stopped
environment:
- tors=15
ryd-proxy:
image: 1337kavin/ryd-proxy:latest
restart: unless-stopped
depends_on:
- tor-proxy
environment:
- PROXY=socks5://tor-proxy:5566
ports:
- 3003:3000
```
#### Without TOR
```yml
ryd-proxy:
image: 1337kavin/ryd-proxy:latest
restart: unless-stopped
ports:
- 3003:3000
```
Modify/add `VITE_DEFAULT_RETURNYTDISLIKES_INSTANCE` for Materialious to be the reverse proxied URL of RYD-Proxy.
## Step 3 (Optional, but recommended): Self-host Invidious API extended
```yaml
services:
api_extended:
image: wardpearce/invidious_api_extended:latest
restart: unless-stopped
ports:
- 3004:80
environment:
api_extended_postgre: '{"host": "invidious-db", "port": 5432, "database": "invidious", "user": "kemal", "password": "kemal"}'
api_extended_allowed_origins: '["https://materialious.example.com"]'
api_extended_debug: false
# No trailing backslashes!
api_extended_invidious_instance: "https://invidious.example.com"
api_extended_production_instance: "https://syncious.example.com"
```
Add these additional environment variables to Materialious.
```yaml
VITE_DEFAULT_API_EXTENDED_INSTANCE: "https://syncious.example.com"
```
## Step 4 (Optional): Self-host PeerJS
[Read the official guide.](https://github.com/peers/peerjs-server?tab=readme-ov-file#docker)
Add these additional environment variables to Materialious.
```yaml
# Will differ depending on how you self-host peerjs.
VITE_DEFAULT_PEERJS_HOST: "peerjs.example.com"
VITE_DEFAULT_PEERJS_PATH: "/"
VITE_DEFAULT_PEERJS_PORT: 443
```
+2
View File
@@ -1,5 +1,7 @@
# Setup
This Guide is for the docker image `wardpearce/materialious` **NOT** `wardpearce/materialious-full`
### CORS
To assure browsers they are allowed to access your Materialious instance
despite their default [same-origin policy][wp-sop], the instance's webserver
+17
View File
@@ -0,0 +1,17 @@
Dockerfile
.dockerignore
.git
.gitignore
.gitattributes
README.md
.npmrc
.prettierrc
.eslintrc.cjs
.graphqlrc
.editorconfig
.svelte-kit
.vscode
node_modules
build
package
**/.env
+2
View File
@@ -1,3 +1,5 @@
# This is the docker file for legacy deployment wardpearce/materialious
FROM node:latest AS builder
# Set the working directory
+28
View File
@@ -0,0 +1,28 @@
FROM node:24-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci
# Tell SvelteKit to use node adapter.
ENV PUBLIC_BUILD_WITH_BACKEND="true"
RUN npm run build
RUN npm prune --omit=dev
FROM node:24-alpine
WORKDIR /app
COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY package.json .
EXPOSE 3000
ENV NODE_ENV=production
# Tell the front end backend is in use.
ENV PUBLIC_BUILD_WITH_BACKEND="true"
CMD ["node", "build"]
+2 -2
View File
@@ -7,8 +7,8 @@ android {
applicationId "us.materialio.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 208
versionName "1.14.4"
versionCode 209
versionName "1.15.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -73,7 +73,11 @@
<release version="1.14.4" date="2026-2-13">
<release version="1.15.0" date="2026-2-13">
<url>https://github.com/Materialious/Materialious/releases/tag/1.15.0</url>
</release>
<release version="1.14.4" date="2026-2-13">
<url>https://github.com/Materialious/Materialious/releases/tag/1.14.4</url>
</release>
<release version="1.14.3" date="2026-2-13">
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "Materialious",
"version": "1.14.4",
"version": "1.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "Materialious",
"version": "1.14.4",
"version": "1.15.0",
"license": "MIT",
"dependencies": {
"@capacitor-community/electron": "^5.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "Materialious",
"version": "1.14.4",
"version": "1.15.0",
"description": "Modern material design for YouTube and Invidious.",
"author": {
"name": "Ward Pearce",
+1 -2
View File
@@ -85,8 +85,7 @@ ipcMain.handle(
{
url: youtubeUrl,
referrer: youtubeUrl,
origin: youtubeUrl,
USER_AGENT
userAgent: USER_AGENT
}
);
+2327 -157
View File
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -1,6 +1,6 @@
{
"name": "materialious",
"version": "1.14.4",
"version": "1.15.0",
"private": true,
"scripts": {
"dev": "npm run patch:github && vite dev",
@@ -19,12 +19,15 @@
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^8.0.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.5.3",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/cookie-signature": "^1.1.2",
"@types/event-source-polyfill": "^1.0.5",
"@types/he": "^1.2.3",
"@types/human-number": "^1.0.2",
"@types/jsdom": "^27.0.0",
"@types/mousetrap": "^1.6.15",
"@vite-pwa/sveltekit": "^1.0.0",
"eslint": "^9.39.1",
@@ -34,7 +37,6 @@
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.47.0",
"svelte-awesome-color-picker": "^4.1.1",
"svelte-check": "^4.3.3",
"tslib": "^2.7.0",
"typescript": "^5.9.3",
@@ -55,11 +57,15 @@
"@capacitor/share": "^8.0.0",
"@macfja/serializer": "^1.1.4",
"@macfja/svelte-persistent-store": "^2.4.2",
"altcha": "^2.3.0",
"altcha-lib": "^1.4.1",
"beercss": "^4.0.2",
"bgutils-js": "^3.2.0",
"buffer": "^6.0.3",
"capacitor-music-controls-plugin": "^6.1.0",
"capacitor-nodejs": "https://github.com/hampoelz/capacitor-nodejs/releases/download/v1.0.0-beta.9/capacitor-nodejs.tgz",
"comlink": "^4.4.2",
"cookie-signature": "^1.2.2",
"dayjs": "^1.11.19",
"dexie": "^4.2.1",
"fuse.js": "^7.0.0",
@@ -69,15 +75,25 @@
"i18next": "^25.7.2",
"iso-3166": "^4.4.0",
"iso-639-1": "^3.1.5",
"jsdom": "^28.0.0",
"libsodium-wrappers-sumo": "^0.8.2",
"mariadb": "^3.4.5",
"material-dynamic-colors": "^1.1.1",
"media-captions": "^1.0.4",
"melt": "^0.44.0",
"mousetrap": "^1.6.5",
"mysql2": "^3.17.1",
"peerjs": "^1.5.5",
"pg": "^8.18.0",
"pg-hstore": "^2.3.4",
"psl": "^1.15.0",
"sequelize": "^6.37.7",
"shaka-player": "^4.16.14",
"sponsorblock-api": "^0.2.4",
"sqlite3": "^5.1.7",
"svelte-awesome-color-picker": "^4.1.1",
"svelte-infinite-loading": "^1.4.0",
"youtubei.js": "^16.0.1",
"zod": "^4.3.6"
}
}
}
+4 -1
View File
@@ -3,7 +3,10 @@ import type { IGetChallengeResponse } from 'youtubei.js';
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
userId: string;
captchaKey: string;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
+44
View File
@@ -0,0 +1,44 @@
import { isOwnBackend } from '$lib/shared';
import { getSequelize } from '$lib/server/database';
import { unsign } from 'cookie-signature';
import { env } from '$env/dynamic/private';
import sodium from 'libsodium-wrappers-sumo';
let captchaKey = '';
sodium.ready.then(() => {
captchaKey = sodium.to_base64(sodium.randombytes_buf(32));
});
let sequelizeAuthenticated = false;
export async function handle({ event, resolve }) {
if (!isOwnBackend()?.internalAuth) {
return await resolve(event);
}
event.locals.captchaKey = captchaKey;
const sequelize = getSequelize();
if (!sequelizeAuthenticated) {
await sequelize.sequelize.sync();
await sequelize.sequelize.authenticate();
sequelizeAuthenticated = true;
}
if (!env.COOKIE_SECRET) {
throw new Error('Cookie secret must be set');
}
if (env.COOKIE_SECRET.length < 16) {
throw new Error('COOKIE_SECRET must be at least 16 characters long');
}
const signedUserId = event.cookies.get('userid');
if (signedUserId) {
const userId = unsign(signedUserId, env.COOKIE_SECRET);
if (userId) {
event.locals.userId = userId;
}
}
return await resolve(event);
}
+247
View File
@@ -0,0 +1,247 @@
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';
async function getInternalAuthorId(authorId: string, rawKey: Uint8Array): Promise<string> {
await sodium.ready;
return sodium.to_base64(
sodium.crypto_generichash(sodium.crypto_generichash_BYTES, authorId, 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 getInternalAuthorId(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 getInternalAuthorId(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 getInternalAuthorId(authorId, rawKey);
await fetch(`/api/user/subscriptions/${internalAuthorId}`, {
method: 'DELETE',
credentials: 'same-origin'
});
}
export async function postSubscribeBackend(authorId: string) {
const rawKey = await getRawKey();
if (!rawKey) return;
const internalAuthorId = await getInternalAuthorId(authorId, rawKey);
const channel = await getChannelYTjs(authorId);
const channelId = await encryptWithMasterKey(authorId);
const channelName = await encryptWithMasterKey(channel.author);
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;
}
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)
);
}
+45 -15
View File
@@ -1,14 +1,14 @@
import { getVideoYTjs } from '$lib/api/youtubejs/video';
import { Capacitor } from '@capacitor/core';
import { get } from 'svelte/store';
import {
authStore,
invidiousAuthStore,
deArrowInstanceStore,
deArrowThumbnailInstanceStore,
instanceStore,
invidiousInstanceStore,
interfaceRegionStore,
playerYouTubeJsAlways,
playerYouTubeJsFallback,
rawMasterKeyStore,
returnYTDislikesInstanceStore,
synciousInstanceStore
} from '../store';
@@ -33,7 +33,7 @@ import type {
} from './model';
import { commentsSetDefaults, searchSetDefaults, useEngineFallback } from './misc';
import { getSearchYTjs } from './youtubejs/search';
import { isYTBackend } from '$lib/misc';
import { isUnrestrictedPlatform, isYTBackend } from '$lib/misc';
import { getSearchSuggestionsYTjs } from './youtubejs/searchSuggestions';
import { getResolveUrlYTjs } from './youtubejs/misc';
import { getCommentsYTjs } from './youtubejs/comments';
@@ -46,9 +46,16 @@ import {
postSubscribeYTjs
} from './youtubejs/subscriptions';
import { getPlaylistYTjs } from './youtubejs/playlist';
import { isOwnBackend } from '$lib/shared';
import {
amSubscribedBackend,
deleteUnsubscribeBackend,
getSubscriptionsBackend,
postSubscribeBackend
} from './backend';
export function buildPath(path: string): URL {
return new URL(`${get(instanceStore)}/api/v1/${path}`);
return new URL(`${get(invidiousInstanceStore)}/api/v1/${path}`);
}
export function setRegion(url: URL): URL {
@@ -77,7 +84,7 @@ export async function fetchErrorHandle(response: Response): Promise<Response> {
}
export function buildAuthHeaders(): { headers: Record<string, string> } {
const authToken = get(authStore)?.token ?? '';
const authToken = get(invidiousAuthStore)?.token ?? '';
if (authToken.startsWith('SID=')) {
return { headers: { __sid_auth: authToken } };
} else {
@@ -110,7 +117,7 @@ export async function getVideo(
fetchOptions?: RequestInit
): Promise<VideoPlay> {
if (
(get(playerYouTubeJsAlways) && Capacitor.isNativePlatform()) ||
(get(playerYouTubeJsAlways) && isUnrestrictedPlatform()) ||
isYTBackend() ||
useEngineFallback('Video')
) {
@@ -119,7 +126,7 @@ export async function getVideo(
const resp = await fetch(setRegion(buildPath(`videos/${videoId}?local=${local}`)), fetchOptions);
if (!resp.ok && get(playerYouTubeJsFallback) && Capacitor.isNativePlatform()) {
if (!resp.ok && get(playerYouTubeJsFallback) && isUnrestrictedPlatform()) {
return await getVideoYTjs(videoId);
} else {
await fetchErrorHandle(resp);
@@ -174,6 +181,10 @@ export async function getChannelContent(
): Promise<ChannelContent> {
if (typeof options.type === 'undefined') options.type = 'videos';
if (isYTBackend() || useEngineFallback('ChannelContent')) {
return await getChannelContentYTjs(channelId, options);
}
const url = buildPath(`channels/${channelId}/${options.type}`);
if (typeof options.continuation !== 'undefined')
@@ -181,10 +192,6 @@ export async function getChannelContent(
if (typeof options.sortBy !== 'undefined') url.searchParams.set('sort_by', options.sortBy);
if (isYTBackend() || useEngineFallback('ChannelContent')) {
return await getChannelContentYTjs(channelId, options);
}
const resp = await fetchErrorHandle(await fetch(url.toString(), fetchOptions));
return await resp.json();
}
@@ -279,6 +286,15 @@ export async function getSubscriptions(
bypassYTBackend: boolean = false
): Promise<Subscription[]> {
if (isYTBackend() && !bypassYTBackend) {
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
return (await getSubscriptionsBackend()).map((sub) => {
return {
author: sub.channelName,
authorId: sub.channelId
};
});
}
return getSubscriptionsYTjs();
}
const resp = await fetchErrorHandle(
@@ -292,10 +308,14 @@ export async function amSubscribed(
fetchOptions: RequestInit = {}
): Promise<boolean> {
if (isYTBackend()) {
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
return amSubscribedBackend(authorId);
}
return amSubscribedYTjs(authorId);
}
if (!get(authStore)) return false;
if (!get(invidiousAuthStore)) return false;
try {
const subscriptions = (await getSubscriptions(fetchOptions)).filter(
@@ -313,6 +333,10 @@ export async function postSubscribe(
bypassYTBackend: boolean = false
) {
if (isYTBackend() && !bypassYTBackend) {
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
return postSubscribeBackend(authorId);
}
return postSubscribeYTjs(authorId);
}
@@ -327,6 +351,12 @@ export async function postSubscribe(
export async function deleteUnsubscribe(authorId: string, fetchOptions: RequestInit = {}) {
if (isYTBackend()) {
// deleteUnsubscribeYTjs still should run
// as cleans feeds of that channel.
if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) {
deleteUnsubscribeBackend(authorId);
}
return deleteUnsubscribeYTjs(authorId);
}
@@ -401,7 +431,7 @@ export async function getPlaylist(
let resp;
if (get(authStore)) {
if (get(invidiousAuthStore)) {
resp = await fetch(buildPath(`auth/playlists/${playlistId}?page=${page}`), {
...buildAuthHeaders(),
...fetchOptions
@@ -518,7 +548,7 @@ export async function getThumbnail(
}
function buildApiExtendedAuthHeaders(): Record<string, Record<string, string>> {
const authToken = get(authStore)?.token ?? '';
const authToken = get(invidiousAuthStore)?.token ?? '';
return { headers: { Authorization: `Bearer ${authToken.replace('SID=', '')}` } };
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { get } from 'svelte/store';
import type { CommentsOptions, SearchOptions } from './model';
import { engineFallbacksStore } from '$lib/store';
import { Capacitor } from '@capacitor/core';
import { isUnrestrictedPlatform } from '$lib/misc';
export function searchSetDefaults(options: SearchOptions) {
if (typeof options.sort_by === 'undefined') {
@@ -34,5 +34,5 @@ export type EngineFallback =
| 'Playlist';
export function useEngineFallback(fallback: EngineFallback): boolean {
return get(engineFallbacksStore).includes(fallback) && Capacitor.isNativePlatform();
return get(engineFallbacksStore).includes(fallback) && isUnrestrictedPlatform();
}
@@ -1,5 +1,4 @@
import { localDb } from '$lib/dexie';
import { clearCaches } from '$lib/misc';
import { localDb, type ChannelSubscriptions } from '$lib/dexie';
import { cleanNumber } from '$lib/numbers';
import { relativeTimestamp } from '$lib/time';
import { get } from 'svelte/store';
@@ -8,8 +7,10 @@ import { getChannelYTjs } from './channel';
import {
engineCooldownYTStore,
engineCullYTStore,
engineMaxConcurrentChannelsStore
engineMaxConcurrentChannelsStore,
rawMasterKeyStore
} from '$lib/store';
import { getSubscriptionsBackend, updateRSSLastUpdated } from '../backend';
export async function getSubscriptionsYTjs(): Promise<Subscription[]> {
const subscriptions: Subscription[] = [];
@@ -54,7 +55,6 @@ export async function postSubscribeYTjs(
export async function deleteUnsubscribeYTjs(authorId: string) {
await localDb.channelSubscriptions.where('channelId').equals(authorId).delete();
await localDb.subscriptionFeed.where('authorId').equals(authorId).delete();
clearCaches();
}
export async function parseChannelRSS(channelId: string): Promise<void> {
@@ -132,14 +132,28 @@ export async function parseChannelRSS(channelId: string): Promise<void> {
// Continue regardless of error
}
await localDb.channelSubscriptions.where('channelId').equals(channelId).modify({
lastRSSFetch: new Date()
});
if (!get(rawMasterKeyStore)) {
localDb.channelSubscriptions.where('channelId').equals(channelId).modify({
lastRSSFetch: new Date()
});
} else {
updateRSSLastUpdated(authorId);
}
}
}
export async function clearFeedYTjs() {
await localDb.subscriptionFeed.clear();
}
export async function getFeedYTjs(maxResults: number, page: number): Promise<Feed> {
const channelSubscriptions = await localDb.channelSubscriptions.toArray();
let channelSubscriptions: ChannelSubscriptions[];
if (!get(rawMasterKeyStore)) {
channelSubscriptions = await localDb.channelSubscriptions.toArray();
} else {
channelSubscriptions = await getSubscriptionsBackend();
}
const toUpdatePromises: Promise<void>[] = [];
+21 -6
View File
@@ -13,8 +13,10 @@ import { convertToSeconds } from '$lib/time';
import { Capacitor } from '@capacitor/core';
import { get } from 'svelte/store';
import type { Types } from 'youtubei.js';
import { Utils, YT, YTNodes, Platform } from 'youtubei.js';
import { Utils, YT, YTNodes, Platform, type IGetChallengeResponse } from 'youtubei.js';
import { getInnertube } from '.';
import { isUnrestrictedPlatform } from '$lib/misc';
import { webPoTokenMinter } from '$lib/web/youtube/minter';
Platform.shim.eval = async (
data: Types.BuildScriptResult,
@@ -36,7 +38,7 @@ Platform.shim.eval = async (
};
export async function getVideoYTjs(videoId: string): Promise<VideoPlay> {
if (!Capacitor.isNativePlatform()) {
if (!isUnrestrictedPlatform()) {
throw new Error('Platform not supported');
}
@@ -44,10 +46,23 @@ export async function getVideoYTjs(videoId: string): Promise<VideoPlay> {
const requestKey = 'O43z0dpjhgX20SCx4KAo';
const platformMinter =
Capacitor.getPlatform() === 'android'
? androidPoTokenMinter
: window.electronAPI.generatePoToken;
let platformMinter: (
requestKey: string,
visitorData: string,
challenge: IGetChallengeResponse
) => Promise<string>;
switch (Capacitor.getPlatform()) {
case 'electron':
platformMinter = window.electronAPI.generatePoToken;
break;
case 'android':
platformMinter = androidPoTokenMinter;
break;
default:
platformMinter = webPoTokenMinter;
break;
}
const clientPlaybackNonce = Utils.generateRandomString(16);
@@ -4,7 +4,7 @@
import type { Image } from '$lib/api/model';
import { getBestThumbnail, proxyGoogleImage } from '$lib/images';
import { isYTBackend, truncate } from '$lib/misc';
import { authStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
import { invidiousAuthStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store';
import { _ } from '$lib/i18n';
import { localDb } from '$lib/dexie';
import { onMount } from 'svelte';
@@ -71,7 +71,7 @@
</nav>
</a>
{#if !hideSubscribe}
{#if $authStore || isYTBackend()}
{#if $invidiousAuthStore || isYTBackend()}
<nav class="group split">
<button
onclick={toggleSubscribed}
@@ -2,7 +2,7 @@
import Thumbnail from '$lib/components/Thumbnail.svelte';
import { _ } from '$lib/i18n';
import { removePlaylistVideo } from '../api';
import { authStore, feedLastItemId, isAndroidTvStore } from '../store';
import { invidiousAuthStore, feedLastItemId, isAndroidTvStore } from '../store';
import ContentColumn from './ContentColumn.svelte';
import { onMount, onDestroy, tick } from 'svelte';
import Mousetrap from 'mousetrap';
@@ -260,7 +260,7 @@
{#key item.videoId}
<Thumbnail video={item} {playlistId} />
{/key}
{#if $authStore && decodeURIComponent($authStore.username) === playlistAuthor && 'indexId' in item}
{#if $invidiousAuthStore && decodeURIComponent($invidiousAuthStore.username) === playlistAuthor && 'indexId' in item}
<div class="right-align" style="margin: 1em .5em;">
<button
onclick={async () => removePlaylistItem(item.indexId)}
+23 -13
View File
@@ -19,8 +19,8 @@
import { deleteVideoProgress, getVideoProgress, saveVideoProgress } from '../api';
import type { VideoPlay } from '../api/model';
import {
authStore,
instanceStore,
invidiousAuthStore,
invidiousInstanceStore,
isAndroidTvStore,
playerAlwaysLoopStore,
playerAndroidLockOrientation,
@@ -45,6 +45,7 @@
} from '../store';
import { setStatusBarColor } from '../theme';
import { getVideoYTjs } from '$lib/api/youtubejs/video';
import { env } from '$env/dynamic/public';
import {
goToNextVideo,
goToPreviousVideo,
@@ -58,7 +59,7 @@
import { Network, type ConnectionStatus } from '@capacitor/network';
import { fade } from 'svelte/transition';
import { addToast } from './Toast.svelte';
import { isMobile, isYTBackend, truncate } from '$lib/misc';
import { isMobile, isUnrestrictedPlatform, isYTBackend, truncate } from '$lib/misc';
import {
generateThumbnailWebVTT,
drawTimelineThumbnail,
@@ -515,8 +516,11 @@
// Due to CORs issues with redirects, hosted instances of Materialious
// dirctly provide the companion instance
// while clients can just use the reirect provided by Invidious' API
if (import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE) {
dashUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE}/api/manifest/dash/id/${data.video.videoId}`;
if (
import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE ||
env.PUBLIC_DEFAULT_COMPANION_INSTANCE
) {
dashUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE || env.PUBLIC_DEFAULT_COMPANION_INSTANCE}/api/manifest/dash/id/${data.video.videoId}`;
} else {
if (!data.video.dashUrl) {
error(500, 'No dash manifest found');
@@ -524,7 +528,7 @@
dashUrl = data.video.dashUrl;
}
if (!data.video.fallbackPatch && (!Capacitor.isNativePlatform() || $playerProxyVideosStore)) {
if (!data.video.fallbackPatch && (!isUnrestrictedPlatform() || $playerProxyVideosStore)) {
dashUrl += '?local=true';
}
@@ -547,7 +551,9 @@
playerMaxKnownTime
);
} else if (!data.video.fallbackPatch) {
const thumbnailVTTResp = await fetch(`${$instanceStore}${selectedStoryboard.url}`);
const thumbnailVTTResp = await fetch(
`${$invidiousInstanceStore}${selectedStoryboard.url}`
);
if (thumbnailVTTResp.ok) thumbnailVTT = await thumbnailVTTResp.text();
}
@@ -576,12 +582,16 @@
if (data.video.captions) {
for (const caption of data.video.captions) {
let captionUrl: string;
if (!import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE) {
if (
!import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE &&
!env.PUBLIC_DEFAULT_COMPANION_INSTANCE &&
$invidiousInstanceStore
) {
captionUrl = caption.url.startsWith('http')
? caption.url
: `${new URL(get(instanceStore)).origin}${caption.url}`;
: `${new URL($invidiousInstanceStore).origin}${caption.url}`;
} else {
captionUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE}${caption.url}`;
captionUrl = `${import.meta.env.VITE_DEFAULT_COMPANION_INSTANCE || env.PUBLIC_DEFAULT_COMPANION_INSTANCE}${caption.url}`;
}
await player.addTextTrackAsync(
@@ -1035,7 +1045,7 @@
console.error(error);
if (
!Capacitor.isNativePlatform() ||
!isUnrestrictedPlatform() ||
data.video.fallbackPatch === 'youtubejs' ||
(error as shaka.extern.Error).code !== 1001
)
@@ -1079,7 +1089,7 @@
// Continue regardless of error
}
if ($synciousStore && $synciousInstanceStore && $authStore) {
if ($synciousStore && $synciousInstanceStore && $invidiousAuthStore) {
try {
toSetTime = (await getVideoProgress(data.video.videoId))[0].time;
} catch {
@@ -1093,7 +1103,7 @@
function savePlayerPos() {
if (data.video.liveNow) return;
const synciousEnabled = $synciousStore && $synciousInstanceStore && $authStore;
const synciousEnabled = $synciousStore && $synciousInstanceStore && $invidiousAuthStore;
if ($playerSavePlaybackPositionStore && playerElement) {
if (
@@ -0,0 +1,45 @@
<script lang="ts">
type Answer = {
text: string;
action: () => void;
};
let {
answers,
question,
info = undefined
}: {
answers: Answer[];
question: string;
info?: string;
} = $props();
const coloumSize = answers.length > 2 ? '4' : '6';
let selectedAnswer = $state('');
function selectAnswer(answer: Answer) {
selectedAnswer = answer.text;
answer.action();
}
</script>
<h3>{question}</h3>
<div class="grid">
{#each answers as answer (answer)}
<div class={`s12 m${coloumSize} l${coloumSize}`}>
<article
style="cursor: pointer;"
role="presentation"
tabindex="-1"
onclick={() => selectAnswer(answer)}
class:primary={selectedAnswer === answer.text}
class:surface-container-highest={selectedAnswer !== answer.text}
>
<h6>{answer.text}</h6>
</article>
</div>
{/each}
</div>
{#if info}
<p><i>info</i> {info}</p>
{/if}
@@ -39,6 +39,7 @@
}
function onSubmit(event: Event | undefined = undefined) {
clearTimeout(debounceTimer);
event?.preventDefault();
goToSearch(search);
@@ -51,6 +52,7 @@
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
const direction = event.key === 'ArrowUp' ? -1 : 1;
const container = document.querySelector('.suggestions-container');
if (container) {
@@ -73,12 +75,18 @@
}
}
} else if (event.key === 'Enter' && selectedSuggestionIndex !== -1) {
event.preventDefault();
search = suggestionsForSearch[selectedSuggestionIndex];
onSubmit();
} else if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
resetSearch();
event.preventDefault();
} else if (event.key === 'Escape') {
event.preventDefault();
resetSearch();
}
}
@@ -139,6 +147,7 @@
bind:value={search}
onkeydown={handleKeyDown}
onkeyup={(event) => {
event.preventDefault();
if (event.key === 'Enter') {
onSubmit();
} else {
@@ -156,6 +165,7 @@
search = suggestion;
onSubmit();
}}
type="reset"
class="transparent suggestion"
class:selected={index === selectedSuggestionIndex}
>
@@ -171,6 +181,7 @@
onclick={() => {
search = history;
}}
type="reset"
class="transparent suggestion"
>
<div>{history}</div>
@@ -1,11 +1,10 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { instanceStore } from '$lib/store';
import { Capacitor } from '@capacitor/core';
import { invidiousInstanceStore } from '$lib/store';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import type { Notification, PlaylistPageVideo, Video, VideoBase } from '../api/model';
import { shareURL } from '$lib/misc';
import { isUnrestrictedPlatform, shareURL } from '$lib/misc';
import { addToast } from './Toast.svelte';
interface Props {
@@ -45,8 +44,8 @@
class="row"
role="presentation"
onclick={async () => {
if (Capacitor.isNativePlatform()) {
shareVideo(`${get(instanceStore)}/watch/${video.videoId}`);
if (isUnrestrictedPlatform()) {
shareVideo(`${get(invidiousInstanceStore)}/watch/${video.videoId}`);
} else {
shareVideo(
`${location.origin}${resolve('/watch/[videoId]', { videoId: video.videoId })}`,
@@ -11,7 +11,7 @@
import { createVideoUrl, insecureRequestImageHandler, isYTBackend } from '../misc';
import type { PlayerEvents } from '../player';
import {
authStore,
invidiousAuthStore,
deArrowEnabledStore,
interfaceLowBandwidthMode,
isAndroidTvStore,
@@ -108,7 +108,7 @@
thumbnail = img;
};
if (get(synciousStore) && get(synciousInstanceStore) && get(authStore)) {
if (get(synciousStore) && get(synciousInstanceStore) && get(invidiousAuthStore)) {
try {
progress = (await queueGetWatchProgress(video.videoId))?.time?.toString() ?? undefined;
} catch {
@@ -3,10 +3,9 @@
import Fuse from 'fuse.js';
import { type VTTCue, parseText, type ParsedCaptionsResult } from 'media-captions';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import type { VideoPlay } from '../api/model';
import { decodeHtmlCharCodes } from '../misc';
import { instanceStore } from '../store';
import { invidiousInstanceStore } from '../store';
interface Props {
video: VideoPlay;
@@ -46,11 +45,19 @@
return;
}
let urlConstructed = '';
if (video.fallbackPatch === 'youtubejs') {
urlConstructed = url;
} else if ($invidiousInstanceStore) {
urlConstructed = new URL($invidiousInstanceStore).origin + url;
} else {
return;
}
isLoading = true;
transcript = null;
const resp = await fetch(
`${!video.fallbackPatch ? new URL(get(instanceStore)).origin : ''}${url}`
);
const resp = await fetch(urlConstructed);
transcript = await parseText(await resp.text(), { strict: false });
transcriptCues = transcript.cues;
@@ -15,12 +15,12 @@
)}
>
<nav>
<div class="field prefix label border max">
<div class="field prefix label surface-container-highest max">
<i>link</i>
<input tabindex="0" bind:value={synciousInstance} name="syncious-instance" type="text" />
<label tabindex="-1" for="syncious-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<button class="square">
<i>done</i>
</button>
</nav>
@@ -17,12 +17,12 @@
<form onsubmit={preventDefault(() => deArrowInstanceStore.set(ensureNoTrailingSlash(deArrowUrl)))}>
<nav>
<div class="field prefix label border max">
<div class="field prefix label surface-container-highest max">
<i>link</i>
<input bind:value={deArrowUrl} name="dearrow-instance" type="text" />
<label for="dearrow-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<button class="square">
<i>done</i>
</button>
</nav>
@@ -30,12 +30,12 @@
<form onsubmit={preventDefault(() => deArrowThumbnailInstanceStore.set(deArrowThumbnailUrl))}>
<nav>
<div class="field prefix label border max">
<div class="field prefix label surface-container-highest max">
<i>link</i>
<input bind:value={deArrowThumbnailUrl} name="dearrow-thumbnail-instance" type="text" />
<label for="dearrow-thumbnail-instance">{$_('layout.deArrow.thumbnailInstanceUrl')}</label>
</div>
<button class="square round">
<button class="square">
<i>done</i>
</button>
</nav>
@@ -2,12 +2,12 @@
import { isYTBackend } from '$lib/misc';
import { _ } from '$lib/i18n';
import {
authStore,
invidiousAuthStore,
engineCooldownYTStore,
engineCullYTStore,
engineFallbacksStore,
engineMaxConcurrentChannelsStore,
instanceStore
invidiousInstanceStore
} from '$lib/store';
import { useEngineFallback, type EngineFallback } from '$lib/api/misc';
import { get } from 'svelte/store';
@@ -97,7 +97,7 @@
{#if isYTBackend()}
<h6>Feed</h6>
<div class="field label prefix border">
<div class="field label prefix surface-container-highest">
<i>view_stream</i>
<input
oninput={(event: Event) => {
@@ -109,7 +109,7 @@
/>
<label for="cull">{$_('layout.backendEngine.cull')}</label>
</div>
<div class="field label prefix border">
<div class="field label prefix surface-container-highest">
<i>schedule</i>
<input
oninput={(event: Event) => {
@@ -121,7 +121,7 @@
/>
<label for="cooldown">{$_('layout.backendEngine.cooldown')}</label>
</div>
<div class="field label prefix border">
<div class="field label prefix surface-container-highest">
<i>pending</i>
<input
oninput={(event: Event) => {
@@ -134,7 +134,7 @@
<label for="concurrent">{$_('layout.backendEngine.concurrent')}</label>
</div>
{#if $authStore && $instanceStore}
{#if $invidiousAuthStore && $invidiousInstanceStore}
<h6>{$_('layout.backendEngine.importExport')}</h6>
<div class="space"></div>
@@ -11,14 +11,20 @@
import type { RgbaColor, HsvaColor, Colord } from 'colord';
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { ensureNoTrailingSlash, isMobile, clearCaches } from '../../misc';
import {
isMobile,
clearCaches,
isUnrestrictedPlatform,
setInvidiousInstance,
goToInvidiousLogin
} from '../../misc';
import { getPages, type Pages } from '../../navPages';
import ColorPicker from 'svelte-awesome-color-picker';
import {
authStore,
invidiousAuthStore,
backendInUseStore,
darkModeStore,
instanceStore,
invidiousInstanceStore,
interfaceAllowInsecureRequests,
interfaceAmoledTheme,
interfaceAndroidUseNativeShare,
@@ -37,8 +43,9 @@
} from '../../store';
import { addToast } from '../Toast.svelte';
import { tick } from 'svelte';
import { isOwnBackend } from '$lib/shared';
let invidiousInstance = $state(get(instanceStore));
let invidiousInstance = $state(get(invidiousInstanceStore));
let region = $state(get(interfaceRegionStore));
let forceCase = $state(get(interfaceForceCase));
let defaultPage = $state(get(interfaceDefaultPage));
@@ -84,37 +91,8 @@
async function setInstance(event: Event) {
event.preventDefault();
invalidInstance = false;
const instance = ensureNoTrailingSlash(invidiousInstance);
try {
new URL(instance);
} catch {
invalidInstance = true;
}
if (invalidInstance) return;
let resp;
try {
resp = await fetch(`${instance}/api/v1/channels/UCH-_hzb2ILSCo9ftVSnrCIQ`);
} catch {
invalidInstance = true;
}
if (invalidInstance) return;
if (resp && !resp.ok) {
invalidInstance = true;
return;
}
instanceStore.set(instance);
invalidInstance = !(await setInvidiousInstance(invidiousInstance));
reloadState();
authStore.set(null);
}
async function setBackend(event: Event) {
@@ -148,13 +126,13 @@
}
let pages: Pages = $state([]);
authStore.subscribe(() => {
invidiousAuthStore.subscribe(() => {
pages = getPages();
});
</script>
{#if Capacitor.isNativePlatform()}
<div class="field label suffix border">
{#if isUnrestrictedPlatform()}
<div class="field label suffix surface-container-highest">
<select name="backend-in-use" onchange={setBackend}>
<option selected={$backendInUseStore === 'ivg'} value="ivg">Invidious</option>
<option selected={$backendInUseStore === 'yt'} value="yt">YouTube (Experimental)</option>
@@ -166,7 +144,10 @@
{#if $backendInUseStore === 'ivg'}
<form onsubmit={setInstance}>
<nav>
<div class="field prefix label border max" class:invalid={invalidInstance}>
<div
class="field prefix label surface-container-highest max"
class:invalid={invalidInstance}
>
<i>link</i>
<input
tabindex="0"
@@ -179,19 +160,36 @@
<span class="error">{$_('invalidInstance')}</span>
{/if}
</div>
<button class="square round">
<button class="square">
<i>done</i>
</button>
</nav>
</form>
{#if isOwnBackend()?.internalAuth}
{#if !$invidiousAuthStore}
<button onclick={goToInvidiousLogin}>
<i>link</i>
<span>{$_('linkInvidious')}</span>
</button>
{:else}
<button
onclick={() => {
invidiousAuthStore.set(null);
goto(resolve('/', {}));
}}
>
<i>link_off</i>
<span>{$_('unlinkInvidious')}</span>
</button>
{/if}
<div class="space"></div>
{/if}
{:else}
<div class="space"></div>
{/if}
{#if isMobile()}
{#if invalidInstance}
<div style="margin-bottom: 6em;"></div>
{/if}
{#if isMobile() && invalidInstance}
<div style="margin-bottom: 6em;"></div>
{/if}
{#if Capacitor.isNativePlatform() && (invalidInstance || $interfaceAllowInsecureRequests)}
@@ -408,7 +406,7 @@
</div>
{/if}
<div class="field label suffix border">
<div class="field label suffix surface-container-highest">
<select
tabindex="0"
name="region"
@@ -425,7 +423,7 @@
<i>arrow_drop_down</i>
</div>
<div class="field label suffix border">
<div class="field label suffix surface-container-highest">
<select
tabindex="0"
name="case"
@@ -443,7 +441,7 @@
<i>arrow_drop_down</i>
</div>
<div class="field label suffix border">
<div class="field label suffix surface-container-highest">
<select
name="defaultPage"
tabindex="0"
@@ -451,7 +449,7 @@
onchange={() => interfaceDefaultPage.set(defaultPage)}
>
{#each pages as page (page)}
{#if !page.requiresAuth || get(authStore)}
{#if !page.requiresAuth || get(invidiousAuthStore)}
<option selected={$interfaceDefaultPage === page.href} value={page.href}>{page.name}</option
>
{/if}
@@ -465,6 +463,7 @@
<div class="space"></div>
<div class="settings">
<h6>{$_('layout.bookmarklet')}</h6>
<div class="space"></div>
<button
class="no-margin"
onclick={async () => {
@@ -0,0 +1,36 @@
<script lang="ts">
import { _ } from '$lib/i18n';
import { logout } from '$lib/misc';
let clickCount = $state(0);
const clicksToDelte = 3;
let deleteDebounce: ReturnType<typeof setTimeout> | undefined;
async function deleteAccount() {
clickCount++;
if (deleteDebounce) clearTimeout(deleteDebounce);
deleteDebounce = setTimeout(() => {
clickCount = 0;
}, 10000);
if (clicksToDelte - clickCount === 0) {
await fetch('/api/user/delete', { method: 'DELETE' });
logout();
}
}
</script>
<h6>Delete account</h6>
<div class="space"></div>
<button class="tertiary" onclick={deleteAccount}>
<i>warning</i>
<span>{$_('layout.deleteAccount')}</span>
{#if clicksToDelte - clickCount > 0}
<div class="tooltip">
{$_('layout.clickXmoreTimesToDelete', {
clicksTillDelete: clicksToDelte - clickCount
})}
</div>
{/if}
</button>
@@ -22,7 +22,7 @@
playerYouTubeJsFallback
} from '../../store';
import { playbackRates } from '$lib/player';
import { isYTBackend } from '$lib/misc';
import { isUnrestrictedPlatform, isYTBackend } from '$lib/misc';
let defaultLanguage = $state(get(playerDefaultLanguage));
@@ -66,7 +66,7 @@
</script>
<div class="margin"></div>
<div class="field label suffix border">
<div class="field label suffix surface-container-highest">
<select
tabindex="0"
name="case"
@@ -84,7 +84,7 @@
<i>arrow_drop_down</i>
</div>
<div class="field label suffix border">
<div class="field label suffix surface-container-highest">
<select
tabindex="0"
name="quality"
@@ -106,7 +106,7 @@
<i>arrow_drop_down</i>
</div>
<div class="field label suffix border">
<div class="field label suffix surface-container-highest">
<select
tabindex="0"
name="quality"
@@ -122,8 +122,8 @@
<i>arrow_drop_down</i>
</div>
{#if Capacitor.isNativePlatform() && !isYTBackend()}
<div class="field suffix border label">
{#if isUnrestrictedPlatform() && !isYTBackend()}
<div class="field suffix surface-container-highest label">
<select
tabindex="0"
name="ytfallback"
@@ -243,7 +243,7 @@
</div>
{/if}
{#if Capacitor.isNativePlatform()}
{#if isUnrestrictedPlatform()}
<div class="field no-margin">
<nav class="no-padding">
<div class="max">
@@ -15,12 +15,12 @@
)}
>
<nav>
<div class="field prefix label border max">
<div class="field prefix label surface-container-highest max">
<i>link</i>
<input tabindex="0" bind:value={returnYTInstance} name="returnyt-instance" type="text" />
<label tabindex="-1" for="returnyt-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<button class="square">
<i>done</i>
</button>
</nav>
@@ -7,15 +7,17 @@
import Player from './Player.svelte';
import Ryd from './RYD.svelte';
import SponsorBlock from './SponsorBlock.svelte';
import { isAndroidTvStore } from '$lib/store';
import { isAndroidTvStore, rawMasterKeyStore } from '$lib/store';
import About from './About.svelte';
import Engine from './Engine.svelte';
import { Capacitor } from '@capacitor/core';
import { isUnrestrictedPlatform } from '$lib/misc';
import { isOwnBackend } from '$lib/shared';
import InternalAccount from './InternalAccount.svelte';
let activeTab = $state('interface');
const isActive = (id: string) => activeTab === id;
const tabs: { id: string; label: string; icon: string; component: Component }[] = [
let tabs: { id: string; label: string; icon: string; component: Component }[] = $state([
{ id: 'interface', label: $_('layout.interface'), icon: 'grid_view', component: Interface },
{ id: 'player', label: $_('layout.player.title'), icon: 'smart_display', component: Player },
{ id: 'ryd', label: 'Return YT Dislike', icon: 'thumb_down', component: Ryd },
@@ -33,9 +35,13 @@
icon: 'info',
component: About
}
];
]);
if (Capacitor.isNativePlatform()) {
let tabIds: string[] = $state([]);
setTabIds();
if (isUnrestrictedPlatform()) {
tabs.splice(1, 0, {
id: 'engine',
label: $_('layout.engine'),
@@ -44,7 +50,28 @@
});
}
const tabIds = tabs.map((tab) => tab.id);
function setTabIds() {
tabs.forEach((tab) => {
tabIds.push(tab.id);
});
}
rawMasterKeyStore.subscribe((value) => {
if (isOwnBackend()?.internalAuth && value) {
tabs.splice(1, 0, {
id: 'account',
label: $_('layout.materialiousAccount'),
icon: 'person',
component: InternalAccount
});
} else {
tabs = tabs.filter((tab) => {
return tab.id !== 'account';
});
}
setTabIds();
});
let dialogType = $state('');
@@ -102,24 +129,26 @@
<div style="height: 100%;">
<nav class="wrap s">
<button class="large small-round surface-container-highest max" data-ui="#tab-menu">
<i>{tabs[tabIds.indexOf(activeTab)].icon}</i>
<span>{tabs[tabIds.indexOf(activeTab)].label}</span>
<menu style="width: 100%;" data-ui="#tab-menu" id="tab-menu">
{#each tabs as tab (tab)}
<li
onclick={() => {
activeTab = tab.id;
}}
role="presentation"
data-ui="#tab-menu"
>
<i>{tab.icon}</i>
<span>{tab.label}</span>
</li>
{/each}
</menu>
</button>
{#if tabIds}
<button class="large small-round surface-container-highest max" data-ui="#tab-menu">
<i>{tabs[tabIds.indexOf(activeTab)]?.icon}</i>
<span>{tabs[tabIds.indexOf(activeTab)]?.label}</span>
<menu style="width: 100%;" data-ui="#tab-menu" id="tab-menu">
{#each tabs as tab (tab)}
<li
onclick={() => {
activeTab = tab.id;
}}
role="presentation"
data-ui="#tab-menu"
>
<i>{tab.icon}</i>
<span>{tab.label}</span>
</li>
{/each}
</menu>
</button>
{/if}
</nav>
<div class="s">
@@ -48,7 +48,7 @@
)}
>
<nav>
<div class="field prefix label border max">
<div class="field prefix label surface-container-highest max">
<i>link</i>
<input
tabindex="0"
@@ -58,7 +58,7 @@
/>
<label tabindex="-1" for="sponsorblock-instance">{$_('layout.instanceUrl')}</label>
</div>
<button class="square round">
<button class="square">
<i>done</i>
</button>
</nav>
@@ -119,7 +119,7 @@
<div class="max">
<p>{sponsor.name}</p>
</div>
<div class="field suffix border">
<div class="field suffix surface-container-highest">
<select onchange={(event) => onSponsorSet(sponsor.category, event)}>
<option selected={currentCatergoryTrigger === undefined} value="disabled"
>{$_('disabled')}</option
+4
View File
@@ -129,6 +129,10 @@ menu {
color: var(--on-surface) !important;
}
.altcha {
border: none !important;
}
@media screen and (max-width: 1000px) {
menu.mobile {
position: fixed !important;
+8 -2
View File
@@ -2,6 +2,8 @@ import { page } from '$app/state';
import { get, type Writable } from 'svelte/store';
import { z } from 'zod';
import { env } from '$env/dynamic/public';
import {
darkModeStore,
deArrowEnabledStore,
@@ -269,9 +271,13 @@ function setStores(toSet: Record<string, unknown>, overwriteExisting = false) {
}
export function loadSettingsFromEnv() {
if (typeof import.meta.env.VITE_DEFAULT_SETTINGS !== 'string') return;
if (
typeof import.meta.env.VITE_DEFAULT_SETTINGS !== 'string' &&
typeof env.PUBLIC_DEFAULT_SETTINGS !== 'string'
)
return;
let raw = import.meta.env.VITE_DEFAULT_SETTINGS;
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);
@@ -1,8 +1,12 @@
import { timeout } from '$lib/misc';
import { isUnrestrictedPlatform, timeout } from '$lib/misc';
import { Capacitor } from '@capacitor/core';
const originalFetch = window.fetch;
const corsProxyUrl: string = 'http://localhost:3000/';
export const originalFetch = window.fetch;
const corsProxyUrl =
Capacitor.getPlatform() === 'android'
? 'http://localhost:3000/'
: `${location.origin}/api/proxy/`;
let mobileNodeLoaded = false;
function needsProxying(target: string): boolean {
@@ -10,11 +14,15 @@ function needsProxying(target: string): boolean {
return true;
}
export const androidFetch = async (
function encodeUrl(url: string): string {
return Capacitor.getPlatform() === 'android' ? url : encodeURIComponent(url);
}
export const fetchProxied = async (
requestInput: string | URL | Request,
requestOptions?: RequestInit
): Promise<Response> => {
if (!mobileNodeLoaded) {
if (!mobileNodeLoaded && Capacitor.getPlatform() === 'android') {
let mobileNodeResp: Response | undefined;
while (!mobileNodeResp || !mobileNodeResp.ok) {
@@ -29,11 +37,14 @@ export const androidFetch = async (
mobileNodeLoaded = true;
}
const uri = requestInput instanceof Request ? requestInput.url : requestInput.toString();
const nonProxiedUrl =
requestInput instanceof Request ? requestInput.url : requestInput.toString();
if (needsProxying(nonProxiedUrl)) {
const proxiedUrl = corsProxyUrl + encodeUrl(nonProxiedUrl);
if (needsProxying(uri)) {
if (requestInput instanceof Request) {
requestInput = new Request(corsProxyUrl + uri, {
requestInput = new Request(proxiedUrl, {
method: requestInput.method,
headers: requestInput.headers,
body: requestInput.body,
@@ -47,7 +58,7 @@ export const androidFetch = async (
...(requestInput.body ? { duplex: 'half' } : {})
});
} else {
requestInput = corsProxyUrl + uri;
requestInput = proxiedUrl;
}
}
@@ -55,14 +66,14 @@ export const androidFetch = async (
return originalFetch(requestInput, requestOptions);
};
if (Capacitor.getPlatform() === 'android') {
window.fetch = androidFetch;
if (isUnrestrictedPlatform() && Capacitor.getPlatform() !== 'electron') {
window.fetch = fetchProxied;
const originalXhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (...args: any[]): void {
if (needsProxying(args[1])) {
args[1] = corsProxyUrl + args[1];
args[1] = corsProxyUrl + encodeUrl(args[1]);
}
// @ts-expect-error args have any type
return originalXhrOpen.apply(this, args);
+28 -1
View File
@@ -7,9 +7,21 @@
"loadMore": "Load more",
"views": "views",
"login": "Login",
"username": "Username",
"password": "Password",
"invalidPassword": "Invalid password",
"usernameTaken": "Username taken",
"linkInvidious": "Link Invidious account",
"unlinkInvidious": "Unlink Invidious account",
"createAccount": "Create account",
"needRegister": "I need to register",
"registrationDisabled": "Registration currently disabled, contact instance owner.",
"needLogin": "I need to login",
"recommendedVideos": "Recommended Videos",
"playlistVideos": "Playlist Videos",
"invidiousLogin": "Please log in with your Invidious account",
"invidiousLogin": "Please log in with your Invidious account.",
"materialiousLogin": "Please login with your Materialious account.",
"materialiousCreate": "Please create your Materialious account.",
"invalidInstance": "Please verify the URL. If it's correct, the instance may be down or may not support third-party clients.",
"invidiousBlockWarning": "Invidious is currently being blocked by Google. If videos aren't loading for this instance, please use this instance on Materialious on {android} or {desktop} to get around this with local video fallback.",
"videos": "videos",
@@ -24,6 +36,18 @@
"sortBy": "Sort By",
"features": "Features"
},
"initalSetup": {
"useInvidious": "I want to use Invidious",
"useLocalFallback": "I want to use local video fallback",
"yes": "Yes",
"no": "No",
"unsure": "Unsure",
"required": "Setup Required",
"invidiousInfo": "Invidious is an Self-hostable YouTube interface.",
"localFallbackInfo": "When a video fails loading from Invidious, Materialious loads it locally.",
"configureInstance": "Configure instance",
"done": "Done"
},
"title": "Title",
"searchPlaceholder": "Search",
"delete": "Delete",
@@ -145,6 +169,7 @@
"engine": "Backend engine",
"syncParty": "Sync party",
"about": "About",
"materialiousAccount": "Account",
"syncPartyWarning": "Please note your IP will be visible to users you invite.",
"startSyncParty": "Start sync party",
"endSyncParty": "End sync party",
@@ -160,6 +185,8 @@
"displayThumbnailAvatars": "Thumbnail avatars (Slow)",
"searchHistory": "Search history",
"companionUrl": "Companion URL",
"deleteAccount": "Delete account",
"clickXmoreTimesToDelete": "Click {{clicksTillDelete}} more time(s) to delete",
"theme": {
"theme": "Theme",
"darkMode": "Dark mode",
+2 -2
View File
@@ -1,6 +1,6 @@
import { get } from 'svelte/store';
import type { Image } from './api/model';
import { instanceStore } from './store';
import { invidiousInstanceStore } from './store';
import { isYTBackend } from './misc';
export class ImageCache {
@@ -66,5 +66,5 @@ export function proxyGoogleImage(source: string): string {
if (typeof path === 'undefined') return '';
return `${get(instanceStore)}/ggpht${path}`;
return `${get(invidiousInstanceStore)}/ggpht${path}`;
}
+87 -6
View File
@@ -3,15 +3,18 @@ import { resolve } from '$app/paths';
import he from 'he';
import type Peer from 'peerjs';
import { get } from 'svelte/store';
import { env } from '$env/dynamic/public';
import {
authStore,
invidiousAuthStore,
backendInUseStore,
channelCacheStore,
feedCacheStore,
invidiousInstanceStore,
interfaceAllowInsecureRequests,
interfaceAndroidUseNativeShare,
isAndroidTvStore,
playlistCacheStore,
rawMasterKeyStore,
searchCacheStore
} from './store';
import type {
@@ -27,6 +30,9 @@ import { page } from '$app/state';
import { Capacitor } from '@capacitor/core';
import { Share } from '@capacitor/share';
import { Clipboard } from '@capacitor/clipboard';
import { isOwnBackend } from './shared';
import { Browser } from '@capacitor/browser';
import { clearFeedYTjs } from './api/youtubejs/subscriptions';
export function isMobile(): boolean {
const userAgent = navigator.userAgent;
@@ -80,9 +86,10 @@ export interface PeerInstance {
export function determinePeerJsInstance(): PeerInstance {
return {
host: import.meta.env.VITE_DEFAULT_PEERJS_HOST || '0.peerjs.com',
path: import.meta.env.VITE_DEFAULT_PEERJS_PATH || '/',
port: import.meta.env.VITE_DEFAULT_PEERJS_PORT || 443
host:
import.meta.env.VITE_DEFAULT_PEERJS_HOST || env.PUBLIC_DEFAULT_PEERJS_HOST || '0.peerjs.com',
path: import.meta.env.VITE_DEFAULT_PEERJS_PATH || env.PUBLIC_DEFAULT_PEERJS_PATH || '/',
port: import.meta.env.VITE_DEFAULT_PEERJS_PORT || env.PUBLIC_DEFAULT_PEERJS_PORT || 443
};
}
@@ -229,8 +236,12 @@ export function findElementForTime<T>(
return null;
}
export function isUnrestrictedPlatform(): boolean {
return isOwnBackend() !== null || Capacitor.isNativePlatform();
}
export function isYTBackend(): boolean {
return get(backendInUseStore) === 'yt' && Capacitor.isNativePlatform();
return get(backendInUseStore) === 'yt' && isUnrestrictedPlatform();
}
export function clearCaches() {
@@ -241,7 +252,77 @@ export function clearCaches() {
}
export function authProtected() {
if (!get(authStore) && !isYTBackend()) {
if (!get(invidiousAuthStore) && !isYTBackend()) {
goto(resolve('/', {}), { replaceState: true });
}
}
export async function setInvidiousInstance(
instanceUrl: string | undefined | null
): Promise<boolean> {
if (typeof instanceUrl !== 'string') {
return false;
}
let invalidInstance = false;
const instance = ensureNoTrailingSlash(instanceUrl);
try {
new URL(instance);
} catch {
invalidInstance = true;
}
if (invalidInstance) return false;
let resp;
try {
resp = await fetch(`${instance}/api/v1/channels/UCH-_hzb2ILSCo9ftVSnrCIQ`);
} catch {
invalidInstance = true;
}
if (invalidInstance) return false;
if (resp && !resp.ok) {
return false;
}
invidiousInstanceStore.set(instance);
invidiousAuthStore.set(null);
return true;
}
export async function goToInvidiousLogin() {
if (!get(invidiousInstanceStore)) return;
const path = new URL(`${get(invidiousInstanceStore)}/authorize_token`);
const searchParams = new URLSearchParams({
scopes: ':feed,:subscriptions*,:playlists*,:history*,:notifications*'
});
if (Capacitor.getPlatform() === 'android') {
searchParams.set('callback_url', 'materialious-auth://');
path.search = searchParams.toString();
await Browser.open({ url: path.toString() });
} else {
searchParams.set('callback_url', `${location.origin}${resolve('/auth', {})}`);
path.search = searchParams.toString();
document.location.href = path.toString();
}
}
export async function logout() {
if (isYTBackend()) {
await clearFeedYTjs();
}
if (isOwnBackend()?.internalAuth) {
fetch('/api/user/logout', { method: 'DELETE' });
rawMasterKeyStore.set(undefined);
} else {
invidiousAuthStore.set(null);
}
goto(resolve('/', {}));
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { isYTBackend } from './misc';
import { authStore } from './store';
import { invidiousAuthStore } from './store';
export type Pages = { icon: string; href: string; name: string; requiresAuth: boolean }[];
@@ -29,7 +29,7 @@ export function getPages(): Pages {
];
pages = pages.filter((page) => {
return !page.requiresAuth || (get(authStore) && !isYTBackend());
return !page.requiresAuth || (get(invidiousAuthStore) && !isYTBackend());
});
return pages;
+2 -2
View File
@@ -1,5 +1,4 @@
import type { VideoPlay } from '$lib/api/model';
import { Capacitor } from '@capacitor/core';
import { SabrStreamingAdapter } from 'googlevideo/sabr-streaming-adapter';
import type shaka from 'shaka-player/dist/shaka-player.ui';
import { ShakaPlayerAdapter } from './ShakaPlayerAdapter';
@@ -7,12 +6,13 @@ import { Constants, YT } from 'youtubei.js';
import { get } from 'svelte/store';
import { poTokenCacheStore } from '$lib/store';
import { buildSabrFormat } from 'googlevideo/utils';
import { isUnrestrictedPlatform } from '$lib/misc';
export async function injectSabr(
video: VideoPlay,
player: shaka.Player
): Promise<SabrStreamingAdapter | null> {
if (!video.ytjs || !Capacitor.isNativePlatform()) return null;
if (!video.ytjs || !isUnrestrictedPlatform()) return null;
const sabrAdapter = new SabrStreamingAdapter({
playerAdapter: new ShakaPlayerAdapter(),
+37
View File
@@ -0,0 +1,37 @@
import { error } from '@sveltejs/kit';
import { verifySolution } from 'altcha-lib';
import { getSequelize } from './database';
export async function verifyCaptcha(payload: string, key: string, maxUses: number = -1) {
const passedCaptcha = await verifySolution(payload, key, true);
if (!passedCaptcha) {
throw error(400, 'Unsupported payload');
}
let captchaSignature: string | undefined;
try {
const decodedCaptcha = JSON.parse(Buffer.from(payload, 'base64').toString());
captchaSignature = decodedCaptcha.signature;
} catch {
// Handle error outside of catch
}
if (typeof captchaSignature === 'undefined') {
throw error(400, 'Unsupported payload');
}
if (maxUses !== -1) {
if (
(await getSequelize().CaptchaTable.count({ where: { signature: captchaSignature } })) >=
maxUses
) {
throw error(400, 'Unsupported payload');
}
await getSequelize().CaptchaTable.create({
signature: captchaSignature,
created: new Date()
});
}
}
+150
View File
@@ -0,0 +1,150 @@
import { Sequelize, DataTypes, Model, type ModelCtor } from 'sequelize';
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 sequelizeInstance: Sequelize | null = null;
// Don't want to make our Sequelize instance always
export function getSequelize(): {
sequelize: Sequelize;
UserTable: ModelCtor<Model<any, any>>;
ChannelSubscriptionTable: ModelCtor<Model<any, any>>;
CaptchaTable: ModelCtor<Model<any, any>>;
} {
if (sequelizeInstance) {
return {
sequelize: sequelizeInstance,
UserTable,
ChannelSubscriptionTable,
CaptchaTable
};
}
if (!env.DATABASE_CONNECTION_URI) {
throw new Error('DATABASE_CONNECTION_URI must be set');
}
sequelizeInstance = new Sequelize(env.DATABASE_CONNECTION_URI);
UserTable = sequelizeInstance.define(
'User',
{
id: {
type: DataTypes.UUIDV4,
allowNull: false,
primaryKey: true,
unique: true
},
username: {
type: DataTypes.STRING,
allowNull: false,
unique: true
},
passwordHash: {
type: DataTypes.STRING,
allowNull: false
},
passwordSalt: {
type: DataTypes.STRING,
allowNull: false
},
created: {
type: DataTypes.DATE,
allowNull: false
},
decryptionKeySalt: {
type: DataTypes.STRING,
allowNull: false
},
masterKeyCipher: {
type: DataTypes.STRING,
allowNull: false
},
masterKeyNonce: {
type: DataTypes.STRING,
allowNull: false
}
},
{
indexes: [
{
unique: true,
fields: ['id', 'username']
}
]
}
);
ChannelSubscriptionTable = sequelizeInstance.define('Subscriptions', {
id: {
type: DataTypes.STRING,
allowNull: false,
primaryKey: true
},
channelIdCipher: {
type: DataTypes.STRING,
allowNull: false
},
channelIdNonce: {
type: DataTypes.STRING,
allowNull: false
},
channelNameCipher: {
type: DataTypes.STRING,
allowNull: false
},
channelNameNonce: {
type: DataTypes.STRING,
allowNull: false
},
lastRSSFetch: {
type: DataTypes.DATE,
allowNull: false
}
});
CaptchaTable = sequelizeInstance.define('Captchas', {
signature: {
type: DataTypes.STRING,
allowNull: false,
primaryKey: true
},
created: {
type: DataTypes.DATE,
allowNull: false
}
});
UserTable.hasMany(ChannelSubscriptionTable);
return {
sequelize: sequelizeInstance,
UserTable,
ChannelSubscriptionTable,
CaptchaTable
};
}
export interface ChannelSubscriptionModel {
id: string; // Hashed authId with subscription key on client.
channelIdCipher: string;
channelIdNonce: string;
channelNameCipher: string;
channelNameNonce: string;
lastRSSFetch: Date;
userId: string;
}
export interface UserTableModel extends Model {
id: string;
username: string;
passwordHash: string;
passwordSalt: string;
created: Date;
decryptionKeySalt: string;
masterKeyCipher: string;
masterKeyNonce: string;
}
+11
View File
@@ -0,0 +1,11 @@
import type { Cookies } from '@sveltejs/kit';
import { sign } from 'cookie-signature';
import { env } from '$env/dynamic/private';
export function setAuthCookie(id: string, cookies: Cookies) {
cookies.set('userid', sign(id, env.COOKIE_SECRET as string), {
httpOnly: true,
path: '/api',
maxAge: 60 * 60 * 24 * 60 // 60 days
});
}
+169
View File
@@ -0,0 +1,169 @@
import { getSequelize, type ChannelSubscriptionModel, type UserTableModel } from './database';
import { Op } from 'sequelize';
import crypto from 'crypto';
import { error } from '@sveltejs/kit';
export class User {
data: UserTableModel;
constructor(data: UserTableModel) {
this.data = data;
}
public get id() {
return this.data.id;
}
public get publicPasswordSalts() {
return {
decryptionKeySalt: this.data.decryptionKeySalt,
passwordSalt: this.data.passwordSalt
};
}
private get userWhere() {
return {
where: {
[Op.or]: [{ id: this.data.id }, { username: this.data.username }]
}
};
}
async delete() {
await getSequelize().UserTable.destroy(this.userWhere);
}
async subscriptionRssUpdated(id: string) {
await getSequelize().ChannelSubscriptionTable.update(
{ lastRSSFetch: new Date() },
{
where: {
id: id,
UserId: this.id
}
}
);
}
async addSubscription(subscription: Omit<ChannelSubscriptionModel, 'userId'>) {
await getSequelize().ChannelSubscriptionTable.create({
...subscription,
UserId: this.id
});
}
async removeSubscription(id: string) {
await getSequelize().ChannelSubscriptionTable.destroy({
where: {
id
}
});
}
async amSubscribed(id: string): Promise<boolean> {
return (
(await getSequelize().ChannelSubscriptionTable.count({
where: {
id
}
})) > 0
);
}
async subscriptions(): Promise<ChannelSubscriptionModel[]> {
const subscriptions = await getSequelize().ChannelSubscriptionTable.findAll({
where: {
userId: this.data.id
}
});
if (!subscriptions) return [];
return subscriptions as unknown as ChannelSubscriptionModel[];
}
}
export type CreateUser = {
username: string;
password: {
hash: string;
salt: string;
};
decryptionKeySalt: string;
masterKey: {
cipher: string;
nonce: string;
};
};
export async function createUser(user: CreateUser): Promise<User> {
const id = crypto.randomUUID().toString();
const createdUser = {
id,
username: user.username,
passwordHash: user.password.hash,
passwordSalt: user.password.salt,
created: new Date(),
decryptionKeySalt: user.decryptionKeySalt,
masterKeyCipher: user.masterKey.cipher,
masterKeyNonce: user.masterKey.nonce
};
let userCreated = false;
try {
await getSequelize().UserTable.create(createdUser);
userCreated = true;
} catch {
userCreated = false;
}
if (!userCreated) {
throw error(400);
}
return new User(createdUser as UserTableModel);
}
export async function getUser(identifier: string): Promise<User> {
const user = await getSequelize().UserTable.findOne({
where: {
[Op.or]: [{ id: identifier }, { username: identifier }]
}
});
if (!user) {
throw error(404);
}
return new User(user as UserTableModel);
}
export async function authenticateUser(username: string, passwordHash: string): Promise<User> {
const user = await getSequelize().UserTable.findOne({
where: {
username
}
});
if (!user) {
throw error(404);
}
const userModel = user as UserTableModel;
const textEncoder = new TextEncoder();
// Password is hashed in the browser, so if db is leaked it doesn't matter.
// Timing safe equal used to stop timing attacks when comparing strings.
if (
crypto.timingSafeEqual(
textEncoder.encode(passwordHash),
textEncoder.encode(userModel.passwordHash)
)
) {
return new User(userModel);
}
throw error(404);
}
+21
View File
@@ -0,0 +1,21 @@
import { env } from '$env/dynamic/public';
export type IsOwnBackend = {
builtWithBackend: boolean;
internalAuth: boolean;
requireAuth: boolean;
registrationAllowed: boolean;
allowAnyProxy: boolean;
};
export function isOwnBackend(): IsOwnBackend | null {
if (env.PUBLIC_BUILD_WITH_BACKEND !== 'true') return null;
return {
builtWithBackend: true,
internalAuth: env.PUBLIC_INTERNAL_AUTH !== 'false',
requireAuth: env.PUBLIC_REQUIRE_AUTH !== 'false',
registrationAllowed: env.PUBLIC_REGISTRATION_ALLOWED === 'true',
allowAnyProxy: env.PUBLIC_DANGEROUS_ALLOW_ANY_PROXY === 'true'
};
}
+31 -13
View File
@@ -3,6 +3,7 @@ import type Peer from 'peerjs';
import type { DataConnection } from 'peerjs';
import { writable, type Writable } from 'svelte/store';
import { Preferences } from '@capacitor/preferences';
import { env } from '$env/dynamic/public';
import {
persist,
createLocalStorage,
@@ -86,14 +87,13 @@ function ifNotWebDefault(givenValue: any, defaultValue: any): any {
}
}
export const instanceStore: Writable<string> = persist(
export const invidiousInstanceStore: Writable<string | undefined> = persist(
writable(
ifNotWebDefault(
!import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE
? undefined
: ensureNoTrailingSlash(import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE),
'https://invidious.materialio.us'
)
!import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE || !env.PUBLIC_DEFAULT_INVIDIOUS_INSTANCE
? undefined
: ensureNoTrailingSlash(
import.meta.env.VITE_DEFAULT_INVIDIOUS_INSTANCE || env.PUBLIC_DEFAULT_INVIDIOUS_INSTANCE
)
),
createStorage(),
'invidiousInstance'
@@ -105,7 +105,7 @@ export const backendInUseStore: Writable<'ivg' | 'yt'> = persist(
'backendInUse'
);
export const authStore: Writable<null | { username: string; token: string }> = persist(
export const invidiousAuthStore: Writable<null | { username: string; token: string }> = persist(
writable(null),
createStorage(),
'authToken'
@@ -193,7 +193,8 @@ export const returnYtDislikesStore = persist(writable(false), createStorage(), '
export const returnYTDislikesInstanceStore: Writable<string | null | undefined> = persist(
writable(
ifNotWebDefault(
import.meta.env.VITE_DEFAULT_RETURNYTDISLIKES_INSTANCE,
import.meta.env.VITE_DEFAULT_RETURNYTDISLIKES_INSTANCE ||
env.PUBLIC_DEFAULT_RETURNYTDISLIKES_INSTANCE,
'https://ryd-proxy.materialio.us'
)
),
@@ -206,7 +207,8 @@ export const synciousInstanceStore: Writable<string | null | undefined> = persis
writable(
ifNotWebDefault(
import.meta.env.VITE_DEFAULT_SYNCIOUS_INSTANCE ||
import.meta.env.VITE_DEFAULT_API_EXTENDED_INSTANCE,
import.meta.env.VITE_DEFAULT_API_EXTENDED_INSTANCE ||
env.PUBLIC_DEFAULT_RETURNYTDISLIKES_INSTANCE,
'https://extended-api.materialio.us'
)
),
@@ -279,7 +281,11 @@ export const interfaceAndroidUseNativeShare = persist(
export const sponsorBlockStore = persist(writable(true), createStorage(), 'sponsorBlock');
export const sponsorBlockUrlStore: Writable<string | null | undefined> = persist(
writable(import.meta.env.VITE_DEFAULT_SPONSERBLOCK_INSTANCE || 'https://sponsor.ajay.app'),
writable(
import.meta.env.VITE_DEFAULT_SPONSERBLOCK_INSTANCE ||
env.PUBLIC_DEFAULT_SPONSERBLOCK_INSTANCE ||
'https://sponsor.ajay.app'
),
createStorage(),
'sponsorBlockUrl'
);
@@ -300,7 +306,11 @@ export const sponsorBlockTimelineStore: Writable<boolean> = persist(
);
export const deArrowInstanceStore = persist(
writable(import.meta.env.VITE_DEFAULT_DEARROW_INSTANCE || 'https://sponsor.ajay.app'),
writable(
import.meta.env.VITE_DEFAULT_DEARROW_INSTANCE ||
env.PUBLIC_DEFAULT_DEARROW_INSTANCE ||
'https://sponsor.ajay.app'
),
createStorage(),
'deArrowInstance'
);
@@ -308,7 +318,9 @@ export const deArrowEnabledStore = persist(writable(false), createStorage(), 'de
export const deArrowTitlesOnly = persist(writable(true), createStorage(), 'deArrowTitlesOnly');
export const deArrowThumbnailInstanceStore = persist(
writable(
import.meta.env.VITE_DEFAULT_DEARROW_THUMBNAIL_INSTANCE || 'https://dearrow-thumb.ajay.app'
import.meta.env.VITE_DEFAULT_DEARROW_THUMBNAIL_INSTANCE ||
env.PUBLIC_DEFAULT_DEARROW_THUMBNAIL_INSTANCE ||
'https://dearrow-thumb.ajay.app'
),
createStorage(),
'deArrowThumbnailInstance'
@@ -336,6 +348,12 @@ export const engineFallbacksStore: Writable<EngineFallback[]> = persist(
'engineFallbacks'
);
export const rawMasterKeyStore: Writable<string | undefined> = persist(
writable(),
createStorage(),
'rawMasterKey'
);
export const syncPartyPeerStore: Writable<Peer | null> = writable(null);
export const syncPartyConnectionsStore: Writable<DataConnection[] | null> = writable();
+2 -2
View File
@@ -7,7 +7,7 @@ import {
} from '$lib/api/index';
import { loadEntirePlaylist } from '$lib/playlist';
import {
authStore,
invidiousAuthStore,
playerProxyVideosStore,
playerState,
returnYTDislikesInstanceStore,
@@ -38,7 +38,7 @@ export async function getWatchDetails(videoId: string, url: URL) {
}
let personalPlaylists;
if (get(authStore)) {
if (get(invidiousAuthStore)) {
postHistory(video.videoId);
personalPlaylists = getPersonalPlaylists({ priority: 'low' });
} else {
@@ -0,0 +1,29 @@
import type { IGetChallengeResponse } from 'youtubei.js';
export async function webPoTokenMinter(
requestKey: string,
visitorData: string,
challenge: IGetChallengeResponse
): Promise<string> {
const resp = await fetch('/api/poToken/', {
body: JSON.stringify({
requestKey,
visitorData,
challenge
}),
method: 'POST'
});
if (!resp.ok) {
let errorMsg = 'An error occurred';
try {
errorMsg = (await resp.json()).message;
} catch {
// Ignore error
}
throw new Error(errorMsg);
}
return await resp.text();
}
+46 -44
View File
@@ -17,14 +17,15 @@
import { bookmarkletLoadFromUrl, loadSettingsFromEnv } from '$lib/externalSettings';
import { getPages } from '$lib/navPages';
import {
authStore,
invidiousAuthStore,
darkModeStore,
instanceStore,
invidiousInstanceStore,
interfaceAmoledTheme,
interfaceDefaultPage,
isAndroidTvStore,
playerState,
playertheatreModeIsActive,
rawMasterKeyStore,
syncPartyPeerStore,
themeColorStore
} from '$lib/store';
@@ -39,23 +40,31 @@
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { pwaInfo } from 'virtual:pwa-info';
import { isYTBackend, clearCaches, truncate } from '$lib/misc';
import { goToInvidiousLogin, isYTBackend, logout, truncate } from '$lib/misc';
import Author from '$lib/components/Author.svelte';
import Toast from '$lib/components/Toast.svelte';
import { isOwnBackend } from '$lib/shared';
let { children } = $props();
let mobileSearchShow = $state(false);
const showLogin = !isYTBackend() || isOwnBackend()?.internalAuth;
let isLoggedIn = $state(false);
authStore.subscribe((value) => {
isLoggedIn = value !== null;
});
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();
});
page.subscribe((pageData) => {
playerIsPip = !pageData.url.pathname.includes('/watch');
requestAnimationFrame(() => resetScroll());
@@ -103,7 +112,7 @@
const token = url.searchParams.get('token');
if (username && token) {
authStore.set({
invidiousAuthStore.set({
username: username,
token: token
});
@@ -112,22 +121,13 @@
});
async function login() {
if (isOwnBackend()?.internalAuth) {
goto(resolve('/internal/login', {}));
return;
}
if (!$isAndroidTvStore) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const path = new URL(`${get(instanceStore)}/authorize_token`);
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const searchParams = new URLSearchParams({
scopes: ':feed,:subscriptions*,:playlists*,:history*,:notifications*'
});
if (Capacitor.getPlatform() === 'android') {
searchParams.set('callback_url', 'materialious-auth://');
path.search = searchParams.toString();
await Browser.open({ url: path.toString() });
} else {
searchParams.set('callback_url', `${location.origin}${resolve('/auth', {})}`);
path.search = searchParams.toString();
document.location.href = path.toString();
}
await goToInvidiousLogin();
} else {
await ui('#tv-login');
document.getElementById('username')?.focus();
@@ -149,7 +149,7 @@
body.append('password', rawPassword);
body.append('action', 'signin');
const response = await fetch(`${$instanceStore}/login?type=invidious`, {
const response = await fetch(`${$invidiousInstanceStore}/login?type=invidious`, {
method: 'POST',
body: body,
headers: {
@@ -167,7 +167,7 @@
if (sid) {
console.log(sid);
authStore.set({ username: rawUsername, token: sid });
invidiousAuthStore.set({ username: rawUsername, token: sid });
await ui('#tv-login');
goto(resolve('/', {}), { replaceState: true });
return;
@@ -178,12 +178,6 @@
loginError = true;
}
function logout() {
authStore.set(null);
clearCaches();
goto(resolve('/', {}));
}
async function loadNotifications() {
const feed = await getFeed(100, 1);
notifications = feed.notifications;
@@ -237,8 +231,16 @@
setTheme();
setAmoledTheme();
if (isLoggedIn && !isYTBackend()) {
loadNotifications().catch(() => authStore.set(null));
if ($invidiousAuthStore && !isYTBackend()) {
loadNotifications().catch(() => logout());
}
if ($rawMasterKeyStore) {
fetch('/api/user/isLoggedIn', { method: 'GET', credentials: 'same-origin' })
.then((resp) => {
if (!resp.ok) logout();
})
.catch(logout);
}
resetScroll();
@@ -259,7 +261,7 @@
class:hide={$playertheatreModeIsActive}
>
<header role="presentation" style="cursor: pointer;" tabindex="-1" class="small-padding">
<a href={resolve($interfaceDefaultPage, {})}>
<a href={resolve($interfaceDefaultPage, {})} data-sveltekit-preload-data="off">
<Logo />
</a>
</header>
@@ -269,7 +271,7 @@
<div>{$_('searchPlaceholder')}</div>
</a>
{/if}
{#each getPages() as navPage (navPage)}
{#each pages as navPage (navPage)}
<a href={resolve(navPage.href, {})} class:active={$page.url.href.endsWith(navPage.href)}
><i>{navPage.icon}</i>
<div>{navPage.name}</div>
@@ -281,8 +283,8 @@
<i>settings</i>
<div>{$_('layout.settings')}</div>
</a>
{#if !isYTBackend()}
{#if !isLoggedIn}
{#if showLogin}
{#if (!$invidiousAuthStore && !isOwnBackend()?.internalAuth) || !$rawMasterKeyStore}
<a onclick={login} href="#login">
<i>login</i>
<div>{$_('layout.login')}</div>
@@ -344,7 +346,7 @@
<i class:primary-text={$syncPartyPeerStore}>group</i>
<div class="tooltip bottom">{$_('layout.syncParty')}</div>
</button>
{#if isLoggedIn && !isYTBackend()}
{#if $invidiousAuthStore && !isYTBackend()}
<button
class="circle large transparent"
onclick={() => {
@@ -367,8 +369,8 @@
<div class="tooltip bottom">{$_('layout.settings')}</div>
</button>
{#if !isYTBackend()}
{#if !isLoggedIn}
{#if showLogin}
{#if (!$invidiousAuthStore && !isOwnBackend()?.internalAuth) || (!$rawMasterKeyStore && isOwnBackend()?.internalAuth)}
<button onclick={login} class="circle large transparent">
<i>login</i>
<div class="tooltip bottom">{$_('layout.login')}</div>
@@ -385,7 +387,7 @@
{/if}
<nav class="bottom s">
{#each getPages() as navPage (navPage)}
{#each pages as navPage (navPage)}
<a
class="round"
href={resolve(navPage.href, {})}
@@ -486,11 +488,11 @@
<form onsubmit={usernamePasswordLogin}>
<div class="field label border" class:invalid={loginError}>
<input id="username" bind:value={rawUsername} name="username" type="text" />
<label for="username">Username</label>
<label for="username">{$_('username')}</label>
</div>
<div class="field label border" class:invalid={loginError}>
<input bind:value={rawPassword} name="password" type="password" />
<label for="password">Password</label>
<label for="password">{$_('password')}</label>
</div>
<nav class="right-align no-space">
@@ -3,7 +3,7 @@
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import PageLoading from '$lib/components/PageLoading.svelte';
import { authStore } from '$lib/store';
import { invidiousAuthStore } from '$lib/store';
import { onMount } from 'svelte';
// Auth response handling for Desktop
@@ -12,7 +12,7 @@
const token = $page.url.searchParams.get('token');
if (username && token) {
authStore.set({
invidiousAuthStore.set({
username: username,
token: token
});
@@ -1,7 +1,7 @@
import { getChannel, getChannelContent } from '$lib/api/index';
import type { ChannelContentVideos, Video } from '$lib/api/model';
import { excludeDuplicateFeeds } from '$lib/misc.js';
import { channelCacheStore } from '$lib/store.js';
import { excludeDuplicateFeeds } from '$lib/misc';
import { channelCacheStore } from '$lib/store';
import { error } from '@sveltejs/kit';
import { get } from 'svelte/store';
@@ -0,0 +1,137 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { createUserBackend, loginUserBackend, type DerivePassword } from '$lib/api/backend';
import PageLoading from '$lib/components/PageLoading.svelte';
import { _ } from '$lib/i18n';
import { isOwnBackend } from '$lib/shared';
import 'altcha';
import * as comlink from 'comlink';
import { onMount } from 'svelte';
let needToRegister = $state(false);
let username = $state('');
let rawPassword = $state('');
let captchaPayload = $state('');
let worker: Worker | undefined;
let derivePassword: DerivePassword;
let isLoading = $state(false);
onMount(() => {
worker = new Worker(new URL('./workers/derivePassword.ts', import.meta.url), {
type: 'module'
});
const workerApi = comlink.wrap(worker);
derivePassword = (workerApi as any).derivePassword as DerivePassword;
});
let failed = $state(false);
async function onLogin(event: Event) {
event.preventDefault();
isLoading = true;
if (needToRegister) {
failed = !(await createUserBackend(username, rawPassword, captchaPayload, derivePassword));
} else {
failed = !(await loginUserBackend(username, rawPassword, captchaPayload, derivePassword));
}
isLoading = false;
if (!failed) {
goto(resolve('/', {}), { replaceState: true });
}
}
</script>
{#if isLoading}
<PageLoading />
{:else}
<nav class="center-align">
<article class="padding left-align">
<h3>{$_(needToRegister ? 'createAccount' : 'login')}</h3>
<p class="no-margin">{$_(needToRegister ? 'materialiousCreate' : 'materialiousLogin')}</p>
<form onsubmit={onLogin}>
<div
class="field label prefix surface-container-highest"
class:invalid={failed && needToRegister}
>
<i>person</i>
<input bind:value={username} name="username" type="text" />
<label for="username">{$_('username')}</label>
{#if failed && needToRegister}
<output class="invalid">{$_('usernameTaken')}</output>
{/if}
</div>
<div
class="field label prefix surface-container-highest"
class:invalid={failed && !needToRegister}
>
<i>password</i>
<input bind:value={rawPassword} name="password" type="password" />
<label for="password">{$_('password')}</label>
{#if failed && !needToRegister}
<output class="invalid">{$_('invalidPassword')}</output>
{/if}
</div>
<article
class="surface-container-highest no-padding"
style="width: 100%;height: fit-content;"
>
<altcha-widget
challengeurl="/api/captcha"
hidelogo
hidefooter
onstatechange={(ev) => {
const { payload, state } = ev.detail;
if (state === 'verified' && payload) {
captchaPayload = payload;
}
}}
></altcha-widget>
</article>
<nav class="right-align">
<button
type="button"
class="secondary"
disabled={!isOwnBackend()?.registrationAllowed}
onclick={() => {
needToRegister = !needToRegister;
failed = false;
}}
>
{#if !isOwnBackend()?.registrationAllowed}
<div class="tooltip bottom">{$_('registrationDisabled')}</div>
{/if}
<span>{$_(!needToRegister ? 'needRegister' : 'needLogin')}</span>
</button>
<button type="submit">
<i>done</i>
<span>{$_(needToRegister ? 'createAccount' : 'login')}</span>
</button>
</nav>
</form>
</article>
</nav>
{/if}
<style>
article {
width: 400px;
}
@media screen and (max-width: 400px) {
article {
width: 100%;
}
}
</style>
@@ -0,0 +1,9 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { isOwnBackend } from '$lib/shared';
export async function load() {
if (!isOwnBackend()?.internalAuth) {
goto(resolve('/', {}), { replaceState: true });
}
}
@@ -0,0 +1,17 @@
import * as comlink from 'comlink';
import sodium from 'libsodium-wrappers-sumo';
async function derivePassword(rawPassword: string, passwordSalt: Uint8Array): Promise<Uint8Array> {
await sodium.ready;
return sodium.crypto_pwhash(
32,
rawPassword,
passwordSalt,
sodium.crypto_pwhash_OPSLIMIT_SENSITIVE,
sodium.crypto_pwhash_MEMLIMIT_SENSITIVE,
sodium.crypto_pwhash_ALG_DEFAULT
);
}
comlink.expose({ derivePassword });
@@ -5,7 +5,7 @@
import { _ } from '$lib/i18n';
import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading';
import ItemsList from '$lib/components/ItemsList.svelte';
import type { SearchOptions, SearchResults } from '$lib/api/model.js';
import type { SearchOptions, SearchResults } from '$lib/api/model';
let { data } = $props();
@@ -0,0 +1,146 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Question from '$lib/components/Question.svelte';
import { _ } from '$lib/i18n';
import { clearCaches, isUnrestrictedPlatform, setInvidiousInstance } from '$lib/misc';
import { isOwnBackend } from '$lib/shared';
import { backendInUseStore, invidiousInstanceStore, playerYouTubeJsFallback } from '$lib/store';
const defaultInstance = !isOwnBackend()
? 'https://invidious.materialio.us'
: $invidiousInstanceStore;
let usingInvidious: boolean = $state(false);
let invidiousInstanceValid: boolean = $state(true);
let invidiousInstance: string = $state(defaultInstance ?? '');
async function setupCompleted() {
invidiousInstanceValid = await setInvidiousInstance(invidiousInstance);
if (!invidiousInstanceValid) {
return;
}
clearCaches();
goto(resolve('/', {}), { replaceState: true });
}
function setYTBackend() {
usingInvidious = false;
backendInUseStore.set('yt');
setupCompleted();
}
</script>
<nav class="center-align">
<div class="setup">
{#if !isUnrestrictedPlatform()}
<p>
<code>VITE_DEFAULT_INVIDIOUS_INSTANCE</code> has not been provided.
</p>
<p>
Please read our <a
href="https://github.com/Materialious/Materialious/blob/main/docs/DOCKER.md"
referrerpolicy="no-referrer"
class="link"
>
Docker guide
</a>
for configuring Materialious as a Invidious frontend.
</p>
<p>
You will be required to reverse proxy Invidious & configure CORS too for Materialious to
work.
</p>
{:else}
<h3 class="center-align">{$_('initalSetup.required')}</h3>
<div class="space"></div>
<div class="divider"></div>
<div class="space"></div>
<Question
question={$_('initalSetup.useInvidious')}
answers={[
{
text: $_('initalSetup.yes'),
action: () => {
usingInvidious = true;
}
},
{
text: $_('initalSetup.no'),
action: setYTBackend
},
{
text: $_('initalSetup.unsure'),
action: setYTBackend
}
]}
info={$_('initalSetup.invidiousInfo')}
/>
{#if usingInvidious}
<div class="space"></div>
<Question
question={$_('initalSetup.useLocalFallback')}
answers={[
{
text: $_('initalSetup.yes'),
action: () => {
playerYouTubeJsFallback.set(true);
}
},
{
text: $_('initalSetup.no'),
action: () => {
playerYouTubeJsFallback.set(false);
}
},
{
text: $_('initalSetup.unsure'),
action: () => {
playerYouTubeJsFallback.set(true);
}
}
]}
info={$_('initalSetup.localFallbackInfo')}
/>
<div class="space"></div>
<h3>{$_('initalSetup.configureInstance')}</h3>
<div
class="field label prefix surface-container-highest"
class:invalid={!invidiousInstanceValid && invidiousInstance !== ''}
>
<i>link</i>
<input bind:value={invidiousInstance} name="instanceUrl" type="text" tabindex="0" />
<label for="instanceUrl" tabindex="-1">{$_('layout.instanceUrl')}</label>
{#if !invidiousInstanceValid && invidiousInstance !== ''}
<span class="error">{$_('invalidInstance')}</span>
{/if}
</div>
<div class="space"></div>
<button onclick={setupCompleted}>
<i>done_all</i>
<span>{$_('initalSetup.done')}</span>
</button>
{/if}
{/if}
</div>
</nav>
<style>
.setup {
width: 600px;
}
@media screen and (max-width: 600px) {
.setup {
width: 100%;
}
}
</style>
@@ -0,0 +1,11 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { isYTBackend } from '$lib/misc';
import { invidiousInstanceStore } from '$lib/store';
import { get } from 'svelte/store';
export async function load() {
if (isYTBackend() || get(invidiousInstanceStore)) {
goto(resolve('/', {}), { replaceState: true });
}
}
@@ -15,7 +15,7 @@
import { cleanNumber, numberWithCommas } from '$lib/numbers';
import { goToNextVideo, goToPreviousVideo, type PlayerEvents } from '$lib/player';
import {
authStore,
invidiousAuthStore,
interfaceAutoExpandChapters,
interfaceAutoExpandComments,
playerMiniplayerEnabled,
@@ -39,8 +39,8 @@
import LikesDislikes from '$lib/components/watch/LikesDislikes.svelte';
import Comment from '$lib/components/watch/Comment.svelte';
import { expandSummery, isYTBackend } from '$lib/misc';
import { humanizeSeconds, relativeTimestamp } from '$lib/time.js';
import { getWatchDetails } from '$lib/watch.js';
import { humanizeSeconds, relativeTimestamp } from '$lib/time';
import { getWatchDetails } from '$lib/watch';
import { page } from '$app/state';
let { data = $bindable() } = $props();
@@ -490,7 +490,7 @@
<button disabled class="surface-container-highest">
<i>add</i>
<div class="tooltip">
{#if $authStore || isYTBackend()}
{#if $invidiousAuthStore || isYTBackend()}
{$_('player.noPlaylists')}
{:else}
{$_('loginRequired')}
+1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { isAndroidTvStore } from '$lib/store';
import { App } from '@capacitor/app';
import '$lib/fetchProxy';
let { children } = $props();
+25 -10
View File
@@ -7,19 +7,22 @@ import '$lib/i18n'; // Import to initialize. Important :)
import { initI18n } from '$lib/i18n';
import { getPages } from '$lib/navPages';
import {
authStore,
invidiousAuthStore,
backendInUseStore,
instanceStore,
invidiousInstanceStore,
interfaceDefaultPage,
isAndroidTvStore
isAndroidTvStore,
rawMasterKeyStore
} from '$lib/store';
import { get, type Writable } from 'svelte/store';
import '$lib/android/http/androidRequests';
import { Capacitor } from '@capacitor/core';
import { Preferences } from '@capacitor/preferences';
import { deserialize } from '@macfja/serializer';
import { isYTBackend } from '$lib/misc';
import { isOwnBackend } from '$lib/shared/index';
export const ssr = false;
export const prerender = false;
export async function load({ url }) {
if (browser) {
@@ -28,13 +31,14 @@ export async function load({ url }) {
isAndroidTvStore.set((await androidTv.isAndroidTv()).value);
// Due to race condition with how we set & save persistent store values
// we manually set stores like auth & instance before load.
if (Capacitor.getPlatform() === 'android') {
// Due to race condition with how we set & save persistent store values
// we manually set stores like auth & instance before load.
const preferenceKey: Record<string, Writable<any>> = {
invidiousInstance: instanceStore,
authToken: authStore,
backendInUse: backendInUseStore
invidiousInstance: invidiousInstanceStore,
authToken: invidiousAuthStore,
backendInUse: backendInUseStore,
rawMasterKey: rawMasterKeyStore
};
for (const [key, store] of Object.entries(preferenceKey)) {
@@ -67,9 +71,20 @@ export async function load({ url }) {
window.history.length < 3
) {
getPages().forEach((page) => {
if (page.href === defaultPage && (!page.requiresAuth || get(authStore))) {
if (page.href === defaultPage && (!page.requiresAuth || get(invidiousAuthStore))) {
goto(resolve(defaultPage, {}));
}
});
}
const isLoginPage = url.pathname.endsWith('/internal/login');
const isSetupPage = url.pathname.endsWith('/setup');
if (!isLoginPage) {
if (isOwnBackend()?.requireAuth && !get(rawMasterKeyStore)) {
goto(resolve('/internal/login', {}), { replaceState: true });
} else if (!get(invidiousInstanceStore) && !isYTBackend() && !isSetupPage) {
goto(resolve('/setup', {}), { replaceState: true });
}
}
}
@@ -1 +1,2 @@
declare module 'virtual:pwa-info';
declare module 'psl';
@@ -0,0 +1,13 @@
import { json } from '@sveltejs/kit';
import { createChallenge } from 'altcha-lib';
export async function GET({ locals }) {
const currentDate = new Date();
return json(
await createChallenge({
hmacKey: locals.captchaKey,
expires: new Date(currentDate.getTime() + 1 * 60 * 60 * 1000)
})
);
}
@@ -0,0 +1,117 @@
import { JSDOM } from 'jsdom';
import type { IGetChallengeResponse } from 'youtubei.js';
import BG, { buildURL, GOOG_API_KEY, USER_AGENT, type WebPoSignalOutput } from 'bgutils-js';
import { error } from '@sveltejs/kit';
import z from 'zod';
import { isOwnBackend } from '$lib/shared/index';
const zPoTokenGenSchema = z.object({
requestKey: z.string(),
visitorData: z.string(),
challenge: z.record(z.any(), z.any())
});
export async function POST({ request, locals }) {
if (isOwnBackend()?.requireAuth && !locals.userId) {
throw error(401);
}
const data = zPoTokenGenSchema.safeParse(await request.json());
if (!data.success) {
throw error(400, data.error.message);
}
const challenge = data.data.challenge as IGetChallengeResponse;
const requestKey = data.data.requestKey;
const visitorData = data.data.visitorData;
const youtubeUrl = 'https://www.youtube.com/';
const dom = new JSDOM(
'<!DOCTYPE html><html lang="en"><head><title>YouTube</title></head><body></body></html>',
{
url: youtubeUrl,
referrer: youtubeUrl,
userAgent: USER_AGENT
}
);
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
location: dom.window.location,
origin: dom.window.origin
});
if (!Reflect.has(globalThis, 'navigator')) {
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator });
}
if (!challenge.bg_challenge) {
throw error(400, 'BotGuard challenge not provided');
}
const interpreterUrl =
challenge.bg_challenge.interpreter_url
.private_do_not_access_or_else_trusted_resource_url_wrapped_value;
const interpreterHash = challenge.bg_challenge.interpreter_hash;
if (!interpreterUrl || !interpreterHash) {
throw error(
500,
`Could not get integrity token. Interpreter Hash: ${challenge.bg_challenge?.interpreter_hash}`
);
}
const interpreterResponse = await fetch(`https:${interpreterUrl}`, {
headers: {
'user-agent': USER_AGENT
}
});
if (!interpreterResponse.ok) {
throw new Error('Unable to fetch interpreter');
}
const interpreterJavascript = await interpreterResponse.text();
if (interpreterJavascript) {
new Function(interpreterJavascript)();
} else throw error(500, 'Could not load VM');
const botguardClient = await BG.BotGuardClient.create({
program: challenge.bg_challenge.program,
globalName: challenge.bg_challenge.global_name,
globalObj: globalThis
});
const webPoSignalOutput: WebPoSignalOutput = [];
const botguardResponse = await botguardClient.snapshot({ webPoSignalOutput });
const integrityTokenResponse = await fetch(buildURL('GenerateIT', true), {
method: 'POST',
headers: {
'content-type': 'application/json+protobuf',
'x-goog-api-key': GOOG_API_KEY,
'x-user-agent': 'grpc-web-javacript/0.1'
},
body: JSON.stringify([requestKey, botguardResponse])
});
const integrityTokenResponseData = await integrityTokenResponse.json();
const integrityToken = integrityTokenResponseData[0] as string | undefined;
if (!integrityToken) {
throw error(500, `Could not get integrity token. Interpreter Hash: ${interpreterHash}`);
}
const integrityTokenBasedMinter = await BG.WebPoMinter.create(
{ integrityToken },
webPoSignalOutput
);
return new Response(
await integrityTokenBasedMinter.mintAsWebsafeString(decodeURIComponent(visitorData)),
{ status: 200 }
);
}
@@ -0,0 +1,133 @@
import { isOwnBackend } from '$lib/shared';
import psl from 'psl';
import { env } from '$env/dynamic/public';
import { error } from '@sveltejs/kit';
const allowedDomains: string[] = [
'youtube.com',
'ytimg.com',
'googlevideo.com',
'returnyoutubedislike.com',
'sponsor.ajay.app',
'dearrow-thumb.ajay.app',
'materialio.us'
];
const dynamicAllowDomains = [
env.PUBLIC_DEFAULT_DEARROW_THUMBNAIL_INSTANCE,
env.PUBLIC_DEFAULT_DEARROW_INSTANCE,
env.PUBLIC_DEFAULT_INVIDIOUS_INSTANCE,
env.PUBLIC_DEFAULT_RETURNYTDISLIKES_INSTANCE,
env.PUBLIC_DEFAULT_API_EXTENDED_INSTANCE,
env.PUBLIC_DEFAULT_SYNCIOUS_INSTANCE,
env.PUBLIC_DEFAULT_COMPANION_INSTANCE
];
dynamicAllowDomains.forEach((domain) => {
if (domain) {
allowedDomains.push(domain);
}
});
async function proxyRequest(
request: Request,
urlToProxy: string,
captchaKey: string,
userId: string | undefined = undefined
): Promise<Response> {
const backendRestrictions = isOwnBackend();
if (!backendRestrictions) {
// Shouldn't be possible.
throw error(400, 'How did you get here?');
}
if (backendRestrictions.requireAuth && !userId) {
throw error(401, 'Auth required');
}
let urlToProxyObj: URL;
try {
urlToProxyObj = new URL(decodeURIComponent(urlToProxy));
} catch {
throw error(400, 'Invalid URL');
}
if (!allowedDomains.includes(psl.parse(urlToProxyObj.host).domain)) {
// allowAnyProxy allows a instance owner to bypass the whitelist.
// BUT is extremely strict.
// AND I still don't recommend this.
if (
!backendRestrictions.allowAnyProxy ||
!backendRestrictions.requireAuth ||
backendRestrictions.registrationAllowed ||
!userId
) {
throw error(400, 'Invalid URL');
}
}
if (urlToProxyObj.pathname.includes('v1/player')) {
urlToProxyObj.searchParams.set(
'$fields',
'playerConfig,captions,playabilityStatus,streamingData,responseContext.mainAppWebResponseContext.datasyncId,videoDetails.isLive,videoDetails.isLiveContent,videoDetails.title,videoDetails.author,playbackTracking'
);
}
const requestHeaders = request.headers;
requestHeaders.set('host', urlToProxyObj.host);
requestHeaders.set('origin', urlToProxyObj.origin);
for (const key of [
'referer',
'x-forwarded-for',
'x-requested-with',
'sec-ch-ua-mobile',
'sec-ch-ua',
'sec-ch-ua-platform'
]) {
requestHeaders.delete(key);
}
const cookieHeader = requestHeaders.get('cookie');
if (cookieHeader) {
const modifiedCookies = cookieHeader
.split(';')
.filter((cookie) => !cookie.trim().startsWith('userid='))
.join('; ');
requestHeaders.set('cookie', modifiedCookies);
}
const fetchRes = await fetch(urlToProxy.toString(), {
method: request.method,
headers: requestHeaders,
body: request.body,
credentials: 'same-origin',
...(request.body ? { duplex: 'half' } : {})
});
return new Response(fetchRes.body, {
status: fetchRes.status,
statusText: fetchRes.statusText,
...(request.body ? { duplex: 'half' } : {})
});
}
export async function GET({ request, params, locals }) {
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
}
export async function PATCH({ request, params, locals }) {
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
}
export async function DELETE({ request, params, locals }) {
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
}
export async function PUT({ request, params, locals }) {
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
}
export async function POST({ request, params, locals }) {
return await proxyRequest(request, params.urlToProxy, locals.captchaKey, locals.userId);
}
@@ -0,0 +1,12 @@
import { getUser } from '$lib/server/user';
import { json } from '@sveltejs/kit';
import { isOwnBackend } from '$lib/shared';
export async function GET({ params }) {
if (!isOwnBackend()?.internalAuth) {
return new Response('', { status: 500 });
}
const user = await getUser(params.userId);
return json(user.publicPasswordSalts);
}
@@ -0,0 +1,49 @@
import { isOwnBackend } from '$lib/shared';
import { createUser } from '$lib/server/user';
import { error } from '@sveltejs/kit';
import z from 'zod';
import { setAuthCookie } from '$lib/server/misc';
import { verifyCaptcha } from '$lib/server/captcha';
const zUserCreate = z.object({
username: z.string().min(3).max(18),
password: z.object({
hash: z.string().max(255),
salt: z.string().max(255)
}),
decryptionKeySalt: z.string().max(255),
masterKey: z.object({
cipher: z.string().max(255),
nonce: z.string().max(255)
}),
captchaPayload: z.string()
});
export async function POST({ request, cookies, locals }) {
if (!isOwnBackend()?.internalAuth || !isOwnBackend()?.registrationAllowed) {
throw error(500);
}
const userToCreate = zUserCreate.safeParse(await request.json());
if (!userToCreate.success) throw error(400);
await verifyCaptcha(userToCreate.data.captchaPayload, locals.captchaKey, 1);
const createdUser = await createUser({
username: userToCreate.data.username,
password: {
hash: userToCreate.data.password.hash,
salt: userToCreate.data.password.salt
},
decryptionKeySalt: userToCreate.data.decryptionKeySalt,
masterKey: {
cipher: userToCreate.data.masterKey.cipher,
nonce: userToCreate.data.masterKey.nonce
}
});
setAuthCookie(createdUser.id, cookies);
return new Response('');
}
@@ -0,0 +1,8 @@
import { getUser } from '$lib/server/user';
export async function DELETE({ locals }) {
const user = await getUser(locals.userId);
await user.delete();
return new Response();
}
@@ -0,0 +1,12 @@
import { getUser } from '$lib/server/user';
import { error } from '@sveltejs/kit';
export async function GET({ locals }) {
if (!locals.userId) {
throw error(401);
}
await getUser(locals.userId);
return new Response();
}
@@ -0,0 +1,33 @@
import { authenticateUser } from '$lib/server/user';
import { error, json } from '@sveltejs/kit';
import z from 'zod';
import { isOwnBackend } from '$lib/shared';
import { setAuthCookie } from '$lib/server/misc';
import { verifyCaptcha } from '$lib/server/captcha';
const zUserLogin = z.object({
username: z.string(),
passwordHash: z.string(),
captchaPayload: z.string()
});
export async function POST({ request, cookies, locals }) {
if (!isOwnBackend()?.internalAuth) {
throw error(500);
}
const userLogin = zUserLogin.safeParse(await request.json());
if (!userLogin.success) throw error(401);
await verifyCaptcha(userLogin.data.captchaPayload, locals.captchaKey, 1);
const userModel = await authenticateUser(userLogin.data.username, userLogin.data.passwordHash);
setAuthCookie(userModel.id, cookies);
return json({
masterKeyCipher: userModel.data.masterKeyCipher,
masterKeyNonce: userModel.data.masterKeyNonce
});
}
@@ -0,0 +1,5 @@
export function DELETE({ cookies }) {
cookies.delete('userid', { path: '/' });
return new Response();
}
@@ -0,0 +1,10 @@
import { getUser } from '$lib/server/user';
import { json } from '@sveltejs/kit';
export async function GET({ locals }) {
const user = await getUser(locals.userId);
return json({
subscriptions: await user.subscriptions()
});
}
@@ -0,0 +1,47 @@
import { getUser } from '$lib/server/user';
import { error, json } from '@sveltejs/kit';
import z from 'zod';
const zSubscriptionCreate = z.object({
channelIdCipher: z.string().max(255),
channelIdNonce: z.string().max(255),
channelNameCipher: z.string().max(255),
channelNameNonce: z.string().max(255)
});
export async function POST({ locals, request, params }) {
const subscription = zSubscriptionCreate.safeParse(await request.json());
if (!subscription.success) error(400);
const user = await getUser(locals.userId);
await user.addSubscription({
...subscription.data,
id: params.id,
lastRSSFetch: new Date(0)
});
return new Response();
}
export async function PATCH({ locals, params }) {
const user = await getUser(locals.userId);
await user.subscriptionRssUpdated(params.id);
return new Response();
}
export async function DELETE({ locals, params }) {
const user = await getUser(locals.userId);
await user.removeSubscription(params.id);
return new Response();
}
export async function GET({ locals, params }) {
const user = await getUser(locals.userId);
return json({
amSubscribed: await user.amSubscribed(params.id)
});
}
File diff suppressed because one or more lines are too long
+13 -13
View File
@@ -1,22 +1,22 @@
import adapter from '@sveltejs/adapter-static';
import adapterStatic from '@sveltejs/adapter-static';
import adapterNode from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const useSsr = process.env.PUBLIC_BUILD_WITH_BACKEND === 'true';
const adapter = useSsr
? adapterNode({ out: 'build', precompress: true })
: adapterStatic({
fallback: 'index.html'
});
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// Optional: Set base path for hosted Materialious here
//paths: {base: '/materialious'},
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({
fallback: 'index.html'
})
},
adapter
}
};
export default config;
+4 -13
View File
@@ -10,21 +10,12 @@
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"types": [
"shaka-player"
]
"types": ["shaka-player"]
},
"include": [
"src/**/*.d.ts",
"src/**/*.ts",
"src/**/*.svelte"
],
"exclude": [
"node_modules",
"dist"
]
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.svelte", ".svelte-kit/ambient.d.ts"],
"exclude": ["node_modules", "dist"]
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import os
import re
from datetime import datetime
LATEST_VERSION = "1.14.4"
LATEST_VERSION = "1.15.0"
RELEASE_DATE = datetime.now().strftime("%Y-%-m-%d") # Format: YYYY-M-D
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")