mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -0,0 +1,51 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: accountStatus.service.js
|
||||
* Type of Program: Service
|
||||
* Description: Shared active/ban-status check + auto-lift for expired temporary
|
||||
* bans. Used by every entry point that authenticates a user
|
||||
* (login, Google OAuth callback, refresh token, JWT middleware)
|
||||
* so the ban logic lives in exactly one place.
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jul. 4, 2026
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const { checkAccountStatus } = require('../services/accountStatus.service');
|
||||
* const status = await checkAccountStatus(user);
|
||||
* if (!status.ok) { ... map status.code ('deactivated' | 'banned') to a response ... }
|
||||
***********************************************************************************************************************************************************************/
|
||||
const mdl_UserBans = require('../models/users/user_bans.mdl');
|
||||
|
||||
/**
|
||||
* Checks whether a user is allowed to authenticate right now.
|
||||
* Auto-lifts an expired temporary ban as a side effect.
|
||||
*
|
||||
* @param {import('../models/users/users.mdl')} user
|
||||
* @returns {Promise<{ok: true} | {ok: false, code: 'deactivated'|'banned', reason?: string|null, ban_type?: string|null, ban_expires_at?: Date|null}>}
|
||||
*/
|
||||
const checkAccountStatus = async (user) => {
|
||||
if (!user.is_active) return { ok: false, code: 'deactivated' };
|
||||
|
||||
if (user.is_banned) {
|
||||
const stillBanned = !user.ban_expires_at || new Date() < new Date(user.ban_expires_at);
|
||||
if (stillBanned) {
|
||||
const activeBan = await mdl_UserBans.findOne({
|
||||
where: { user_id: user.user_id, is_lifted: false },
|
||||
order: [['banned_at', 'DESC']],
|
||||
attributes: ['reason', 'ban_type', 'expires_at'],
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
code: 'banned',
|
||||
reason: activeBan?.reason ?? null,
|
||||
ban_type: activeBan?.ban_type ?? null,
|
||||
ban_expires_at: activeBan?.expires_at ?? null,
|
||||
};
|
||||
}
|
||||
// Expired temporary ban — auto-lift
|
||||
await user.update({ is_banned: false, ban_expires_at: null });
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
module.exports = { checkAccountStatus };
|
||||
+92
-23
@@ -6,12 +6,14 @@
|
||||
// deleteFile(key)
|
||||
//
|
||||
// Required .env vars:
|
||||
// S3_ENDPOINT – http://127.0.0.1:3900
|
||||
// S3_ENDPOINT – http://127.0.0.1:3900 (internal — always used for uploads/deletes)
|
||||
// S3_REGION – garage
|
||||
// S3_ACCESS_KEY
|
||||
// S3_SECRET_KEY
|
||||
// S3_BUCKET – your-bucket-name
|
||||
// S3_PUBLIC_URL – https://cdn.yourdomain.com
|
||||
// S3_PUBLIC_URL – https://cdn.yourdomain.com (used for browser-facing URLs
|
||||
// whenever S3_ENDPOINT isn't reachable from this machine —
|
||||
// see resolvePublicHost() below)
|
||||
|
||||
const { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadBucketCommand } = require("@aws-sdk/client-s3");
|
||||
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");
|
||||
@@ -26,6 +28,8 @@ const credentials = {
|
||||
};
|
||||
|
||||
// Internal client — uploads, deletes, direct streams from the server itself.
|
||||
// Always targets S3_ENDPOINT: these calls originate from this machine, so the
|
||||
// internal address is the correct (and only) one to use.
|
||||
const s3 = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION || "garage",
|
||||
@@ -33,19 +37,80 @@ const s3 = new S3Client({
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
// Public client — generates pre-signed URLs using the externally reachable
|
||||
// endpoint (S3_PUBLIC_URL) so URLs work from any machine, not just the one
|
||||
// running Garage. Falls back to the internal endpoint when S3_PUBLIC_URL is
|
||||
// unset (single-machine dev).
|
||||
const s3Public = new S3Client({
|
||||
endpoint: process.env.S3_PUBLIC_URL ?? process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION || "garage",
|
||||
credentials,
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
const DEFAULT_BUCKET = process.env.S3_BUCKET;
|
||||
const PUBLIC_URL = (process.env.S3_PUBLIC_URL || "").replace(/\/$/, "");
|
||||
|
||||
// ─── Public host resolution ───────────────────────────────────────────────────
|
||||
//
|
||||
// URLs handed to browsers (file_url, presigned GET links) need a host reachable
|
||||
// from wherever the client sits. S3_ENDPOINT (e.g. 127.0.0.1:3900) only works
|
||||
// from the machine running Garage itself; S3_PUBLIC_URL is the externally
|
||||
// reachable address (tunnel/CDN/domain).
|
||||
//
|
||||
// Rather than always preferring one, probe S3_ENDPOINT and use it when it's
|
||||
// actually reachable (same-machine dev setup — no extra hop through the
|
||||
// tunnel), falling back to S3_PUBLIC_URL when it isn't (any other machine).
|
||||
//
|
||||
// The probe runs once at startup and then on a background timer — never on
|
||||
// the request path itself. A machine without Garage would otherwise pay the
|
||||
// full HeadBucket timeout on whichever upload/asset request happens to land
|
||||
// right after the cache expires; polling in the background means every
|
||||
// request just reads the last known-good host instantly.
|
||||
const PROBE_TIMEOUT_MS = 1500;
|
||||
const PROBE_CACHE_MS = 15000;
|
||||
|
||||
let hostCache = { host: process.env.S3_PUBLIC_URL || process.env.S3_ENDPOINT || "" };
|
||||
|
||||
async function probeEndpoint(endpoint) {
|
||||
const probe = new S3Client({
|
||||
endpoint,
|
||||
region: process.env.S3_REGION || "garage",
|
||||
credentials,
|
||||
forcePathStyle: true,
|
||||
});
|
||||
await Promise.race([
|
||||
probe.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET })),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), PROBE_TIMEOUT_MS)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function refreshHostCache() {
|
||||
const endpoint = process.env.S3_ENDPOINT;
|
||||
const publicUrl = process.env.S3_PUBLIC_URL || "";
|
||||
|
||||
if (!endpoint) { hostCache = { host: publicUrl }; return; }
|
||||
if (!publicUrl) { hostCache = { host: endpoint }; return; }
|
||||
|
||||
try {
|
||||
await probeEndpoint(endpoint);
|
||||
hostCache = { host: endpoint };
|
||||
} catch {
|
||||
hostCache = { host: publicUrl };
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off the first probe immediately so the cache is populated before any
|
||||
// request needs it, then keep it fresh in the background. unref() so this
|
||||
// timer alone doesn't keep the process (or a test run) alive.
|
||||
const initialProbe = refreshHostCache();
|
||||
const refreshTimer = setInterval(refreshHostCache, PROBE_CACHE_MS);
|
||||
refreshTimer.unref?.();
|
||||
|
||||
async function resolvePublicHost() {
|
||||
await initialProbe; // no-op after the first call — already resolved
|
||||
return hostCache.host;
|
||||
}
|
||||
|
||||
// Public client — lazily built against whichever host resolvePublicHost()
|
||||
// picks, so it follows the reachability check instead of a fixed endpoint.
|
||||
async function getPublicClient() {
|
||||
const endpoint = await resolvePublicHost();
|
||||
return new S3Client({
|
||||
endpoint,
|
||||
region: process.env.S3_REGION || "garage",
|
||||
credentials,
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Key prefix map ───────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -83,10 +148,11 @@ function buildKey(originalname, ownerType) {
|
||||
}
|
||||
|
||||
// Builds the public URL for a stored object.
|
||||
// Garage path-style: {S3_PUBLIC_URL}/{bucket}/{key}
|
||||
// Garage path-style: {host}/{bucket}/{key}
|
||||
// e.g. https://cdn.yourdomain.com/your-bucket/images/uuid.jpg
|
||||
function buildPublicUrl(key, bucket = DEFAULT_BUCKET) {
|
||||
return `${PUBLIC_URL}/${bucket}/${key}`;
|
||||
async function buildPublicUrl(key, bucket = DEFAULT_BUCKET) {
|
||||
const host = (await resolvePublicHost()).replace(/\/$/, "");
|
||||
return `${host}/${bucket}/${key}`;
|
||||
}
|
||||
|
||||
// ─── uploadFile ───────────────────────────────────────────────────────────────
|
||||
@@ -111,7 +177,7 @@ async function uploadFile({ buffer, originalname, mimetype, ownerType = "image"
|
||||
}));
|
||||
|
||||
return {
|
||||
url: buildPublicUrl(key, bucket),
|
||||
url: await buildPublicUrl(key, bucket),
|
||||
uuid: key, // used as storage_key in DB — mirrors chibi_uuid usage
|
||||
};
|
||||
}
|
||||
@@ -131,13 +197,15 @@ async function deleteFile(key) {
|
||||
|
||||
// ─── getSignedDownloadUrl ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Generates a short-lived pre-signed GET URL using the public endpoint so the
|
||||
// URL is resolvable from any machine (browser or proxy server), not just the
|
||||
// one running Garage locally.
|
||||
// Generates a short-lived pre-signed GET URL against whichever host
|
||||
// resolvePublicHost() picks, so the URL is resolvable from wherever the
|
||||
// request is served (browser or proxy server), not just the one running
|
||||
// Garage locally.
|
||||
//
|
||||
async function getSignedDownloadUrl(key, expiresInSeconds = 3600) {
|
||||
const client = await getPublicClient();
|
||||
return getSignedUrl(
|
||||
s3Public,
|
||||
client,
|
||||
new GetObjectCommand({ Bucket: DEFAULT_BUCKET, Key: key }),
|
||||
{ expiresIn: expiresInSeconds }
|
||||
);
|
||||
@@ -169,7 +237,8 @@ async function getObjectStream(key) {
|
||||
}
|
||||
|
||||
async function ping() {
|
||||
await s3Public.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET }));
|
||||
const client = await getPublicClient();
|
||||
await client.send(new HeadBucketCommand({ Bucket: DEFAULT_BUCKET }));
|
||||
}
|
||||
|
||||
module.exports = { uploadFile, deleteFile, getSignedDownloadUrl, getObjectStream, ping };
|
||||
@@ -0,0 +1,149 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: trustedDevice.service.js
|
||||
* Type of Program: Service
|
||||
* Description: Lets a login from an already-verified device skip the OTP
|
||||
* gate. A device is trusted the first time its user clears an
|
||||
* OTP; trust rolls forward 30 days on each trusted login and is
|
||||
* tied to both an opaque cookie token (device_trust) and a
|
||||
* User-Agent fingerprint, so a stolen cookie alone isn't enough
|
||||
* once the fingerprint no longer matches. Trust is revoked on
|
||||
* logout, password change/reset, admin ban/deactivate, or a
|
||||
* single session being terminated.
|
||||
* Author: Kenneth Obsequio
|
||||
* Date Created: Jul. 5, 2026
|
||||
***********************************************************************************************************************************************************************
|
||||
* HOW TO USE:
|
||||
* const trustedDevice = require('../services/trustedDevice.service');
|
||||
* const fingerprintHash = trustedDevice.getFingerprintHash(req);
|
||||
* const trusted = await trustedDevice.findValid(user.user_id, req.cookies.device_trust, fingerprintHash);
|
||||
* if (trusted) { ...skip OTP... }
|
||||
* await trustedDevice.issueOrRefresh(res, user.user_id, fingerprintHash, session.session_id);
|
||||
***********************************************************************************************************************************************************************/
|
||||
const crypto = require('crypto');
|
||||
const mdl_TrustedDevices = require('../models/users/trusted_devices.mdl');
|
||||
const { parseUA } = require('../utils/session_info.util');
|
||||
const { hashToken } = require('../utils/token.util');
|
||||
|
||||
const TRUST_DAYS = 30;
|
||||
const COOKIE_NAME = 'device_trust';
|
||||
|
||||
const getFingerprintHash = (req) => {
|
||||
const { browser, os, device } = parseUA(req.headers['user-agent']);
|
||||
return crypto.createHash('sha256').update(`${browser}|${os}|${device}`).digest('hex');
|
||||
};
|
||||
|
||||
const cookieOptions = (maxAge) => ({
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
|
||||
maxAge,
|
||||
});
|
||||
|
||||
/**
|
||||
* Looks up a non-revoked, non-expired trusted device matching both the
|
||||
* cookie token and the current request's fingerprint.
|
||||
*
|
||||
* Fails safe: any lookup error (e.g. table not migrated yet) is treated as
|
||||
* "not trusted" rather than propagating — a broken trust check should never
|
||||
* take down the login/OTP path itself, it should just fall back to OTP.
|
||||
* @returns {Promise<import('../models/users/trusted_devices.mdl')|null>}
|
||||
*/
|
||||
const findValid = async (userId, rawToken, fingerprintHash) => {
|
||||
if (!rawToken) return null;
|
||||
|
||||
try {
|
||||
const row = await mdl_TrustedDevices.findOne({
|
||||
where: {
|
||||
user_id: userId,
|
||||
device_token_hash: hashToken(rawToken),
|
||||
fingerprint_hash: fingerprintHash,
|
||||
revoked_at: null,
|
||||
},
|
||||
});
|
||||
return (row && new Date(row.expires_at) > new Date()) ? row : null;
|
||||
} catch (err) {
|
||||
console.error('[TRUSTED DEVICE] findValid failed, falling back to OTP:', err.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks the current device as trusted for TRUST_DAYS, rolling the window
|
||||
* forward on repeat use, and sets the device_trust cookie.
|
||||
*
|
||||
* Fails safe: called after tokens/session are already minted, so a failure
|
||||
* here (e.g. table not migrated yet) must not break an otherwise-successful
|
||||
* login — it just means this device won't skip OTP next time.
|
||||
*/
|
||||
const issueOrRefresh = async (res, userId, fingerprintHash, sessionId) => {
|
||||
try {
|
||||
const rawToken = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + TRUST_DAYS * 24 * 60 * 60 * 1000);
|
||||
const fields = {
|
||||
device_token_hash: hashToken(rawToken),
|
||||
expires_at: expiresAt,
|
||||
revoked_at: null,
|
||||
last_session_id: sessionId,
|
||||
};
|
||||
|
||||
// Plain find-then-create/update rather than findOrCreate() — Sequelize's
|
||||
// postgres findOrCreate() relies on a temp PL/pgSQL function for atomicity
|
||||
// that CockroachDB doesn't support ("cannot create user-defined functions
|
||||
// under a temporary schema").
|
||||
const row = await mdl_TrustedDevices.findOne({ where: { user_id: userId, fingerprint_hash: fingerprintHash } });
|
||||
if (row) {
|
||||
await row.update(fields);
|
||||
} else {
|
||||
await mdl_TrustedDevices.create({ user_id: userId, fingerprint_hash: fingerprintHash, ...fields });
|
||||
}
|
||||
|
||||
res.cookie(COOKIE_NAME, rawToken, cookieOptions(TRUST_DAYS * 24 * 60 * 60 * 1000));
|
||||
} catch (err) {
|
||||
console.error('[TRUSTED DEVICE] issueOrRefresh failed:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeByToken = async (userId, rawToken) => {
|
||||
if (!rawToken) return;
|
||||
try {
|
||||
await mdl_TrustedDevices.update(
|
||||
{ revoked_at: new Date() },
|
||||
{ where: { user_id: userId, device_token_hash: hashToken(rawToken), revoked_at: null } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[TRUSTED DEVICE] revokeByToken failed:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeAllForUser = async (userId) => {
|
||||
try {
|
||||
await mdl_TrustedDevices.update(
|
||||
{ revoked_at: new Date() },
|
||||
{ where: { user_id: userId, revoked_at: null } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[TRUSTED DEVICE] revokeAllForUser failed:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const revokeBySessionId = async (sessionId) => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
await mdl_TrustedDevices.update(
|
||||
{ revoked_at: new Date() },
|
||||
{ where: { last_session_id: sessionId, revoked_at: null } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[TRUSTED DEVICE] revokeBySessionId failed:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
COOKIE_NAME,
|
||||
getFingerprintHash,
|
||||
findValid,
|
||||
issueOrRefresh,
|
||||
revokeByToken,
|
||||
revokeAllForUser,
|
||||
revokeBySessionId,
|
||||
};
|
||||
Reference in New Issue
Block a user