First Major commit for BackendV2, still more to do

This commit is contained in:
FifthWit 2025-03-06 22:25:17 -06:00
parent 76344624b0
commit 6d0e59d2ae
31 changed files with 7788 additions and 0 deletions

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
DATABASE_URL="postgresql://user:password@localhost:5432/db?schema=public"
META_NAME=''
META_DESCRIPTION=''
CAPTCHA=false
CAPTCHA_CLIENT_KEY=''
# USE A RANDOM PASSWORD GENERATOR LIKE THIS: https://bitwarden.com/password-generator/ NEVER SHARE
CRYPTO_SECRET=''

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules
dist
.data
.nitro
.cache
.output
.env

2
.npmrc Normal file
View file

@ -0,0 +1,2 @@
shamefully-hoist=true
strict-peer-dependencies=false

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# BackendV2
P-Stream's Backend has some issues, and this branch is designed to remake all the endpoints with Nitro for better DX

18
nitro.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { version } from "./server/utils/config";
//https://nitro.unjs.io/config
export default defineNitroConfig({
srcDir: "server",
compatibilityDate: "2025-03-05",
runtimeConfig: {
public: {
meta: {
name: process.env.META_NAME || '',
description: process.env.META_DESCRIPTION || '',
version: version || '',
captcha: process.env.CAPTCHA || false,
captchaClientKey: process.env.CAPTCHA_CLIENT_KEY || ''
}
},
cyrptoSecret: process.env.CRYPTO_SECRET
}
});

6199
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"private": true,
"scripts": {
"build": "nitro build",
"dev": "nitro dev",
"prepare": "nitro prepare",
"preview": "node .output/server/index.mjs"
},
"devDependencies": {
"nitropack": "latest",
"prisma": "^6.4.1"
},
"dependencies": {
"@prisma/client": "^6.4.1",
"bs58": "^6.0.0",
"jsonwebtoken": "^9.0.2",
"tweetnacl": "^1.0.3",
"zod": "^3.24.2"
}
}

78
prisma/schema.prisma Normal file
View file

@ -0,0 +1,78 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model bookmarks {
tmdb_id String @db.VarChar(255)
user_id String @db.VarChar(255)
meta Json
updated_at DateTime @db.Timestamptz(0)
@@id([tmdb_id, user_id])
@@unique([tmdb_id, user_id], map: "bookmarks_tmdb_id_user_id_unique")
}
model challenge_codes {
code String @id @db.Uuid
flow String
auth_type String @db.VarChar(255)
created_at DateTime @db.Timestamptz(0)
expires_at DateTime @db.Timestamptz(0)
}
model mikro_orm_migrations {
id Int @id @default(autoincrement())
name String? @db.VarChar(255)
executed_at DateTime? @default(now()) @db.Timestamptz(6)
}
model progress_items {
id String @id @db.Uuid
tmdb_id String @db.VarChar(255)
user_id String @db.VarChar(255)
season_id String? @db.VarChar(255)
episode_id String? @db.VarChar(255)
meta Json
updated_at DateTime @db.Timestamptz(0)
duration BigInt
watched BigInt
season_number Int?
episode_number Int?
@@unique([tmdb_id, user_id, season_id, episode_id], map: "progress_items_tmdb_id_user_id_season_id_episode_id_unique")
}
model sessions {
id String @id @db.Uuid
user String
created_at DateTime @db.Timestamptz(0)
accessed_at DateTime @db.Timestamptz(0)
expires_at DateTime @db.Timestamptz(0)
device String
user_agent String
}
model user_settings {
id String @id
application_theme String? @db.VarChar(255)
application_language String? @db.VarChar(255)
default_subtitle_language String? @db.VarChar(255)
proxy_urls String[]
trakt_key String? @db.VarChar(255)
febbox_key String? @db.VarChar(255)
}
model users {
id String @id
public_key String @unique(map: "users_public_key_unique")
namespace String @db.VarChar(255)
created_at DateTime @db.Timestamptz(0)
last_logged_in DateTime? @db.Timestamptz(0)
permissions String[]
profile Json
}

12
server/middleware/cors.ts Normal file
View file

@ -0,0 +1,12 @@
export default defineEventHandler((event) => {
setResponseHeaders(event, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,HEAD,PUT,PATCH,POST,DELETE',
'Access-Control-Allow-Headers': '*'
})
if (event.method === 'OPTIONS') {
event.node.res.statusCode = 204
return ''
}
})

View file

