diff --git a/materialious/src/lib/api/backend.ts b/materialious/src/lib/api/backend.ts index 9f25ab1a..c18b82f1 100644 --- a/materialious/src/lib/api/backend.ts +++ b/materialious/src/lib/api/backend.ts @@ -4,11 +4,14 @@ import { get } from 'svelte/store'; import { parseChannelRSS } from './youtubejs/subscriptions'; import { getChannelYTjs } from './youtubejs/channel'; import type { ChannelSubscriptions } from '$lib/dexie'; +import type { VideoPlay } from './model'; +import { vi } from 'zod/v4/locales'; +import { getBestThumbnail } from '$lib/images'; -async function getInternalAuthorId(authorId: string, rawKey: Uint8Array): Promise { +async function getSecureHash(toHash: string, rawKey: Uint8Array): Promise { await sodium.ready; return sodium.to_base64( - sodium.crypto_generichash(sodium.crypto_generichash_BYTES, authorId, rawKey) + sodium.crypto_generichash(sodium.crypto_generichash_BYTES, toHash, rawKey) ); } @@ -48,7 +51,7 @@ export async function updateRSSLastUpdated(authorId: string) { const rawKey = await getRawKey(); if (!rawKey) return false; - const internalAuthorId = await getInternalAuthorId(authorId, rawKey); + const internalAuthorId = await getSecureHash(authorId, rawKey); await fetch(`/api/user/subscriptions/${internalAuthorId}`, { method: 'PATCH', @@ -60,7 +63,7 @@ export async function amSubscribedBackend(authorId: string): Promise { const rawKey = await getRawKey(); if (!rawKey) return false; - const internalAuthorId = await getInternalAuthorId(authorId, rawKey); + const internalAuthorId = await getSecureHash(authorId, rawKey); const resp = await fetch(`/api/user/subscriptions/${internalAuthorId}`, { method: 'GET', @@ -77,7 +80,7 @@ export async function deleteUnsubscribeBackend(authorId: string) { const rawKey = await getRawKey(); if (!rawKey) return false; - const internalAuthorId = await getInternalAuthorId(authorId, rawKey); + const internalAuthorId = await getSecureHash(authorId, rawKey); await fetch(`/api/user/subscriptions/${internalAuthorId}`, { method: 'DELETE', @@ -92,7 +95,7 @@ export async function postSubscribeBackend( const rawKey = await getRawKey(); if (!rawKey) return; - const internalAuthorId = await getInternalAuthorId(authorId, rawKey); + const internalAuthorId = await getSecureHash(authorId, rawKey); if (!authorName) { const channel = await getChannelYTjs(authorId); @@ -260,6 +263,44 @@ export async function getKeyValue(key: string): Promise { return (await decryptWithMasterKey(respJson.valueNonce, respJson.valueCipher)) ?? null; } +export async function saveHistoryToBackend(video: VideoPlay, progress: number = 0) { + await sodium.ready; + const rawKey = await getRawKey(); + if (!rawKey) return; + + const videoHash = await getSecureHash(video.videoId, rawKey); + + const title = await encryptWithMasterKey(video.title); + const author = await encryptWithMasterKey(video.author); + const thumbnail = await encryptWithMasterKey(getBestThumbnail(video.videoThumbnails)); + const duration = await encryptWithMasterKey(video.lengthSeconds.toString()); + + await fetch('/api/user/history', { + method: 'POST', + body: JSON.stringify({ + id: videoHash, + watched: new Date(), + progress: progress, + title: { + cipher: title?.cipher, + nonce: title?.nonce + }, + author: { + cipher: author?.cipher, + nonce: author?.nonce + }, + thumbnail: { + cipher: thumbnail?.cipher, + nonce: thumbnail?.nonce + }, + duration: { + cipher: duration?.cipher, + nonce: duration?.nonce + } + }) + }); +} + async function encryptWithMasterKey( text: string ): Promise<{ nonce: string; cipher: string } | undefined> { diff --git a/materialious/src/lib/server/database.ts b/materialious/src/lib/server/database.ts index 5a58d109..b8de0aac 100644 --- a/materialious/src/lib/server/database.ts +++ b/materialious/src/lib/server/database.ts @@ -5,6 +5,7 @@ let UserTable: ModelCtor>; let ChannelSubscriptionTable: ModelCtor>; let CaptchaTable: ModelCtor>; let UserKeyValueTable: ModelCtor>; +let UserHistoryTable: ModelCtor>; let sequelizeInstance: Sequelize | null = null; @@ -15,6 +16,7 @@ export function getSequelize(): { ChannelSubscriptionTable: ModelCtor>; CaptchaTable: ModelCtor>; UserKeyValueTable: ModelCtor>; + UserHistoryTable: ModelCtor>; } { if (sequelizeInstance) { return { @@ -22,7 +24,8 @@ export function getSequelize(): { UserTable, ChannelSubscriptionTable, CaptchaTable, - UserKeyValueTable + UserKeyValueTable, + UserHistoryTable }; } @@ -107,6 +110,54 @@ export function getSequelize(): { } ); + UserHistoryTable = sequelizeInstance.define('UserHistory', { + id: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true + }, + watched: { + type: DataTypes.DATE, + allowNull: false + }, + progress: { + type: DataTypes.INTEGER, + allowNull: false + }, + titleCipher: { + type: DataTypes.STRING, + allowNull: false + }, + titleNonce: { + type: DataTypes.STRING, + allowNull: false + }, + authorCipher: { + type: DataTypes.STRING, + allowNull: false + }, + authorNonce: { + type: DataTypes.STRING, + allowNull: false + }, + thumbnailCipher: { + type: DataTypes.STRING, + allowNull: false + }, + thumbnailNonce: { + type: DataTypes.STRING, + allowNull: false + }, + durationCipher: { + type: DataTypes.STRING, + allowNull: false + }, + durationNonce: { + type: DataTypes.STRING, + allowNull: false + } + }); + ChannelSubscriptionTable = sequelizeInstance.define('Subscriptions', { id: { type: DataTypes.STRING, @@ -149,13 +200,15 @@ export function getSequelize(): { UserTable.hasMany(ChannelSubscriptionTable); UserTable.hasMany(UserKeyValueTable); + UserTable.hasMany(UserHistoryTable); return { sequelize: sequelizeInstance, UserTable, ChannelSubscriptionTable, CaptchaTable, - UserKeyValueTable + UserKeyValueTable, + UserHistoryTable }; } diff --git a/materialious/src/lib/watch.ts b/materialious/src/lib/watch.ts index 996f2c91..fa9b12a8 100644 --- a/materialious/src/lib/watch.ts +++ b/materialious/src/lib/watch.ts @@ -10,6 +10,7 @@ import { invidiousAuthStore, playerProxyVideosStore, playerState, + rawMasterKeyStore, returnYTDislikesInstanceStore, returnYtDislikesStore } from '$lib/store'; @@ -17,6 +18,8 @@ import { parseDescription } from '$lib/description'; import { error } from '@sveltejs/kit'; import { get } from 'svelte/store'; import { _ } from './i18n'; +import { isOwnBackend } from './shared'; +import { saveHistoryToBackend } from './api/backend'; export async function getWatchDetails(videoId: string, url: URL) { const playerStateRetrieved = get(playerState); @@ -45,6 +48,10 @@ export async function getWatchDetails(videoId: string, url: URL) { personalPlaylists = null; } + if (isOwnBackend()?.internalAuth && get(rawMasterKeyStore)) { + saveHistoryToBackend(video); + } + let comments; try { comments = video.liveNow ? null : getComments(videoId, { sort_by: 'top' }, { priority: 'low' }); diff --git a/materialious/src/routes/api/user/history/+server.ts b/materialious/src/routes/api/user/history/+server.ts new file mode 100644 index 00000000..68ed2bda --- /dev/null +++ b/materialious/src/routes/api/user/history/+server.ts @@ -0,0 +1,106 @@ +import { getSequelize } from '$lib/server/database.js'; +import { error, json } from '@sveltejs/kit'; +import z from 'zod'; +import { Op } from 'sequelize'; + +const zUserHistory = z.object({ + id: z.string(), + watched: z.coerce.date(), + progress: z.number(), + title: z.object({ + cipher: z.string().max(255), + nonce: z.string().max(255) + }), + author: z.object({ + cipher: z.string().max(255), + nonce: z.string().max(255) + }), + thumbnail: z.object({ + cipher: z.string().max(255), + nonce: z.string().max(255) + }), + duration: z.object({ + cipher: z.string().max(255), + nonce: z.string().max(255) + }) +}); + +export async function POST({ locals, request }) { + if (!locals.userId) throw error(401, 'Unauthorized'); + + const data = zUserHistory.safeParse(await request.json()); + + if (!data.success) throw error(400, data.error.message); + + const history = await getSequelize().UserHistoryTable.findOne({ + where: { + UserId: locals.userId, + id: data.data.id + } + }); + + const toStore = { + progress: data.data.progress, + watched: data.data.watched, + titleCipher: data.data.title.cipher, + titleNonce: data.data.title.nonce, + authorCipher: data.data.author.cipher, + authorNonce: data.data.author.nonce, + thumbnailCipher: data.data.thumbnail.cipher, + thumbnailNonce: data.data.thumbnail.nonce, + durationCipher: data.data.duration.cipher, + durationNonce: data.data.duration.nonce + }; + + if (history) { + await history.update(toStore); + } else { + await getSequelize().UserHistoryTable.create({ + UserId: locals.userId, + id: data.data.id, + ...toStore + }); + } + + return new Response(); +} + +export async function GET({ locals, url }) { + if (!locals.userId) throw error(401, 'Unauthorized'); + + const limit = 100; + const page = Number(url.searchParams.get('page') ?? 0); + + const videoHashes = url.searchParams.get('videoHashes'); + let videoHashesList: string[] = []; + + if (videoHashes) { + videoHashesList = videoHashes.split(','); + } + + const whereClause: any = { + UserId: locals.userId + }; + + if (videoHashesList.length > 0) { + whereClause[Op.or] = [{ id: { [Op.in]: videoHashesList } }]; + } + + const history = await getSequelize().UserHistoryTable.findAll({ + where: whereClause, + limit, + offset: limit * page + }); + + return json(history); +} + +export async function DELETE({ locals }) { + await getSequelize().UserHistoryTable.destroy({ + where: { + UserId: locals.userId + } + }); + + return new Response(); +} diff --git a/materialious/src/routes/api/user/history/[videoHash]/+server.ts b/materialious/src/routes/api/user/history/[videoHash]/+server.ts new file mode 100644 index 00000000..ac2c92fc --- /dev/null +++ b/materialious/src/routes/api/user/history/[videoHash]/+server.ts @@ -0,0 +1,47 @@ +import { getSequelize } from '$lib/server/database.js'; +import { error, json } from '@sveltejs/kit'; +import z from 'zod'; + +const zUserHistoryUpdate = z + .object({ + watched: z.date().optional(), + progress: z.number().optional() + }) + .transform((data) => { + return Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined)); + }); + +export async function POST({ locals, request, params }) { + if (!locals.userId) throw error(401, 'Unauthorized'); + + const data = zUserHistoryUpdate.safeParse(await request.json()); + if (!data.success) throw error(400); + + await getSequelize().UserHistoryTable.update(data.data, { + where: { UserId: locals.userId, VideoHash: params.videoHash } + }); + + return new Response(); +} + +export async function DELETE({ locals, params }) { + if (!locals.userId) throw error(401, 'Unauthorized'); + + await getSequelize().UserHistoryTable.destroy({ + where: { UserId: locals.userId, VideoHash: params.videoHash } + }); + + return new Response(); +} + +export async function GET({ locals, params }) { + if (!locals.userId) throw error(401, 'Unauthorized'); + + const history = await getSequelize().UserHistoryTable.findOne({ + where: { UserId: locals.userId, VideoHash: params.videoHash } + }); + + if (!history) throw error(404); + + return json(history); +}