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
3 changes: 2 additions & 1 deletion backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { API_PREFIX } from './config/constants.js'
import { env } from './config/env.js'
import { openapi } from './docs/openapi.js'
import { errorHandler, notFoundHandler } from './middlewares/errorHandler.js'
import { enforceAccessRestrictions } from './middlewares/enforceAccessRestrictions.js'
import { apiLimiter } from './middlewares/rateLimiter.js'
import { verifyOrigin } from './middlewares/verifyOrigin.js'
import routes from './routes/index.js'
Expand Down Expand Up @@ -47,7 +48,7 @@ app.get('/api-docs.json', (req, res) => res.json(openapi))
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true }))
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openapi, { explorer: true }))

app.use(API_PREFIX, apiLimiter, verifyOrigin, routes)
app.use(API_PREFIX, apiLimiter, verifyOrigin, enforceAccessRestrictions, routes)

app.use(notFoundHandler)
app.use(errorHandler)
Expand Down
9 changes: 9 additions & 0 deletions backend/src/controllers/accessRestrictionController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { accessRestrictionService } from '../services/accessRestrictionService.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { created, noContent, ok } from '../utils/response.js'

export const accessRestrictionController = {
list: asyncHandler(async (req, res) => ok(res, await accessRestrictionService.list())),
create: asyncHandler(async (req, res) => created(res, await accessRestrictionService.create(req.body, req.user._id))),
remove: asyncHandler(async (req, res) => { await accessRestrictionService.remove(req.params.id); noContent(res) }),
}
2 changes: 2 additions & 0 deletions backend/src/controllers/authController.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { clearAuthCookie, setAuthCookie } from '../utils/tokens.js'
export const authController = {
signup: asyncHandler(async (req, res) => {
const { user, token } = await authService.register(req.body)
await authService.recordDevice(user, req.get('X-Device-ID'))
setAuthCookie(res, token)
created(res, user)
}),

login: asyncHandler(async (req, res) => {
const { user, token } = await authService.login(req.body)
await authService.recordDevice(user, req.get('X-Device-ID'))
setAuthCookie(res, token)
ok(res, user)
}),
Expand Down
4 changes: 3 additions & 1 deletion backend/src/controllers/userController.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { userService } from '../services/userService.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { ok } from '../utils/response.js'
import { noContent, ok } from '../utils/response.js'

export const userController = {
me: (req, res) => ok(res, req.user),
updateMe: asyncHandler(async (req, res) => ok(res, await userService.updateConnections(req.user, req.body.connections))),
list: asyncHandler(async (req, res) => ok(res, await userService.listAll())),
updateStatus: asyncHandler(async (req, res) => ok(res, await userService.updateStatus(req.params.id, req.user, req.body))),
remove: asyncHandler(async (req, res) => { await userService.remove(req.params.id, req.user); noContent(res) }),
}
13 changes: 12 additions & 1 deletion backend/src/docs/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ export const openapi = {
type: 'object',
properties: {
id: { type: 'string' }, firstName: { type: 'string' }, surname: { type: 'string' }, email: { type: 'string', format: 'email' },
role: { type: 'string', enum: ['user', 'admin'] }, connections: { type: 'object', additionalProperties: { type: 'string', nullable: true } },
role: { type: 'string', enum: ['user', 'admin'] }, status: { type: 'string', enum: ['active', 'suspended'] }, statusReason: { type: 'string' },
connections: { type: 'object', additionalProperties: { type: 'string', nullable: true } },
},
},
AuthInput: {
Expand Down Expand Up @@ -135,6 +136,16 @@ export const openapi = {
},
'/contact': { post: { tags: ['Contact'], summary: 'Send a contact message', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['name', 'email', 'message'], properties: { name: { type: 'string' }, email: { type: 'string', format: 'email' }, message: { type: 'string' } } } } } }, responses: { 201: { description: 'Message sent' }, 400: errorResponse } } },
'/admin/users': { get: { tags: ['Admin'], summary: 'List all users', security: adminSecurity, responses: { 200: { description: 'Users' }, 403: errorResponse } } },
'/admin/users/{id}': {
parameters: [idParameter],
patch: { tags: ['Admin'], summary: 'Activate or suspend a user', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['status'], properties: { status: { type: 'string', enum: ['active', 'suspended'] }, reason: { type: 'string', maxLength: 500 } } } } } }, responses: { 200: { description: 'Updated user' }, 403: errorResponse, 404: errorResponse } },
delete: { tags: ['Admin'], summary: 'Delete a user and their submitted recordings', security: adminSecurity, responses: { 204: { description: 'Deleted' }, 403: errorResponse, 404: errorResponse } },
},
'/admin/restrictions': {
get: { tags: ['Admin'], summary: 'List IP and device restrictions', security: adminSecurity, responses: { 200: { description: 'Restrictions' } } },
post: { tags: ['Admin'], summary: 'Restrict an IP address or device ID', security: adminSecurity, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['type', 'value'], properties: { type: { type: 'string', enum: ['ip', 'device'] }, value: { type: 'string' }, reason: { type: 'string', maxLength: 500 } } } } } }, responses: { 201: { description: 'Restriction created' }, 422: errorResponse } },
},
'/admin/restrictions/{id}': { parameters: [idParameter], delete: { tags: ['Admin'], summary: 'Remove an IP or device restriction', security: adminSecurity, responses: { 204: { description: 'Removed' }, 404: errorResponse } } },
'/admin/stats': { get: { tags: ['Admin'], summary: 'Get dashboard statistics', security: adminSecurity, parameters: [{ name: 'days', in: 'query', schema: { type: 'integer', minimum: 7, maximum: 90, default: 14 } }], responses: { 200: { description: 'Statistics' }, 403: errorResponse } } },
'/admin/messages': { get: { tags: ['Admin'], summary: 'List contact messages', security: adminSecurity, parameters: [{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 200, default: 50 } }], responses: { 200: { description: 'Messages' }, 403: errorResponse } } },
},
Expand Down
4 changes: 3 additions & 1 deletion backend/src/middlewares/authenticate.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ async function loadUser(req) {
export const authenticate = asyncHandler(async (req, res, next) => {
const user = await loadUser(req)
if (!user) throw unauthorized()
if (user.status === 'suspended') throw unauthorized('This account is suspended.')
req.user = user
next()
})

/** Attaches req.user when there is a valid session, but lets anonymous visitors through. */
export const optionalAuthenticate = asyncHandler(async (req, res, next) => {
req.user = (await loadUser(req)) || null
const user = await loadUser(req)
req.user = user?.status === 'active' ? user : null
next()
})
13 changes: 13 additions & 0 deletions backend/src/middlewares/enforceAccessRestrictions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { accessRestrictionService } from '../services/accessRestrictionService.js'
import { forbidden } from '../utils/AppError.js'
import { asyncHandler } from '../utils/asyncHandler.js'

export const enforceAccessRestrictions = asyncHandler(async (req, res, next) => {
// Admin routes stay reachable so an administrator can remove a mistaken restriction.
if (req.path === '/admin' || req.path.startsWith('/admin/')) return next()
const deviceId = req.get('X-Device-ID')?.slice(0, 128)
if (await accessRestrictionService.isRequestRestricted(req.ip, deviceId)) {
throw forbidden('Access from this network or device has been restricted.')
}
next()
})
16 changes: 16 additions & 0 deletions backend/src/middlewares/validators/adminSchemas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { isIP } from 'node:net'
import { z } from 'zod'

export const updateUserStatusSchema = z.object({
status: z.enum(['active', 'suspended']),
reason: z.string().trim().max(500, 'Keep the reason under 500 characters.').optional().default(''),
})

export const accessRestrictionSchema = z.object({
type: z.enum(['ip', 'device']),
value: z.string().trim().min(1, 'Enter an IP address or device ID.').max(128),
reason: z.string().trim().max(500, 'Keep the reason under 500 characters.').optional().default(''),
}).superRefine(({ type, value }, context) => {
const valid = type === 'ip' ? isIP(value) !== 0 : /^[A-Za-z0-9._:-]{16,128}$/.test(value)
if (!valid) context.addIssue({ code: 'custom', path: ['value'], message: type === 'ip' ? 'Enter a valid IPv4 or IPv6 address.' : 'Enter a valid device ID.' })
})
18 changes: 18 additions & 0 deletions backend/src/models/AccessRestriction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import mongoose from 'mongoose'
import { jsonOptions } from '../utils/mongoose.js'

const accessRestrictionSchema = new mongoose.Schema({
type: { type: String, enum: ['ip', 'device'], required: true },
identifierHash: { type: String, required: true },
identifierHint: { type: String, required: true },
reason: { type: String, trim: true, maxlength: 500, default: '' },
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
}, {
timestamps: true,
versionKey: false,
toJSON: jsonOptions((doc, ret) => { delete ret.identifierHash; return ret }),
})

accessRestrictionSchema.index({ type: 1, identifierHash: 1 }, { unique: true })

export const AccessRestriction = mongoose.model('AccessRestriction', accessRestrictionSchema)
5 changes: 4 additions & 1 deletion backend/src/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ const userSchema = new mongoose.Schema({
surname: { type: String, required: true, trim: true, maxlength: 60 },
passwordHash: { type: String, required: true, select: false },
role: { type: String, enum: ROLE_LIST, default: ROLES.USER },
status: { type: String, enum: ['active', 'suspended'], default: 'active' },
statusReason: { type: String, trim: true, maxlength: 500, default: '' },
lastDeviceId: { type: String, select: false, default: null },
connections: {
github: { type: String, default: null },
linkedin: { type: String, default: null },
},
}, {
timestamps: true,
toJSON: jsonOptions((doc, ret) => { delete ret.passwordHash; delete ret.updatedAt; return ret }),
toJSON: jsonOptions((doc, ret) => { delete ret.passwordHash; delete ret.updatedAt; delete ret.lastDeviceId; return ret }),
})

export const User = mongoose.model('User', userSchema)
8 changes: 8 additions & 0 deletions backend/src/repositories/submissionRepo.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export const submissionRepo = {
recent: (limit) => Submission.find().sort({ createdAt: -1 }).limit(limit),
countForPair: (userId, videoId) => Submission.countDocuments({ user: userId, video: videoId }),
count: () => Submission.countDocuments(),
async removeForUser(userId) {
const submissions = await Submission.find({ user: userId }).select('recording')
await Promise.all([
Submission.deleteMany({ user: userId }),
SubmissionCooldown.deleteMany({ user: userId }),
])
return submissions.map((submission) => submission.recording)
},
// Just the timestamps, for bucketing into days.
createdSince: (date) => Submission.find({ createdAt: { $gte: date } }).select('createdAt').lean(),
countsByVideo: () => Submission.aggregate([{ $group: { _id: '$video', count: { $sum: 1 } } }]),
Expand Down
7 changes: 6 additions & 1 deletion backend/src/repositories/userRepo.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export const userRepo = {
findManyByIds: (ids) => User.find({ _id: { $in: ids } }),
create: (data) => User.create(data),
save: (doc) => doc.save(),
list: ({ role } = {}) => User.find(role ? { role } : {}).sort({ createdAt: 1 }),
list: ({ role, includeDeviceIds = false } = {}) => {
const query = User.find(role ? { role } : {}).sort({ createdAt: 1 })
return includeDeviceIds ? query.select('+lastDeviceId') : query
},
countByRole: (role) => User.countDocuments({ role }),
countActiveAdmins: () => User.countDocuments({ role: 'admin', status: 'active' }),
remove: (id) => User.deleteOne({ _id: id }),
}
8 changes: 8 additions & 0 deletions backend/src/routes/adminRoutes.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import { Router } from 'express'
import { ROLES } from '../config/constants.js'
import { adminController } from '../controllers/adminController.js'
import { accessRestrictionController } from '../controllers/accessRestrictionController.js'
import { contactController } from '../controllers/contactController.js'
import { userController } from '../controllers/userController.js'
import { authenticate } from '../middlewares/authenticate.js'
import { requireRole } from '../middlewares/requireRole.js'
import { validate } from '../middlewares/validate.js'
import { limitQuerySchema, statsQuerySchema } from '../middlewares/validators/miscSchemas.js'
import { accessRestrictionSchema, updateUserStatusSchema } from '../middlewares/validators/adminSchemas.js'
import { idParams } from '../middlewares/validators/common.js'

const router = Router()

router.use(authenticate, requireRole(ROLES.ADMIN))
router.get('/users', userController.list)
router.patch('/users/:id', validate({ params: idParams, body: updateUserStatusSchema }), userController.updateStatus)
router.delete('/users/:id', validate({ params: idParams }), userController.remove)
router.get('/restrictions', accessRestrictionController.list)
router.post('/restrictions', validate({ body: accessRestrictionSchema }), accessRestrictionController.create)
router.delete('/restrictions/:id', validate({ params: idParams }), accessRestrictionController.remove)
router.get('/stats', validate({ query: statsQuerySchema }), adminController.stats)
router.get('/messages', validate({ query: limitQuerySchema }), contactController.list)

Expand Down
44 changes: 44 additions & 0 deletions backend/src/services/accessRestrictionService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createHash } from 'node:crypto'
import { AccessRestriction } from '../models/AccessRestriction.js'
import { conflict, notFound } from '../utils/AppError.js'

const hash = (value) => createHash('sha256').update(value.trim().toLowerCase()).digest('hex')

function hint(type, value) {
if (type === 'ip') {
const parts = value.split('.')
if (parts.length === 4) return `${parts[0]}.${parts[1]}.${parts[2]}.x`
return `${value.slice(0, 12)}...`
}
return `${value.slice(0, 8)}...${value.slice(-4)}`
}

export const accessRestrictionService = {
list: () => AccessRestriction.find().sort({ createdAt: -1 }),

async create({ type, value, reason }, adminId) {
try {
return await AccessRestriction.create({
type,
identifierHash: hash(value),
identifierHint: hint(type, value),
reason,
createdBy: adminId,
})
} catch (error) {
if (error?.code === 11000) throw conflict('That identifier is already restricted.', { value: 'That identifier is already restricted.' })
throw error
}
},

async remove(id) {
const result = await AccessRestriction.deleteOne({ _id: id })
if (!result.deletedCount) throw notFound('That restriction does not exist.')
},

async isRequestRestricted(ip, deviceId) {
const identifiers = [{ type: 'ip', identifierHash: hash(ip) }]
if (deviceId) identifiers.push({ type: 'device', identifierHash: hash(deviceId) })
return AccessRestriction.exists({ $or: identifiers })
},
}
7 changes: 7 additions & 0 deletions backend/src/services/authService.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,16 @@ export const authService = {
const user = await userRepo.findByEmail(email, { withPassword: true })
const matches = await bcrypt.compare(password, user?.passwordHash || DUMMY_HASH)
if (!user || !matches) throw unauthorized('That email and password do not match. Check them and try again.')
if (user.status === 'suspended') throw unauthorized('This account is suspended.')
return { user, token: signToken(user.id) }
},

async recordDevice(user, deviceId) {
if (!/^[A-Za-z0-9._:-]{16,128}$/.test(deviceId || '')) return user
user.lastDeviceId = deviceId
return userRepo.save(user)
},

/** Admin accounts come from environment variables, never from the signup form. Safe to run on every boot. */
async ensureAdminFromEnv() {
const existing = await userRepo.findByEmail(env.ADMIN_EMAIL)
Expand Down
32 changes: 31 additions & 1 deletion backend/src/services/userService.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { ROLES } from '../config/constants.js'
import { submissionRepo } from '../repositories/submissionRepo.js'
import { userRepo } from '../repositories/userRepo.js'
import { storageService } from './storage/index.js'
import { conflict, forbidden, notFound } from '../utils/AppError.js'

export const userService = {
async updateConnections(user, connections) {
Expand All @@ -11,5 +14,32 @@ export const userService = {
},

listContributors: () => userRepo.list({ role: ROLES.USER }),
listAll: () => userRepo.list(),
async listAll() {
const users = await userRepo.list({ includeDeviceIds: true })
return users.map((user) => ({ ...user.toJSON(), lastDeviceId: user.lastDeviceId }))
},

async updateStatus(id, actor, { status, reason = '' }) {
const user = await userRepo.findById(id)
if (!user) throw notFound('That user does not exist.')
if (user.id === actor.id) throw forbidden('You cannot suspend your own account.')
if (user.role === ROLES.ADMIN && user.status === 'active' && status === 'suspended' && await userRepo.countActiveAdmins() <= 1) {
throw conflict('The last active administrator cannot be suspended.')
}
user.status = status
user.statusReason = status === 'suspended' ? reason : ''
return userRepo.save(user)
},

async remove(id, actor) {
const user = await userRepo.findById(id)
if (!user) throw notFound('That user does not exist.')
if (user.id === actor.id) throw forbidden('You cannot delete your own account.')
if (user.role === ROLES.ADMIN && user.status === 'active' && await userRepo.countActiveAdmins() <= 1) {
throw conflict('The last active administrator cannot be deleted.')
}
const recordings = await submissionRepo.removeForUser(user._id)
await userRepo.remove(user._id)
await Promise.all(recordings.map((recording) => storageService.remove(recording)))
},
}
Loading
Loading