@ -0,0 +1,74 @@
import { z } from 'zod';
import { useChallenge } from '~/utils/challenge';
import { useAuth } from '~/utils/auth';
const completeSchema = z.object({
publicKey: z.string(),
challenge: z.object({
code: z.string(),
signature: z.string(),
}),
device: z.string().max(500).min(1),
});
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const result = completeSchema.safeParse(body);
if (!result.success) {
throw createError({
statusCode: 400,
message: 'Invalid request body'
});
}
const challenge = useChallenge();
await challenge.verifyChallengeCode(
body.challenge.code,
body.publicKey,
body.challenge.signature,
'login',
'mnemonic'
);
const user = await prisma.users.findUnique({
where: { public_key: body.publicKey }
});
if (!user) {
throw createError({
statusCode: 401,
message: 'User cannot be found'
});
}
await prisma.users.update({
where: { id: user.id },
data: { last_logged_in: new Date() }
});
const auth = useAuth();
const userAgent = getRequestHeader(event, 'user-agent') || '';
const session = await auth.makeSession(user.id, body.device, userAgent);
const token = auth.makeSessionToken(session);
return {
user: {
id: user.id,
publicKey: user.public_key,
namespace: user.namespace,
profile: user.profile,
permissions: user.permissions
},
session: {
id: session.id,
user: session.user,
createdAt: session.created_at,
accessedAt: session.accessed_at,
expiresAt: session.expires_at,
device: session.device,
userAgent: session.user_agent
},
token
};
});

View file

@ -0,0 +1,36 @@
import { z } from 'zod';
import { useChallenge } from '~/utils/challenge';
const startSchema = z.object({
publicKey: z.string(),
});
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const result = startSchema.safeParse(body);
if (!result.success) {
throw createError({
statusCode: 400,
message: 'Invalid request body'
});
}
const user = await prisma.users.findUnique({
where: { public_key: body.publicKey }
});
if (!user) {
throw createError({
statusCode: 401,
message: 'User cannot be found'
});
}
const challenge = useChallenge();
const challengeCode = await challenge.createChallengeCode('login', 'mnemonic');
return {
challenge: challengeCode.code
};
});

View file

@ -0,0 +1,91 @@
import { z } from 'zod';
import { useChallenge } from '~/utils/challenge';
import { useAuth } from '~/utils/auth';
import { randomUUID } from 'crypto';
const completeSchema = z.object({
publicKey: z.string(),
challenge: z.object({
code: z.string(),
signature: z.string(),
}),
namespace: z.string().min(1),
device: z.string().max(500).min(1),
profile: z.object({
colorA: z.string(),
colorB: z.string(),
icon: z.string(),
}),
});
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const result = completeSchema.safeParse(body);
if (!result.success) {
throw createError({
statusCode: 400,
message: 'Invalid request body'
});
}
const challenge = useChallenge();
await challenge.verifyChallengeCode(
body.challenge.code,
body.publicKey,
body.challenge.signature,
'registration',
'mnemonic'
);
const existingUser = await prisma.users.findUnique({
where: { public_key: body.publicKey }
});
if (existingUser) {
throw createError({
statusCode: 409,
message: 'A user with this public key already exists'
});
}
const userId = randomUUID();
const now = new Date();
const user = await prisma.users.create({
data: {
id: userId,
namespace: body.namespace,
public_key: body.publicKey,
created_at: now,
last_logged_in: now,
permissions: [],
profile: body.profile
}
});
const auth = useAuth();
const userAgent = getRequestHeader(event, 'user-agent');
const session = await auth.makeSession(user.id, body.device, userAgent);
const token = auth.makeSessionToken(session);
return {
user: {
id: user.id,
publicKey: user.public_key,
namespace: user.namespace,
profile: user.profile,
permissions: user.permissions
},
session: {
id: session.id,
user: session.user,
createdAt: session.created_at,
accessedAt: session.accessed_at,
expiresAt: session.expires_at,
device: session.device,
userAgent: session.user_agent
},
token
};
});

View file

@ -0,0 +1,25 @@
import { z } from 'zod';
import { useChallenge } from '~/utils/challenge';
const startSchema = z.object({
captchaToken: z.string().optional(),
});
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const result = startSchema.safeParse(body);
if (!result.success) {
throw createError({
statusCode: 400,
message: 'Invalid request body'
});
}
const challenge = useChallenge();
const challengeCode = await challenge.createChallengeCode('registration', 'mnemonic');
return {
challenge: challengeCode.code
};
});

View file

