mirror of
https://github.com/p-stream/backend.git
synced 2026-01-11 20:10:33 +00:00
First Major commit for BackendV2, still more to do
This commit is contained in:
parent
76344624b0
commit
6d0e59d2ae
31 changed files with 7788 additions and 0 deletions
7
.env.example
Normal file
7
.env.example
Normal 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
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.data
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
.output
|
||||||
|
.env
|
||||||
2
.npmrc
Normal file
2
.npmrc
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
shamefully-hoist=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
3
README.md
Normal file
3
README.md
Normal 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
18
nitro.config.ts
Normal 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
6199
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
package.json
Normal file
20
package.json
Normal 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
78
prisma/schema.prisma
Normal 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
12
server/middleware/cors.ts
Normal 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 ''
|
||||||
|
}
|
||||||
|
})
|
||||||
74
server/routes/auth/login/complete/index.ts
Normal file
74
server/routes/auth/login/complete/index.ts
Normal 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
|
||||||
|
};
|
||||||
|
});
|
||||||
36
server/routes/auth/login/start/index.ts
Normal file
36
server/routes/auth/login/start/index.ts
Normal 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
|
||||||
|
};
|
||||||
|
});
|
||||||
91
server/routes/auth/register/complete.ts
Normal file
91
server/routes/auth/register/complete.ts
Normal 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
|
||||||
|
};
|
||||||
|
});
|
||||||
25
server/routes/auth/register/start.ts
Normal file
25
server/routes/auth/register/start.ts
Normal 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
|
||||||
|
};
|
||||||
|
});
|
||||||
5
server/routes/healthcheck.ts
Normal file
5
server/routes/healthcheck.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
return {
|
||||||
|
message: ``
|
||||||
|
};
|
||||||
|
});
|
||||||
6
server/routes/index.ts
Normal file
6
server/routes/index.ts
Normal 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
10
server/routes/meta.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
103
server/routes/sessions/[sid]/index.ts
Normal file
103
server/routes/sessions/[sid]/index.ts
Normal 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'
|
||||||
|
});
|
||||||
|
});
|
||||||
60
server/routes/users/@me.ts
Normal file
60
server/routes/users/@me.ts
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
166
server/routes/users/[id]/bookmarks.ts
Normal file
166
server/routes/users/[id]/bookmarks.ts
Normal 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'
|
||||||
|
});
|
||||||
|
});
|
||||||
73
server/routes/users/[id]/bookmarks/[tmdbid]/index.ts
Normal file
73
server/routes/users/[id]/bookmarks/[tmdbid]/index.ts
Normal 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'
|
||||||
|
})
|
||||||
|
})
|
||||||
200
server/routes/users/[id]/progress.ts
Normal file
200
server/routes/users/[id]/progress.ts
Normal 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'
|
||||||
|
});
|
||||||
|
});
|
||||||
164
server/routes/users/[id]/progress/import.ts
Normal file
164
server/routes/users/[id]/progress/import.ts
Normal 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
|
||||||
|
}));
|
||||||
|
});
|
||||||
54
server/routes/users/[id]/sessions.ts
Normal file
54
server/routes/users/[id]/sessions.ts
Normal 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
|
||||||
|
}));
|
||||||
|
});
|
||||||
132
server/routes/users/[id]/settings.ts
Normal file
132
server/routes/users/[id]/settings.ts
Normal 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'
|
||||||
|
});
|
||||||
|
});
|
||||||
1
server/routes/users/bookmark.ts
Normal file
1
server/routes/users/bookmark.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
import { prisma } from '~/utils/prisma'
|
||||||
51
server/routes/users/me.ts
Normal file
51
server/routes/users/me.ts
Normal 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
84
server/utils/auth.ts
Normal 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
93
server/utils/challenge.ts
Normal 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
1
server/utils/config.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const version = '2.0.0'
|
||||||
9
server/utils/prisma.ts
Normal file
9
server/utils/prisma.ts
Normal 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
4
tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// https://nitro.unjs.io/guide/typescript
|
||||||
|
{
|
||||||
|
"extends": "./.nitro/types/tsconfig.json"
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue