Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed 23
Empty file.
1 change: 1 addition & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ PORT=3333
BASE_URL=http://localhost:3333
JWT_SECRET=
CLIENT_URL=http://localhost:3000
IOS_TOKEN=

# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432"
Expand Down
3 changes: 0 additions & 3 deletions apps/backend/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ services:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: plotwist_db
POSTGRESQL_REPLICATION_USE_PASSFILE: "no"
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- 5432:5432
networks:
Expand Down Expand Up @@ -60,7 +58,6 @@ services:
- plotwist_network

volumes:
postgres_data:
redis-data:
localstack_data:
driver: local
Expand Down
24 changes: 0 additions & 24 deletions apps/backend/fly.toml

This file was deleted.

1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/redis": "^7.1.0",
"@fastify/swagger": "^9.6.1",
"@fastify/swagger-ui": "^5.2.4",
Expand Down
5 changes: 4 additions & 1 deletion apps/backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function loadServicesEnvs() {

function loadDatabaseEnvs() {
const schema = z.object({
DATABASE_URL: z.string().url(),
DATABASE_URL: z.string(),
})

return schema.parse(process.env)
Expand All @@ -44,9 +44,12 @@ function loadAppEnvs() {
const schema = z.object({
APP_ENV: z.enum(['dev', 'test', 'production']).optional().default('dev'),
CLIENT_URL: z.string(),
IOS_TOKEN: z.string().optional().default(''),
PORT: z.coerce.number().default(3333),
BASE_URL: z.string().default('http://localhost:3333'),
JWT_SECRET: z.string(),
RATE_LIMIT_MAX: z.coerce.number().optional().default(100),
RATE_LIMIT_TIME_WINDOW_MS: z.coerce.number().optional().default(60_000),
})

return schema.parse(process.env)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { insertFeedback } from '@/db/repositories/feedback-repository'
import { isForeignKeyViolation } from '@/db/utils/postgres-errors'
import { UserNotFoundError } from '@/domain/errors/user-not-found'
import type { InsertFeedbackModel } from '@/domain/entities/feedback'
import { UserNotFoundError } from '@/domain/errors/user-not-found'

export async function createFeedbackService(params: InsertFeedbackModel) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ type GetUserItemsCountInput = {
userId: string
}

export async function getUserItemsCountService({ userId }: GetUserItemsCountInput) {
export async function getUserItemsCountService({
userId,
}: GetUserItemsCountInput) {
const count = await selectUserItemsCount(userId)

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ async function getMovieRuntimes(
watchedItems: Awaited<ReturnType<typeof getAllUserItemsService>>,
redis: FastifyRedis
) {
const movies = watchedItems.userItems.filter(item => item.mediaType === 'MOVIE')
const movies = watchedItems.userItems.filter(
item => item.mediaType === 'MOVIE'
)

return processInBatches(movies, async item => {
const { runtime } = await getTMDBMovieService(redis, {
Expand Down
75 changes: 75 additions & 0 deletions apps/backend/src/http/client-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { timingSafeEqual } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import { config } from '@/config'

const SKIP_PATHS = ['/health', '/complete-stripe-subscription']
const ALLOWED_ORIGINS = ['https://plotwist.app']

function pathMatches(path: string) {
return SKIP_PATHS.some(s => path === s || path.endsWith(s))
}

function timingSafeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false
const bufA = Buffer.from(a, 'utf8')
const bufB = Buffer.from(b, 'utf8')
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB)
}

function allowedOrigin(
origin: string | undefined,
referer: string | undefined
): boolean {
const allowed = [...new Set([config.app.CLIENT_URL, ...ALLOWED_ORIGINS])]
return (
(typeof origin === 'string' && allowed.includes(origin)) ||
(typeof referer === 'string' && allowed.some(o => referer.startsWith(o)))
)
}

function forbidden(reply: FastifyReply) {
return reply.status(403).send({
statusCode: 403,
error: 'Forbidden',
message: 'Request not allowed from this client.',
})
}

function validIosToken(request: FastifyRequest, app: FastifyInstance): boolean {
const expected = config.app.IOS_TOKEN
if (!expected) {
app.log.warn('X-IOS-Token received but IOS_TOKEN is not set.')
return false
}
const received = request.headers['x-ios-token']
return typeof received === 'string' && timingSafeCompare(received, expected)
}

/**
* Production-only guard:
* - X-IOS-Token present → must match IOS_TOKEN.
* - X-Android-Token present → not implemented (403).
* - Otherwise → allow only if Origin/Referer is in allowed list (e.g. plotwist.app).
*/
export function registerClientGuard(app: FastifyInstance) {
app.addHook('onRequest', async (request, reply) => {
if (config.app.APP_ENV !== 'production') return
const path = request.url.split('?')[0]
if (pathMatches(path)) return

const iosToken = request.headers['x-ios-token']
if (typeof iosToken === 'string' && iosToken.length > 0) {
if (!validIosToken(request, app)) return forbidden(reply)
return
}

const androidToken = request.headers['x-android-token']
if (typeof androidToken === 'string' && androidToken.length > 0) {
app.log.warn('X-Android-Token received but not implemented yet.')
return forbidden(reply)
}

if (allowedOrigin(request.headers.origin, request.headers.referer)) return
return forbidden(reply)
})
}
15 changes: 10 additions & 5 deletions apps/backend/src/http/controllers/user-items-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import { getAllUserItemsService } from '@/domain/services/user-items/get-all-use
import { getUserItemService } from '@/domain/services/user-items/get-user-item'
import { getUserItemsService } from '@/domain/services/user-items/get-user-items'
import { getUserItemsCountService } from '@/domain/services/user-items/get-user-items-count'
import { reorderUserItemsService } from '@/domain/services/user-items/reorder-user-items'
import { upsertUserItemService } from '@/domain/services/user-items/upsert-user-item'
import { invalidateUserStatsCache } from '@/domain/services/user-stats/cache-utils'
import { reorderUserItemsService } from '@/domain/services/user-items/reorder-user-items'
import {
deleteUserItemParamsSchema,
getAllUserItemsQuerySchema,
Expand Down Expand Up @@ -91,18 +91,23 @@ export async function upsertUserItemController(
},
})

// Invalidate user stats cache since item status changed
await invalidateUserStatsCache(redis, request.user.id)

// Fetch the user item via Drizzle ORM to ensure consistent format with getUserItem
const result = await getUserItemService({
mediaType,
tmdbId,
userId: request.user.id,
})

// Ensure dates are serialized as ISO strings
const userItem = result.userItem!
const userItem = result.userItem
if (!userItem) {
return reply.status(500).send({
statusCode: 500,
error: 'Internal Server Error',
message: 'User item could not be retrieved after upsert.',
})
}

return reply.status(201).send({
userItem: {
id: userItem.id,
Expand Down
22 changes: 22 additions & 0 deletions apps/backend/src/http/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import fastifyRateLimit from '@fastify/rate-limit'
import type { FastifyInstance } from 'fastify'
import { config } from '@/config'

export function registerRateLimit(app: FastifyInstance) {
app.register(async instance => {
await instance.register(fastifyRateLimit, {
redis: instance.redis,
max: config.app.RATE_LIMIT_MAX,
timeWindow: config.app.RATE_LIMIT_TIME_WINDOW_MS,
skipOnError: true,
errorResponseBuilder: (
_request: unknown,
context: { after: string }
) => ({
statusCode: 429,
error: 'Too Many Requests',
message: `Rate limit exceeded, retry in ${Math.ceil(Number(context.after) / 1000)} seconds`,
}),
})
})
}
1 change: 1 addition & 0 deletions apps/backend/src/http/routes/healthcheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const healthCheck = (app: FastifyInstance) =>
app.route({
method: 'GET',
url: '/health',
config: { rateLimit: false },
handler: (_request, reply) => {
reply.send({ alive: true })
},
Expand Down
32 changes: 8 additions & 24 deletions apps/backend/src/http/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import fastifySwaggerUi from '@fastify/swagger-ui'
import type { FastifyInstance } from 'fastify'

import { config } from '@/config'
import { registerRateLimit } from '../rate-limit'
import { feedbackRoutes } from './feedback'
import { followsRoutes } from './follow'
import { healthCheck } from './healthcheck'
Expand Down Expand Up @@ -40,8 +41,9 @@ export function routes(app: FastifyInstance) {
app.register(fastifyCors, {
origin: getCorsOrigin(),
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Client-Token'],
credentials: true,
strictPreflight: false,
})

app.register(fastifyJwt, {
Expand All @@ -54,6 +56,8 @@ export function routes(app: FastifyInstance) {
url: config.redis.REDIS_URL,
})

registerRateLimit(app)

app.register(usersRoute)
app.register(listsRoute)
app.register(loginRoute)
Expand Down Expand Up @@ -81,36 +85,16 @@ export function routes(app: FastifyInstance) {
return
}

function getCorsOrigin() {
function getCorsOrigin(): true | string[] {
if (config.app.APP_ENV !== 'production') {
return true // Permite todas as origens em dev/test
return true
}

// Em produção, permite múltiplas origens
const allowedOrigins = [
config.app.CLIENT_URL,
'https://plotwist.app',
'https://www.plotwist.app',
]

// Remove duplicatas caso CLIENT_URL já seja plotwist.app
const uniqueOrigins = [...new Set(allowedOrigins)]

return (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void
) => {
// Apps nativos (iOS/Android) podem não enviar header Origin
// Nesse caso, permitimos a requisição
if (!origin) {
callback(null, true)
return
}

if (uniqueOrigins.includes(origin)) {
callback(null, true)
} else {
callback(new Error('Not allowed by CORS'), false)
}
}
return [...new Set(allowedOrigins)]
}
2 changes: 1 addition & 1 deletion apps/backend/src/http/routes/watch-entries.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { FastifyInstance } from 'fastify'
import type { ZodTypeProvider } from 'fastify-type-provider-zod'
import { verifyJwt } from '../middlewares/verify-jwt'
import {
createWatchEntryController,
deleteWatchEntryController,
getWatchEntriesController,
updateWatchEntryController,
} from '../controllers/watch-entries-controller'
import { verifyJwt } from '../middlewares/verify-jwt'
import {
createWatchEntryBodySchema,
createWatchEntryResponseSchema,
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/http/routes/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export async function webhookRoutes(app: FastifyInstance) {
app.withTypeProvider<ZodTypeProvider>().route({
method: 'POST',
url: '/complete-stripe-subscription',
config: { rateLimit: false },
schema: {
description: 'Webhook route',
tags: ['Webhook'],
Expand Down
16 changes: 16 additions & 0 deletions apps/backend/src/http/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,26 @@ export function startServer() {
.send({ message: 'You hit the rate limit! Slow down please!' })
}

if (
typeof (error as { statusCode?: number }).statusCode === 'number' &&
(error as { statusCode: number }).statusCode === 429
) {
if (!reply.sent) {
return reply
.code(429)
.send(
(error as { message?: string }).message ?? 'Rate limit exceeded.'
)
}
return
}

console.error({ error })
return reply.status(500).send({ message: 'Internal server error.' })
})

// TODO: Uncomment this when we have a client guard
// registerClientGuard(app)
routes(app)

app
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"colors" : [
"colors": [
{
"idiom" : "universal"
"idiom": "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}
Loading