@ -0,0 +1,5 @@
export default defineEventHandler((event) => {
return {
message: ``
};
});

6
server/routes/index.ts Normal file
View file

@ -0,0 +1,6 @@
import { version } from "~/utils/config";
export default defineEventHandler((event) => {
return {
message: `Backend is working as expected (v${version})`
};
});

10
server/routes/meta.ts Normal file
View file

@ -0,0 +1,10 @@
const meta = useRuntimeConfig().public.meta
export default defineEventHandler((event) => {
return {
name: meta.name,
description: meta.description,
version: meta.version,
hasCaptcha: meta.captcha === 'true',
captchaClientKey: meta.captchaClientKey
}
})

View file

@ -0,0 +1,103 @@
import { useAuth } from '~/utils/auth';
import { z } from 'zod';
const updateSessionSchema = z.object({
deviceName: z.string().max(500).min(1).optional(),
});
export default defineEventHandler(async (event) => {
const sessionId = getRouterParam(event, 'sid');
const authHeader = getRequestHeader(event, 'authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
});
}
const token = authHeader.split(' ')[1];
const auth = useAuth();
const payload = auth.verifySessionToken(token);
if (!payload) {
throw createError({
statusCode: 401,
message: 'Invalid token'
});
}
const currentSession = await auth.getSessionAndBump(payload.sid);
if (!currentSession) {
throw createError({
statusCode: 401,
message: 'Session not found or expired'
});
}
const targetedSession = await prisma.sessions.findUnique({
where: { id: sessionId }
});
if (!targetedSession) {
if (event.method === 'DELETE') {
return { id: sessionId };
}
throw createError({
statusCode: 404,
message: 'Session cannot be found'
});
}
if (targetedSession.user !== currentSession.user) {
throw createError({
statusCode: 401,
message: event.method === 'DELETE'
? 'Cannot delete sessions you do not own'
: 'Cannot edit sessions other than your own'
});
}
if (event.method === 'PATCH') {
const body = await readBody(event);
const validatedBody = updateSessionSchema.parse(body);
if (validatedBody.deviceName) {
await prisma.sessions.update({
where: { id: sessionId },
data: {
device: validatedBody.deviceName
}
});
}
const updatedSession = await prisma.sessions.findUnique({
where: { id: sessionId }
});
return {
id: updatedSession.id,
user: updatedSession.user,
createdAt: updatedSession.created_at,
accessedAt: updatedSession.accessed_at,
expiresAt: updatedSession.expires_at,
device: updatedSession.device,
userAgent: updatedSession.user_agent,
current: updatedSession.id === currentSession.id
};
}
if (event.method === 'DELETE') {
await prisma.sessions.delete({
where: { id: sessionId }
});
return { id: sessionId };
}
throw createError({
statusCode: 405,
message: 'Method not allowed'
});
});

View file

@ -0,0 +1,60 @@
import { useAuth } from '~/utils/auth';
export default defineEventHandler(async (event) => {
const authHeader = getRequestHeader(event, 'authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
});
}
const token = authHeader.split(' ')[1];
const auth = useAuth();
const payload = auth.verifySessionToken(token);
if (!payload) {
throw createError({
statusCode: 401,
message: 'Invalid token'
});
}
const session = await auth.getSessionAndBump(payload.sid);
if (!session) {
throw createError({
statusCode: 401,
message: 'Session not found or expired'
});
}
const user = await prisma.users.findUnique({
where: { id: session.user }
});
if (!user) {
throw createError({
statusCode: 404,
message: 'User not found'
});
}
return {
user: {
id: user.id,
publicKey: user.public_key,
namespace: user.namespace,
profile: user.profile,
permissions: user.permissions
},
session: {
id: session.id,
user: session.user,
createdAt: session.created_at,
accessedAt: session.accessed_at,
expiresAt: session.expires_at,
device: session.device,
userAgent: session.user_agent
}
};
});

View file

@ -0,0 +1,166 @@
import { useAuth } from '~/utils/auth';
import { z } from 'zod';
const bookmarkMetaSchema = z.object({
title: z.string(),
year: z.number().optional(),
poster: z.string().optional(),
type: z.enum(['movie', 'tv']),
});
const bookmarkDataSchema = z.object({
tmdbId: z.string(),
meta: bookmarkMetaSchema,
});
export default defineEventHandler(async (event) => {
const userId = event.context.params?.id;
const method = event.method;
const authHeader = getRequestHeader(event, 'authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
});
}
const token = authHeader.split(' ')[1];
const auth = useAuth();
const payload = auth.verifySessionToken(token);
if (!payload) {
throw createError({
statusCode: 401,
message: 'Invalid token'
});
}
const session = await auth.getSessionAndBump(payload.sid);
if (!session) {
throw createError({
statusCode: 401,
message: 'Session not found or expired'
});
}
if (session.user !== userId) {
throw createError({
statusCode: 403,
message: 'Cannot access other user information'
});
}
if (method === 'GET') {
const bookmarks = await prisma.bookmarks.findMany({
where: { user_id: userId }
});
return bookmarks.map(bookmark => ({
tmdbId: bookmark.tmdb_id,
userId: bookmark.user_id,
meta: bookmark.meta,
updatedAt: bookmark.updated_at
}));
}
if (method === 'PUT') {
const body = await readBody(event);
const validatedBody = z.array(bookmarkDataSchema).parse(body);
const now = new Date();
const results = [];
for (const item of validatedBody) {
const bookmark = await prisma.bookmarks.upsert({
where: {
tmdb_id_user_id: {
tmdb_id: item.tmdbId,
user_id: userId
}
},
update: {
meta: item.meta,
updated_at: now
},
create: {
tmdb_id: item.tmdbId,
user_id: userId,
meta: item.meta,
updated_at: now
}
});
results.push({
tmdbId: bookmark.tmdb_id,
userId: bookmark.user_id,
meta: bookmark.meta,
updatedAt: bookmark.updated_at
});
}
return results;
}
const segments = event.path.split('/');
const tmdbId = segments[segments.length - 1];
if (method === 'POST') {
const body = await readBody(event);
const validatedBody = bookmarkDataSchema.parse(body);
const existing = await prisma.bookmarks.findUnique({
where: {
tmdb_id_user_id: {
tmdb_id: tmdbId,
user_id: userId
}
}
});
if (existing) {
throw createError({
statusCode: 400,
message: 'Already bookmarked'
});
}
const bookmark = await prisma.bookmarks.create({
data: {
tmdb_id: tmdbId,
user_id: userId,
meta: validatedBody.meta,
updated_at: new Date()
}
});
return {
tmdbId: bookmark.tmdb_id,
userId: bookmark.user_id,
meta: bookmark.meta,
updatedAt: bookmark.updated_at
};
}
if (method === 'DELETE') {
try {
await prisma.bookmarks.delete({
where: {
tmdb_id_user_id: {
tmdb_id: tmdbId,
user_id: userId
}
}
});
} catch (error) {
}
return { tmdbId };
}
throw createError({
statusCode: 405,
message: 'Method not allowed'
});
});

View file

@ -0,0 +1,73 @@
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'id')
const tmdbId = getRouterParam(event, 'tmdbid')
const authHeader = getRequestHeader(event, 'authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
});
}
const token = authHeader.split(' ')[1];
const auth = useAuth();
const payload = auth.verifySessionToken(token);
if (!payload) {
throw createError({
statusCode: 401,
message: 'Invalid token'
});
}
const session = await auth.getSessionAndBump(payload.sid);
if (!session) {
throw createError({
statusCode: 401,
message: 'Session not found or expired'
});
}
if (session.user !== userId) {
throw createError({
statusCode: 403,
message: 'Cannot access bookmarks for other users'
});
}
if (event.method === "POST") {
const body = await readBody(event);
const bookmark = await prisma.bookmarks.create({
data: {
user_id: session.user,
tmdb_id: tmdbId,
meta: body.meta,
updated_at: new Date()
}
});
return {
tmdbId: bookmark.tmdb_id,
userId: bookmark.user_id,
meta: bookmark.meta,
updatedAt: bookmark.updated_at
};
} else if (event.method === "DELETE") {
await prisma.bookmarks.delete({
where: {
tmdb_id_user_id: {
tmdb_id: tmdbId,
user_id: session.user
}
}
});
return { success: true, tmdbId };
}
throw createError({
statusCode: 405,
message: 'Method not allowed'
})
})

View file

