mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Grades a submission against the stored correct answers.
|
||||
*
|
||||
* The breakdown returned here is what gets sent back to the client, so it
|
||||
* deliberately never includes which option(s) were correct — only whether
|
||||
* the user's own answer for each question was right or wrong. Explanation
|
||||
* text is included only when the question was answered correctly, since
|
||||
* showing it for a wrong answer would effectively reveal the correct one.
|
||||
*
|
||||
* @param {Array} questions - QuizQuestion rows w/ .options (incl. is_correct), already fetched
|
||||
* @param {Object} answers - { [question_id]: optionId | optionId[] } submitted by the client
|
||||
*/
|
||||
function gradeSubmission(questions, answers = {}) {
|
||||
let totalPoints = 0;
|
||||
let earnedPoints = 0;
|
||||
|
||||
const breakdown = questions.map((q) => {
|
||||
const points = q.points ?? 1;
|
||||
totalPoints += points;
|
||||
|
||||
const correctIds = (q.options ?? [])
|
||||
.filter((o) => o.is_correct)
|
||||
.map((o) => o.option_id);
|
||||
|
||||
const submitted = answers[q.question_id];
|
||||
const submittedIds = Array.isArray(submitted)
|
||||
? submitted
|
||||
: (submitted !== undefined && submitted !== null ? [submitted] : []);
|
||||
|
||||
const isCorrect =
|
||||
submittedIds.length === correctIds.length &&
|
||||
correctIds.every((id) => submittedIds.includes(id));
|
||||
|
||||
if (isCorrect) earnedPoints += points;
|
||||
|
||||
return {
|
||||
question_id: q.question_id,
|
||||
type: q.type,
|
||||
question: q.question,
|
||||
points,
|
||||
is_correct: isCorrect,
|
||||
selected_option_ids: submittedIds,
|
||||
explanation: isCorrect ? (q.explanation ?? null) : null,
|
||||
options: (q.options ?? []).map((o) => ({
|
||||
option_id: o.option_id,
|
||||
text: o.text,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const score = totalPoints > 0 ? Math.round((earnedPoints / totalPoints) * 100) : 0;
|
||||
return { totalPoints, earnedPoints, score, breakdown };
|
||||
}
|
||||
|
||||
module.exports = { gradeSubmission };
|
||||
@@ -0,0 +1,67 @@
|
||||
// This will do mandatory call
|
||||
const MAX_ATTEMPTS = 10;
|
||||
const ATTEMPT_WINDOW_HOURS = 24;
|
||||
const COOLDOWN_MINUTES = 60;
|
||||
|
||||
// Fisher-Yates shuffle of each question's options. Pure — returns new
|
||||
// arrays/objects, never mutates input. Grading is unaffected since
|
||||
// submitUnitQuiz/submitCourseAssessment always re-fetch questions fresh
|
||||
// from the DB and never trust shuffled client-facing order.
|
||||
function shuffleOptions(questions) {
|
||||
return questions.map((q) => {
|
||||
const options = [...(q.options ?? [])];
|
||||
for (let i = options.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[options[i], options[j]] = [options[j], options[i]];
|
||||
}
|
||||
return { ...q, options };
|
||||
});
|
||||
}
|
||||
|
||||
// Single source of truth for both the GET-time info fields and the
|
||||
// submit-time enforcement check. Doesn't care about input order —
|
||||
// derives best/most-recent itself, so callers can just fetch attempts
|
||||
// with no ORDER BY.
|
||||
function getAttemptStatus(attempts) {
|
||||
const now = new Date();
|
||||
const windowStart = new Date(now.getTime() - ATTEMPT_WINDOW_HOURS * 60 * 60 * 1000);
|
||||
const attemptsInWindow = attempts.filter((a) => new Date(a.createdAt) >= windowStart);
|
||||
|
||||
const attempt_count = attempts.length; // lifetime — still used for has_passed/best_attempt
|
||||
const has_passed = attempts.some((a) => a.passed);
|
||||
|
||||
const best_attempt = attempts.reduce(
|
||||
(best, a) => (!best || a.score > best.score ? a : best),
|
||||
null
|
||||
);
|
||||
|
||||
const most_recent = attempts.reduce(
|
||||
(latest, a) => (!latest || new Date(a.createdAt) > new Date(latest.createdAt) ? a : latest),
|
||||
null
|
||||
);
|
||||
|
||||
let cooldown_until = null;
|
||||
if (most_recent) {
|
||||
const unlockAt = new Date(new Date(most_recent.createdAt).getTime() + COOLDOWN_MINUTES * 60000);
|
||||
if (unlockAt > now) cooldown_until = unlockAt.toISOString();
|
||||
}
|
||||
|
||||
const attempts_remaining = Math.max(0, MAX_ATTEMPTS - attemptsInWindow.length);
|
||||
|
||||
let window_reset_at = null;
|
||||
if (attempts_remaining === 0 && attemptsInWindow.length > 0) {
|
||||
const oldestInWindow = attemptsInWindow.reduce(
|
||||
(oldest, a) => (!oldest || new Date(a.createdAt) < new Date(oldest.createdAt) ? a : oldest),
|
||||
null
|
||||
);
|
||||
window_reset_at = new Date(
|
||||
new Date(oldestInWindow.createdAt).getTime() + ATTEMPT_WINDOW_HOURS * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
}
|
||||
|
||||
const can_attempt = attempts_remaining > 0 && !cooldown_until;
|
||||
|
||||
return { attempt_count, has_passed, best_attempt, attempts_remaining, cooldown_until, window_reset_at, can_attempt };
|
||||
}
|
||||
|
||||
module.exports = { MAX_ATTEMPTS, ATTEMPT_WINDOW_HOURS, COOLDOWN_MINUTES, shuffleOptions, getAttemptStatus };
|
||||
@@ -0,0 +1,94 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: google_oidc.util.js
|
||||
* Type of Program: Utility
|
||||
* Description: Manual Google OIDC primitives — no Passport.
|
||||
* Provides state/nonce generation, PKCE (S256), authorization URL construction,
|
||||
* authorization code exchange, and ID token verification via google-auth-library.
|
||||
*
|
||||
* OIDC flow summary:
|
||||
* 1. googleRedirect → generateState + generateNonce + generatePKCE → buildAuthUrl → redirect
|
||||
* 2. googleCallback → verify state cookie → exchangeCode → verifyIdToken → create/find user
|
||||
*
|
||||
* Security properties:
|
||||
* - state : anti-CSRF; verified against signed httpOnly cookie
|
||||
* - nonce : anti-replay; embedded in ID token by Google and checked here
|
||||
* - PKCE S256 : prevents auth code interception even if code leaks
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 21, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { OAuth2Client } = require('google-auth-library');
|
||||
const axios = require('axios');
|
||||
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
// Lazily initialised so the module can be required before env is loaded.
|
||||
let _client;
|
||||
const getClient = () => {
|
||||
if (!_client) _client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
|
||||
return _client;
|
||||
};
|
||||
|
||||
// ── Generators ─────────────────────────────────────────────────────────────────
|
||||
const generateState = () => crypto.randomBytes(32).toString('hex');
|
||||
const generateNonce = () => crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const generatePKCE = () => {
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||||
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
return { codeVerifier, codeChallenge };
|
||||
};
|
||||
|
||||
// ── Auth URL ───────────────────────────────────────────────────────────────────
|
||||
const buildAuthUrl = (state, nonce, codeChallenge) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: process.env.GOOGLE_CLIENT_ID,
|
||||
redirect_uri: process.env.GOOGLE_CALLBACK_URL,
|
||||
response_type: 'code',
|
||||
scope: 'openid email profile',
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
access_type: 'offline',
|
||||
prompt: 'select_account',
|
||||
});
|
||||
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
||||
};
|
||||
|
||||
// ── Code exchange ──────────────────────────────────────────────────────────────
|
||||
const exchangeCode = async (code, codeVerifier) => {
|
||||
const { data } = await axios.post(
|
||||
GOOGLE_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
code,
|
||||
client_id: process.env.GOOGLE_CLIENT_ID,
|
||||
client_secret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
redirect_uri: process.env.GOOGLE_CALLBACK_URL,
|
||||
grant_type: 'authorization_code',
|
||||
code_verifier: codeVerifier,
|
||||
}).toString(),
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
|
||||
);
|
||||
return data; // { access_token, id_token, expires_in, token_type, ... }
|
||||
};
|
||||
|
||||
// ── ID token verification ──────────────────────────────────────────────────────
|
||||
const verifyIdToken = async (idToken, expectedNonce) => {
|
||||
const ticket = await getClient().verifyIdToken({
|
||||
idToken,
|
||||
audience: process.env.GOOGLE_CLIENT_ID,
|
||||
});
|
||||
const payload = ticket.getPayload();
|
||||
|
||||
// Verify nonce to prevent token replay attacks.
|
||||
if (payload.nonce !== expectedNonce) throw new Error('Nonce mismatch');
|
||||
|
||||
return payload; // { sub, email, given_name, family_name, name, picture, ... }
|
||||
};
|
||||
|
||||
module.exports = { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken };
|
||||
@@ -0,0 +1,25 @@
|
||||
const mdl_UserActivity = require('../models/users/user_activity.mdl');
|
||||
|
||||
/**
|
||||
* Fire-and-forget activity logger. Never throws — failures are console-logged only.
|
||||
*
|
||||
* @param {number} userId
|
||||
* @param {string} action — e.g. 'login', 'submit_task', 'deactivate_user'
|
||||
* @param {Object} [opts]
|
||||
* @param {string} [opts.entityType] — e.g. 'user', 'task', 'lesson'
|
||||
* @param {number} [opts.entityId]
|
||||
* @param {number} [opts.sessionId]
|
||||
* @param {Object} [opts.details] — free-form JSONB context
|
||||
*/
|
||||
function logActivity(userId, action, { entityType = null, entityId = null, sessionId = null, details = null } = {}) {
|
||||
mdl_UserActivity.create({
|
||||
user_id: userId,
|
||||
action,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
session_id: sessionId,
|
||||
details,
|
||||
}).catch((err) => console.error('[USER_ACTIVITY] Failed to log:', action, err.message));
|
||||
}
|
||||
|
||||
module.exports = logActivity;
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
|
||||
const axios = require('axios');
|
||||
const UAParser = require('ua-parser-js');
|
||||
|
||||
const LOCALHOST = new Set(['::1', '127.0.0.1', '::ffff:127.0.0.1']);
|
||||
|
||||
const getIP = (req) => {
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
if (forwarded) return forwarded.split(',')[0].trim();
|
||||
return req.ip || req.connection?.remoteAddress || null;
|
||||
};
|
||||
|
||||
const parseUA = (uaString) => {
|
||||
const p = new UAParser(uaString || '');
|
||||
return {
|
||||
ua: uaString || 'unknown',
|
||||
browser: [p.getBrowser().name, p.getBrowser().version].filter(Boolean).join(' ') || 'unknown',
|
||||
os: [p.getOS().name, p.getOS().version].filter(Boolean).join(' ') || 'unknown',
|
||||
device: p.getDevice().type || 'desktop',
|
||||
};
|
||||
};
|
||||
|
||||
const getGeo = async (ip) => {
|
||||
if (!ip || LOCALHOST.has(ip)) return { country: null, region: null, city: null, lat: null, lon: null };
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`http://ip-api.com/json/${ip}?fields=status,country,regionName,city,lat,lon`,
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
if (data.status !== 'success') return { country: null, region: null, city: null, lat: null, lon: null };
|
||||
return { country: data.country, region: data.regionName, city: data.city, lat: data.lat, lon: data.lon };
|
||||
} catch {
|
||||
return { country: null, region: null, city: null, lat: null, lon: null };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a rich session info object from the request.
|
||||
* Used for both login_info and logout_info.
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {Object} extras — e.g. { forced_by: admin_user_id }
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
const buildSessionInfo = async (req, extras = {}) => {
|
||||
const ip = getIP(req);
|
||||
const geo = await getGeo(ip);
|
||||
|
||||
return {
|
||||
date: new Date().toISOString(),
|
||||
ip_address: ip,
|
||||
country: geo.country,
|
||||
region: geo.region,
|
||||
city: geo.city,
|
||||
lat: geo.lat,
|
||||
lon: geo.lon,
|
||||
device_info: parseUA(req.headers['user-agent']),
|
||||
...extras,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = buildSessionInfo;
|
||||
Reference in New Issue
Block a user