mirror of
https://github.com/p-stream/backend.git
synced 2026-01-11 12:00:45 +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