@ -0,0 +1,200 @@
import { useAuth } from '~/utils/auth';
import { z } from 'zod';
import { randomUUID } from 'crypto';
const progressMetaSchema = z.object({
title: z.string(),
poster: z.string().optional(),
type: z.enum(['movie', 'tv']),
year: z.number().optional()
});
const progressItemSchema = z.object({
meta: progressMetaSchema,
tmdbId: z.string(),
duration: z.number().transform((n) => Math.round(n)),
watched: z.number().transform((n) => Math.round(n)),
seasonId: z.string().optional(),
episodeId: z.string().optional(),
seasonNumber: z.number().optional(),
episodeNumber: z.number().optional(),
updatedAt: z.string().datetime({ offset: true }).optional(),
});
// 13th July 2021 - movie-web epoch
const minEpoch = 1626134400000;
function defaultAndCoerceDateTime(dateTime: string | undefined) {
const epoch = dateTime ? new Date(dateTime).getTime() : Date.now();
const clampedEpoch = Math.max(minEpoch, Math.min(epoch, Date.now()));
return new Date(clampedEpoch);
}
export default defineEventHandler(async (event) => {
const userId = event.context.params?.id;
const method = event.method;
const authHeader = getRequestHeader(event, 'authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
});
}
const token = authHeader.split(' ')[1];
const auth = useAuth();
const payload = auth.verifySessionToken(token);
if (!payload) {
throw createError({
statusCode: 401,
message: 'Invalid token'
});
}
const session = await auth.getSessionAndBump(payload.sid);
if (!session) {
throw createError({
statusCode: 401,
message: 'Session not found or expired'
});
}
if (session.user !== userId) {
throw createError({
statusCode: 403,
message: 'Cannot modify user other than yourself'
});
}
if (method === 'GET') {
const items = await prisma.progress_items.findMany({
where: { user_id: userId }
});
return items.map(item => ({
id: item.id,
tmdbId: item.tmdb_id,
userId: item.user_id,
seasonId: item.season_id,
episodeId: item.episode_id,
seasonNumber: item.season_number,
episodeNumber: item.episode_number,
meta: item.meta,
duration: Number(item.duration),
watched: Number(item.watched),
updatedAt: item.updated_at
}));
}
if (event.path.includes('/progress/') && !event.path.endsWith('/import')) {
const segments = event.path.split('/');
const tmdbId = segments[segments.length - 1];
if (method === 'PUT') {
const body = await readBody(event);
const validatedBody = progressItemSchema.parse(body);
const now = defaultAndCoerceDateTime(validatedBody.updatedAt);
const existingItem = await prisma.progress_items.findUnique({
where: {
tmdb_id_user_id_season_id_episode_id: {
tmdb_id: tmdbId,
user_id: userId,
season_id: validatedBody.seasonId || null,
episode_id: validatedBody.episodeId || null
}
}
});
let progressItem;
if (existingItem) {
progressItem = await prisma.progress_items.update({
where: {
id: existingItem.id
},
data: {
duration: BigInt(validatedBody.duration),
watched: BigInt(validatedBody.watched),
meta: validatedBody.meta,
updated_at: now
}
});
} else {
progressItem = await prisma.progress_items.create({
data: {
id: randomUUID(),
tmdb_id: tmdbId,
user_id: userId,
season_id: validatedBody.seasonId || null,
episode_id: validatedBody.episodeId || null,
season_number: validatedBody.seasonNumber || null,
episode_number: validatedBody.episodeNumber || null,
duration: BigInt(validatedBody.duration),
watched: BigInt(validatedBody.watched),
meta: validatedBody.meta,
updated_at: now
}
});
}
return {
id: progressItem.id,
tmdbId: progressItem.tmdb_id,
userId: progressItem.user_id,
seasonId: progressItem.season_id,
episodeId: progressItem.episode_id,
seasonNumber: progressItem.season_number,
episodeNumber: progressItem.episode_number,
meta: progressItem.meta,
duration: Number(progressItem.duration),
watched: Number(progressItem.watched),
updatedAt: progressItem.updated_at
};
}
if (method === 'DELETE') {
const body = await readBody(event).catch(() => ({}));
const whereClause: any = {
user_id: userId,
tmdb_id: tmdbId
};
if (body.seasonId) whereClause.season_id = body.seasonId;
if (body.episodeId) whereClause.episode_id = body.episodeId;
const itemsToDelete = await prisma.progress_items.findMany({
where: whereClause
});
if (itemsToDelete.length === 0) {
return {
count: 0,
tmdbId,
episodeId: body.episodeId,
seasonId: body.seasonId
};
}
await prisma.progress_items.deleteMany({
where: whereClause
});
return {
count: itemsToDelete.length,
tmdbId,
episodeId: body.episodeId,
seasonId: body.seasonId
};
}
}
throw createError({
statusCode: 405,
message: 'Method not allowed'
});
});

View file

@ -0,0 +1,164 @@
import { useAuth } from '~/utils/auth';
import { z } from 'zod';
import { randomUUID } from 'crypto';
const progressMetaSchema = z.object({
title: z.string(),
poster: z.string().optional(),
type: z.enum(['movie', 'tv']),
year: z.number().optional()
});
const progressItemSchema = z.object({
meta: progressMetaSchema,
tmdbId: z.string(),
duration: z.number().transform((n) => Math.round(n)),
watched: z.number().transform((n) => Math.round(n)),
seasonId: z.string().optional(),
episodeId: z.string().optional(),
seasonNumber: z.number().optional(),
episodeNumber: z.number().optional(),
updatedAt: z.string().datetime({ offset: true }).optional(),
});
// 13th July 2021 - movie-web epoch
const minEpoch = 1626134400000;
function defaultAndCoerceDateTime(dateTime: string | undefined) {
const epoch = dateTime ? new Date(dateTime).getTime() : Date.now();
const clampedEpoch = Math.max(minEpoch, Math.min(epoch, Date.now()));
return new Date(clampedEpoch);
}
export default defineEventHandler(async (event) => {
const userId = event.context.params?.id;
const authHeader = getRequestHeader(event, 'authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
});
}
const token = authHeader.split(' ')[1];
const auth = useAuth();
const payload = auth.verifySessionToken(token);
if (!payload) {
throw createError({
statusCode: 401,
message: 'Invalid token'
});
}
const session = await auth.getSessionAndBump(payload.sid);
if (!session) {
throw createError({
statusCode: 401,
message: 'Session not found or expired'
});
}
if (session.user !== userId) {
throw createError({
statusCode: 403,
message: 'Cannot modify user other than yourself'
});
}
if (event.method !== 'PUT') {
throw createError({
statusCode: 405,
message: 'Method not allowed'
});
}
const body = await readBody(event);
const validatedBody = z.array(progressItemSchema).parse(body);
const existingItems = await prisma.progress_items.findMany({
where: { user_id: userId }
});
const newItems = [...validatedBody];
const itemsToUpsert = [];
for (const existingItem of existingItems) {
const newItemIndex = newItems.findIndex(
(item) =>
item.tmdbId === existingItem.tmdb_id &&
item.seasonId === existingItem.season_id &&
item.episodeId === existingItem.episode_id
);
if (newItemIndex > -1) {
const newItem = newItems[newItemIndex];
if (Number(existingItem.watched) < newItem.watched) {
itemsToUpsert.push({
id: existingItem.id,
tmdb_id: existingItem.tmdb_id,
user_id: existingItem.user_id,
season_id: existingItem.season_id,
episode_id: existingItem.episode_id,
season_number: existingItem.season_number,
episode_number: existingItem.episode_number,
duration: BigInt(newItem.duration),
watched: BigInt(newItem.watched),
meta: newItem.meta,
updated_at: defaultAndCoerceDateTime(newItem.updatedAt)
});
}
newItems.splice(newItemIndex, 1);
}
}
for (const newItem of newItems) {
itemsToUpsert.push({
id: randomUUID(),
tmdb_id: newItem.tmdbId,
user_id: userId,
season_id: newItem.seasonId || null,
episode_id: newItem.episodeId || null,
season_number: newItem.seasonNumber || null,
episode_number: newItem.episodeNumber || null,
duration: BigInt(newItem.duration),
watched: BigInt(newItem.watched),
meta: newItem.meta,
updated_at: defaultAndCoerceDateTime(newItem.updatedAt)
});
}
const result = await prisma.$transaction(
itemsToUpsert.map(item =>
prisma.progress_items.upsert({
where: {
id: item.id
},
update: {
watched: item.watched,
duration: item.duration,
meta: item.meta,
updated_at: item.updated_at
},
create: item
})
)
);
return result.map(item => ({
id: item.id,
tmdbId: item.tmdb_id,
userId: item.user_id,
seasonId: item.season_id,
episodeId: item.episode_id,
seasonNumber: item.season_number,
episodeNumber: item.episode_number,
meta: item.meta,
duration: Number(item.duration),
watched: Number(item.watched),
updatedAt: item.updated_at
}));
});

View file

