Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,10 @@ MAX_RECORDING_UPLOAD_MB=80

RATE_LIMIT_ENABLED=true

# Optional Upstash Redis REST credentials for caching public landing-page counts
# for 12 hours. Set both values to enable the cache.
# UPSTASH_REDIS_REST_URL=https://<database-id>.upstash.io
# UPSTASH_REDIS_REST_TOKEN=

# Minimum gap, in milliseconds, between two submissions for the same (user, video).
SUBMISSION_COOLDOWN_MS=30000
2 changes: 2 additions & 0 deletions backend/docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ shared credentials.
| --- | --- | --- |
| `RATE_LIMIT_ENABLED` | `true` or `false`; default `true` | Enables API and upload rate limits. Keep enabled outside local debugging. |
| `SUBMISSION_COOLDOWN_MS` | Set manually; default `30000` | Minimum delay between submissions for the same user and reference video. |
| `UPSTASH_REDIS_REST_URL` | Upstash Redis database REST URL | Optional shared cache for public landing-page counts. Set together with `UPSTASH_REDIS_REST_TOKEN`. |
| `UPSTASH_REDIS_REST_TOKEN` | Upstash Redis database REST token; keep secret | Enables the public stats cache. Cached values expire after 12 hours; the API continues using MongoDB if Redis is unavailable. |
| `WEB3FORMS_ACCESS_KEY` | Create an access key at Web3Forms | Required in production for contact-message delivery. |
| `CONTACT_DAILY_LIMIT` | Set manually; default `25` | Maximum successfully delivered messages per UTC day. |
| `CONTACT_USER_DAILY_LIMIT` | Set manually; default `1` | Maximum contact messages per account per UTC day. |
Expand Down
4 changes: 2 additions & 2 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.0.0",
"version": "1.0.1",
"description": "SignPakCommons API: Express, Node.js and MongoDB",
"main": "server.js",
"scripts": {
Expand Down
10 changes: 10 additions & 0 deletions backend/src/config/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,17 @@ const schema = z.object({

RATE_LIMIT_ENABLED: bool.default(true),
SUBMISSION_COOLDOWN_MS: z.coerce.number().int().positive().default(30_000),
UPSTASH_REDIS_REST_URL: z.string().url().optional(),
UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(),
}).superRefine((data, context) => {
if (Boolean(data.UPSTASH_REDIS_REST_URL) !== Boolean(data.UPSTASH_REDIS_REST_TOKEN)) {
context.addIssue({
code: 'custom',
path: ['UPSTASH_REDIS_REST_URL'],
message: 'Set both UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN to enable the Redis cache.',
})
}

if (data.NODE_ENV === 'production') {
if (!data.BREVO_API_KEY) {
context.addIssue({ code: 'custom', path: ['BREVO_API_KEY'], message: 'Required in production for email verification and password reset.' })
Expand Down
7 changes: 7 additions & 0 deletions backend/src/controllers/publicStatsController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { publicStatsService } from '../services/publicStatsService.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { ok } from '../utils/response.js'

export const publicStatsController = {
get: asyncHandler(async (req, res) => ok(res, await publicStatsService.get())),
}
17 changes: 17 additions & 0 deletions backend/src/docs/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,23 @@ export const openapi = {
},
},
},
'/public/stats': {
get: {
tags: ['Public'],
summary: 'Get cached landing-page aggregate counts',
responses: {
200: jsonResponse('Public stats', {
type: 'object',
required: ['contributors', 'publishedVideos', 'categories'],
properties: {
contributors: { type: 'integer', minimum: 0 },
publishedVideos: { type: 'integer', minimum: 0 },
categories: { type: 'integer', minimum: 0 },
},
}),
},
},
},
'/docs.json': {
get: { tags: ['Health'], summary: 'Get the OpenAPI document', responses: { 200: { description: 'OpenAPI JSON document' } } },
},
Expand Down
2 changes: 2 additions & 0 deletions backend/src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import authRoutes from './authRoutes.js'
import categoryRoutes from './categoryRoutes.js'
import contactRoutes from './contactRoutes.js'
import demoVideoRoutes from './demoVideoRoutes.js'
import publicStatsRoutes from './publicStatsRoutes.js'
import submissionRoutes from './submissionRoutes.js'
import userRoutes from './userRoutes.js'
import videoRoutes from './videoRoutes.js'
Expand All @@ -34,6 +35,7 @@ router.use('/demo-video', demoVideoRoutes)
router.use('/videos', videoRoutes)
router.use('/submissions', submissionRoutes)
router.use('/contact', contactRoutes)
router.use('/public', publicStatsRoutes)
router.use('/admin', adminRoutes)

export default router
8 changes: 8 additions & 0 deletions backend/src/routes/publicStatsRoutes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Router } from 'express'
import { publicStatsController } from '../controllers/publicStatsController.js'

const router = Router()

router.get('/stats', publicStatsController.get)

export default router
39 changes: 39 additions & 0 deletions backend/src/services/cache/upstashRedis.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { env } from '../../config/env.js'

const configured = Boolean(env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN)

async function command(...args) {
const response = await fetch(env.UPSTASH_REDIS_REST_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(args),
signal: AbortSignal.timeout(2500),
})
if (!response.ok) throw new Error(`Upstash Redis request failed (${response.status}).`)
const payload = await response.json()
if (payload.error) throw new Error(`Upstash Redis command failed: ${payload.error}`)
return payload.result
}

export const upstashRedis = {
get enabled() {
return configured && !env.isTest
},

async get(key) {
if (!this.enabled) return null
const result = await command('GET', key)
if (result !== null && typeof result !== 'string') {
throw new Error('Upstash Redis returned an invalid cache value.')
}
return result
},

async set(key, value, ttlSeconds) {
if (!this.enabled) return
await command('SET', key, JSON.stringify(value), 'EX', ttlSeconds)
},
}
51 changes: 51 additions & 0 deletions backend/src/services/publicStatsService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ROLES, VIDEO_STATUS } from '../config/constants.js'
import { env } from '../config/env.js'
import { categoryRepo } from '../repositories/categoryRepo.js'
import { userRepo } from '../repositories/userRepo.js'
import { videoRepo } from '../repositories/videoRepo.js'
import { logger } from '../utils/logger.js'
import { upstashRedis } from './cache/upstashRedis.js'