@ -0,0 +1,54 @@
import { useAuth } from '~/utils/auth';
export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'id');
const authHeader = getRequestHeader(event, 'authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
});
}
const token = authHeader.split(' ')[1];
const auth = useAuth();
const payload = auth.verifySessionToken(token);
if (!payload) {
throw createError({
statusCode: 401,
message: 'Invalid token'
});
}
const session = await auth.getSessionAndBump(payload.sid);
if (!session) {
throw createError({
statusCode: 401,
message: 'Session not found or expired'
});
}
if (session.user !== userId) {
throw createError({
statusCode: 403,
message: 'Cannot access sessions for other users'
});
}
const sessions = await prisma.sessions.findMany({
where: { user: userId }
});
return sessions.map(s => ({
id: s.id,
user: s.user,
createdAt: s.created_at,
accessedAt: s.accessed_at,
expiresAt: s.expires_at,
device: s.device,
userAgent: s.user_agent,
current: s.id === session.id
}));
});

View file

@ -0,0 +1,132 @@
import { useAuth } from '~/utils/auth';
import { z } from 'zod';
const userSettingsSchema = z.object({
application_theme: z.string().optional(),
application_language: z.string().optional(),
default_subtitle_language: z.string().optional(),
proxy_urls: z.array(z.string()).optional(),
trakt_key: z.string().optional(),
febbox_key: z.string().optional()
});
export default defineEventHandler(async (event) => {
const userId = event.context.params?.id;
const authHeader = getRequestHeader(event, 'authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
});
}
const token = authHeader.split(' ')[1];
const auth = useAuth();
const payload = auth.verifySessionToken(token);
if (!payload) {
throw createError({
statusCode: 401,
message: 'Invalid token'
});
}
const session = await auth.getSessionAndBump(payload.sid);
if (!session) {
throw createError({
statusCode: 401,
message: 'Session not found or expired'
});
}
if (session.user !== userId) {
throw createError({
statusCode: 403,
message: 'Permission denied'
});
}
if (event.method === 'PUT') {
try {
const body = await readBody(event);
const validatedSettings = userSettingsSchema.parse(body);
const existingSettings = await prisma.user_settings.findUnique({
where: { id: userId }
});
let settings;
if (existingSettings) {
settings = await prisma.user_settings.update({
where: { id: userId },
data: validatedSettings
});
} else {
settings = await prisma.user_settings.create({
data: {
id: userId,
...validatedSettings
}
});
}
return {
settings: {
applicationTheme: settings.application_theme,
applicationLanguage: settings.application_language,
defaultSubtitleLanguage: settings.default_subtitle_language,
proxyUrls: settings.proxy_urls,
traktKey: settings.trakt_key,
febboxKey: settings.febbox_key
}
};
} catch (error) {
if (error instanceof z.ZodError) {
throw createError({
statusCode: 400,
message: 'Invalid settings data'
});
}
throw createError({
statusCode: 500,
message: 'Failed to update settings'
});
}
} else if (event.method === 'GET') {
const settings = await prisma.user_settings.findUnique({
where: { id: userId }
});
if (!settings) {
return {
settings: {
applicationTheme: null,
applicationLanguage: null,
defaultSubtitleLanguage: null,
proxyUrls: [],
traktKey: null,
febboxKey: null
}
};
}
return {
settings: {
applicationTheme: settings.application_theme,
applicationLanguage: settings.application_language,
defaultSubtitleLanguage: settings.default_subtitle_language,
proxyUrls: settings.proxy_urls,
traktKey: settings.trakt_key,
febboxKey: settings.febbox_key
}
};
}
throw createError({
statusCode: 405,
message: 'Method not allowed'
});
});

View file

@ -0,0 +1 @@
import { prisma } from '~/utils/prisma'

51
server/routes/users/me.ts Normal file
View file

@ -0,0 +1,51 @@
import { useAuth } from '~/utils/auth';
export default defineEventHandler(async (event) => {
const authHeader = getRequestHeader(event, 'authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
});
}
const token = authHeader.split(' ')[1];
const auth = useAuth();
const payload = auth.verifySessionToken(token);
if (!payload) {
throw createError({
statusCode: 401,
message: 'Invalid token'
});
}
const session = await auth.getSessionAndBump(payload.sid);
if (!session) {
throw createError({
statusCode: 401,
message: 'Session not found or expired'
});
}
const user = await prisma.users.findUnique({
where: { id: session.user }
});
if (!user) {
throw createError({
statusCode: 404,
message: 'User not found'
});
}
return {
user: {
id: user.id,
publicKey: user.public_key,
namespace: user.namespace,
profile: user.profile,
permissions: user.permissions
}
};
});

84
server/utils/auth.ts Normal file
View file

@ -0,0 +1,84 @@
import { prisma } from './prisma';
import jwt from 'jsonwebtoken';
const { sign, verify } = jwt;
import { randomUUID } from 'crypto';
// 21 days in ms
const SESSION_EXPIRY_MS = 21 * 24 * 60 * 60 * 1000;
export function useAuth() {
const getSession = async (id: string) => {
const session = await prisma.sessions.findUnique({
where: { id }
});
if (!session) return null;
if (new Date(session.expires_at) < new Date()) return null;
return session;
};
const getSessionAndBump = async (id: string) => {
const session = await getSession(id);
if (!session) return null;
const now = new Date();
const expiryDate = new Date(now.getTime() + SESSION_EXPIRY_MS);
return await prisma.sessions.update({
where: { id },
data: {
accessed_at: now,
expires_at: expiryDate
}
});
};
const makeSession = async (user: string, device: string, userAgent?: string) => {
if (!userAgent) throw new Error('No useragent provided');
const now = new Date();
const expiryDate = new Date(now.getTime() + SESSION_EXPIRY_MS);
return await prisma.sessions.create({
data: {
id: randomUUID(),
user,
device,
user_agent: userAgent,
created_at: now,
accessed_at: now,
expires_at: expiryDate
}
});
};
const makeSessionToken = (session: { id: string }) => {
const runtimeConfig = useRuntimeConfig();
return sign({ sid: session.id }, runtimeConfig.cyrptoSecret, {
algorithm: 'HS256'
});
};
const verifySessionToken = (token: string) => {
try {
const runtimeConfig = useRuntimeConfig();
const payload = verify(token, runtimeConfig.cyrptoSecret, {
algorithms: ['HS256']
});
if (typeof payload === 'string') return null;
return payload as { sid: string };
} catch {
return null;
}
};
return {
getSession,
getSessionAndBump,
makeSession,
makeSessionToken,
verifySessionToken
};
}

93
server/utils/challenge.ts Normal file
View file

@ -0,0 +1,93 @@
import { randomUUID, createVerify, verify as cryptoVerify } from 'crypto';
import { prisma } from './prisma';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const nacl = require('tweetnacl');
const bs58 = require('bs58');
// Challenge code expires in 10 minutes
const CHALLENGE_EXPIRY_MS = 10 * 60 * 1000;
export function useChallenge() {
const createChallengeCode = async (flow: string, authType: string) => {
const now = new Date();
const expiryDate = new Date(now.getTime() + CHALLENGE_EXPIRY_MS);
return await prisma.challenge_codes.create({
data: {
code: randomUUID(),
flow,
auth_type: authType,
created_at: now,
expires_at: expiryDate
}
});
};
const verifyChallengeCode = async (
code: string,
publicKey: string,
signature: string,
flow: string,
authType: string
) => {
const challengeCode = await prisma.challenge_codes.findUnique({
where: { code }
});
if (!challengeCode) {
throw new Error('Invalid challenge code');
}
if (challengeCode.flow !== flow || challengeCode.auth_type !== authType) {
throw new Error('Invalid challenge flow or auth type');
}
if (new Date(challengeCode.expires_at) < new Date()) {
throw new Error('Challenge code expired');
}
const isValidSignature = verifySignature(code, publicKey, signature);
if (!isValidSignature) {
throw new Error('Invalid signature');
}
await prisma.challenge_codes.delete({
where: { code }
});
return true;
};
const verifySignature = (data: string, publicKey: string, signature: string) => {
try {
let normalizedSignature = signature.replace(/-/g, '+').replace(/_/g, '/');
while (normalizedSignature.length % 4 !== 0) {
normalizedSignature += '=';
}
let normalizedPublicKey = publicKey.replace(/-/g, '+').replace(/_/g, '/');
while (normalizedPublicKey.length % 4 !== 0) {
normalizedPublicKey += '=';
}
const signatureBuffer = Buffer.from(normalizedSignature, 'base64');
const publicKeyBuffer = Buffer.from(normalizedPublicKey, 'base64');
const messageBuffer = Buffer.from(data);
return nacl.sign.detached.verify(
messageBuffer,
signatureBuffer,
publicKeyBuffer
);
} catch (error) {
console.error('Signature verification error:', error);
return false;
}
};
return {
createChallengeCode,
verifyChallengeCode
};
}

1
server/utils/config.ts Normal file
View file

@ -0,0 +1 @@
export const version = '2.0.0'

9
server/utils/prisma.ts Normal file
View file

@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

4
tsconfig.json Normal file
View file

@ -0,0 +1,4 @@
// https://nitro.unjs.io/guide/typescript
{
"extends": "./.nitro/types/tsconfig.json"
}