const CACHE_KEY = 'signpak:public-stats:v1'
const CACHE_TTL_SECONDS = 12 * 60 * 60

function isValidStats(value) {
return value
&& Number.isInteger(value.contributors) && value.contributors >= 0
&& Number.isInteger(value.publishedVideos) && value.publishedVideos >= 0
&& Number.isInteger(value.categories) && value.categories >= 0
}

async function loadStats() {
const [contributors, publishedVideos, categories] = await Promise.all([
userRepo.countByRole(ROLES.USER),
videoRepo.count({ status: VIDEO_STATUS.PUBLISHED, category: { $ne: null } }),
categoryRepo.count(),
])
return { contributors, publishedVideos, categories }
}

export const publicStatsService = {
async get() {
if (!upstashRedis.enabled) return loadStats()

try {
const cached = await upstashRedis.get(CACHE_KEY)
if (cached !== null) {
const stats = JSON.parse(cached)
if (isValidStats(stats)) return stats
logger.warn('Ignoring invalid cached public stats from Upstash Redis.')
}
} catch (error) {
logger.warn({ err: error }, 'Could not read public stats from Upstash Redis; querying the database.')
}

const stats = await loadStats()
try {
await upstashRedis.set(CACHE_KEY, stats, CACHE_TTL_SECONDS)
} catch (error) {
logger.warn({ err: error }, 'Could not cache public stats in Upstash Redis.')
}
return stats
},
}
13 changes: 13 additions & 0 deletions backend/tests/api-basics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ describe('basics', () => {
assert.deepEqual(res.body.data.database, 'connected')
})

test('public landing stats return aggregate counts without authentication', async () => {
const client = new Client(base)
const response = await client.get('/public/stats')
assert.equal(response.status, 200)
assert.deepEqual(Object.keys(response.body.data).sort(), ['categories', 'contributors', 'publishedVideos'])
for (const value of Object.values(response.body.data)) {
assert.ok(Number.isInteger(value) && value >= 0)
}
assert.equal(response.body.data.categories, (await client.get('/categories')).body.data.length)
assert.equal(response.body.data.publishedVideos, (await client.get('/videos')).body.data.length)
assert.ok(response.body.data.contributors >= 2)
})

test('unknown routes and bad JSON use the error shape', async () => {
const c = new Client(base)
const missing = await c.get('/nope')
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.0.1",
"version": "1.0.3",
"type": "module",
"engines": {
"node": ">=20.19.0"
Expand Down
Binary file added frontend/public/MCS_Library.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added frontend/public/NUST_H-12.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading