first commit

This commit is contained in:
2025-06-03 21:13:56 +09:00
commit e91d481216
171 changed files with 45905 additions and 0 deletions

10
functions/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Compiled JavaScript files
lib/**/*.js
lib/**/*.js.map
# TypeScript v1 declaration files
typings/
# Node.js dependency directory
node_modules/
*.local

35
functions/.prettierignore Normal file
View File

@@ -0,0 +1,35 @@
# Ignore build output directories
.nuxt/
dist/
lib/
# Ignore node modules
node_modules/
# Ignore specific configuration files
*.config.js
# Ignore environment variables files
.env
.env.*
# Ignore lock files
yarn.lock
package-lock.json
# Ignore logs
*.log
# Ignore compiled files
*.min.js
*.min.css
# Ignore specific file types
*.png
*.jpg
*.jpeg
*.gif
*.svg
# Ignore other generated files
coverage/

22
functions/.prettierrc Normal file
View File

@@ -0,0 +1,22 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"parser": "typescript",
"filepath": "",
"requirePragma": false,
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "css",
"vueIndentScriptAndStyle": false,
"endOfLine": "auto",
"embeddedLanguageFormatting": "auto",
"singleAttributePerLine": false
}

View File

@@ -0,0 +1,45 @@
import path from 'path';
import { fileURLToPath } from 'url';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import tsParser from '@typescript-eslint/parser'; // TS parser
import js from '@eslint/js';
import { defineConfig } from 'eslint/config';
import eslintConfigPrettier from 'eslint-config-prettier';
import importPlugin from 'eslint-plugin-import';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig([
js.configs.recommended,
tseslint.configs.recommended,
importPlugin.flatConfigs.recommended,
importPlugin.flatConfigs.errors,
importPlugin.flatConfigs.warnings,
importPlugin.flatConfigs.typescript,
eslintConfigPrettier,
{ ignores: ['node_modules', 'dist', 'public', 'lib'] },
{
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaFeatures: { jsx: true },
ecmaVersion: 'latest',
sourceType: 'module',
project: ['tsconfig.json', 'tsconfig.dev.json'],
tsconfigRootDir: __dirname,
},
globals: globals.node,
},
rules: {
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-var-requires': 'error',
'@typescript-eslint/strict-boolean-expressions': 'off',
'import/no-unresolved': 0,
},
},
eslintConfigPrettier,
]);

9515
functions/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
functions/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "functions",
"scripts": {
"lint": "eslint",
"build": "tsc",
"build:watch": "tsc --watch",
"serve": "npm run build && firebase emulators:start --only functions",
"shell": "npm run build && firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"logs": "firebase functions:log"
},
"engines": {
"node": "22"
},
"main": "lib/index.js",
"dependencies": {
"firebase-admin": "^13.3.0",
"firebase-functions": "^6.3.2"
},
"devDependencies": {
"@eslint/js": "^9.26.0",
"@types/cookie": "^0.6.0",
"@typescript-eslint/parser": "^8.32.0",
"eslint-config-prettier": "^10.1.3",
"eslint-plugin-import": "^2.31.0",
"firebase-functions-test": "^3.1.0",
"globals": "^16.1.0",
"prettier": "3.5.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.0"
},
"private": true
}

View File

@@ -0,0 +1,148 @@
import { onRequest } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import { corsV2Origins, db } from '../config'; // Import corsV2Origins
// Other imports
import * as admin from 'firebase-admin';
import { toErrorWithMessage } from '../utils';
export const adminChangePasswordV2 = onRequest(
{
cors: corsV2Origins,
},
async (request, response) => {
logger.info(
'adminChangePassword: Function invoked (CORS handled by framework using config).',
);
const token = request.get('Authorization')?.split('Bearer ')[1];
if (token == null) {
logger.warn('adminChangePassword: No token provided.');
response.status(401).send({
error: 'unauthenticated',
message: 'You must be logged in to change the password.',
});
return;
}
let decodedToken;
try {
decodedToken = await admin.auth().verifyIdToken(token);
logger.info(
`adminChangePassword: Token verified for admin UID: ${decodedToken.uid}`,
);
} catch (error: unknown) {
const errorMsg = toErrorWithMessage(error).message;
logger.error(
`adminChangePassword: Token verification failed: ${errorMsg}`,
);
response.status(401).send({
error: 'unauthenticated',
message: 'Invalid token.',
details: errorMsg,
});
return;
}
// Fetch the user's role using the authenticated user's UID
let userRole = 0; // default
try {
const userDoc = await db.collection('users').doc(decodedToken.uid).get();
if (userDoc.exists) {
userRole = userDoc.data()?.role || 0;
logger.info(
`adminChangePassword: Requesting admin role is ${userRole}.`,
);
} else {
logger.warn(
`adminChangePassword: Admin user document not found for UID: ${decodedToken.uid}`,
);
}
} catch (error: unknown) {
const errorMsg = toErrorWithMessage(error).message;
logger.error(
`adminChangePassword: Failed to fetch admin role for UID ${decodedToken.uid}: ${errorMsg}`,
);
response.status(500).send({
error: 'internal',
message: "Failed to fetch user's role.",
details: errorMsg,
});
return;
}
// --- Role Check ---
const requiredRole = 5; // Define required role level
if (userRole < requiredRole) {
logger.warn(
`adminChangePassword: Permission denied for admin UID ${decodedToken.uid} with role ${userRole}. Required: ${requiredRole}`,
);
response.status(403).send({
error: 'permission-denied',
message: 'Insufficient role level to change the password.',
});
return;
}
logger.info(
`adminChangePassword: Permission granted for admin UID ${decodedToken.uid}.`,
);
// --- Proceed with password change ---
try {
const { email, newPassword } = request.body;
if (!email || !newPassword) {
logger.warn(
'adminChangePassword: Missing email or newPassword in request body.',
);
response.status(400).send({
error: 'bad-input',
message: 'Both email and newPassword are required.',
});
return;
}
logger.info(
`adminChangePassword: Attempting to change password for email: ${email}`,
);
const userRecord = await admin.auth().getUserByEmail(email);
await admin.auth().updateUser(userRecord.uid, {
password: newPassword,
});
logger.info(
`adminChangePassword: Successfully changed password for user UID: ${userRecord.uid}`,
);
response.send({ success: true, uid: userRecord.uid });
} catch (error: unknown) {
const errorMsg = toErrorWithMessage(error).message;
if (
error instanceof Error &&
'code' in error &&
error.code === 'auth/user-not-found'
) {
logger.warn(
`adminChangePassword: User not found for email: ${request.body?.email}`,
);
response.status(404).send({
error: 'not-found',
message: 'No user associated with this email.',
details: errorMsg,
});
} else {
logger.error(
`adminChangePassword: Failed to change password: ${errorMsg}`,
{ error },
);
response.status(500).send({
error: 'internal',
message: 'Failed to change password.',
details: errorMsg,
});
}
}
}, // End of the async handler function
); // End of onRequest

View File

@@ -0,0 +1,59 @@
//NEEDS WORKS ON < PAST CODE >
import { onRequest } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
// Import Request/Response from express
// Import the MIDDLEWARE HANDLER and db from config
import { corsMiddlewareHandler, db } from '../config'; // Use the middleware export name
// Import error utility
import { toErrorWithMessage } from '../utils';
export const checkEmailDuplicateV2 = onRequest(async (request, response) => {
return corsMiddlewareHandler(request, response, async () => {
logger.info('checkEmailDuplicate: Function invoked via CORS Middleware.');
if (request.method !== 'POST') {
logger.warn(`checkEmailDuplicate: Method not allowed: ${request.method}`);
response.status(405).send({ message: 'Method Not Allowed.' });
return;
}
const { email } = request.body;
// ... rest of your validation and try/catch logic using db and logger ...
logger.info(`checkEmailDuplicate: Checking email: ${email}`);
if (!email || typeof email !== 'string') {
logger.warn('checkEmailDuplicate: Missing or invalid email field.');
response.status(400).send({
/* ... */
});
return;
}
try {
const usersCollection = db.collection('users');
const querySnapshot = await usersCollection
.where('email', '==', email)
.limit(1)
.get();
if (!querySnapshot.empty) {
logger.info(`checkEmailDuplicate: Email ${email} exists.`);
response.send({ status: 'exists' });
} else {
logger.info(`checkEmailDuplicate: Email ${email} is available.`);
response.send({ status: 'available' });
}
} catch (error: unknown) {
const errorMsg = toErrorWithMessage(error).message;
logger.error(
`checkEmailDuplicate: Error checking email ${email}: ${errorMsg}`,
{ error },
);
response.status(500).send({
/* ... */
});
}
}); // End of corsMiddlewareHandler callback
}); // End of onRequest

View File

@@ -0,0 +1,55 @@
import { onRequest } from 'firebase-functions/v2/https';
import { getAuth } from 'firebase-admin/auth';
import { initializeApp, getApps } from 'firebase-admin/app';
import * as cookie from 'cookie';
import { logger } from 'firebase-functions/v2';
import { cookieExpiration, corsMiddlewareHandler } from '../config';
// ✅ Admin SDK init
if (!getApps().length) {
initializeApp(); // ✅ auto uses service account in Firebase Functions
}
export const createSession = onRequest(
{ region: 'asia-northeast3' },
async (req, res) => {
return corsMiddlewareHandler(req, res, async () => {
logger.info('countBoards: CORSenabled function called');
if (req.method !== 'POST') {
res.status(405).send('Method not allowed');
return;
}
const idToken = req.body?.idToken;
if (!idToken) {
res.status(400).send('Missing ID token');
return;
}
const auth = getAuth();
try {
const expiresIn = cookieExpiration;
const sessionCookie = await auth.createSessionCookie(idToken, {
expiresIn,
});
// Set cookie
const options = {
httpOnly: true,
secure: true,
maxAge: expiresIn / 1000,
sameSite: 'lax' as const,
path: '/',
};
res.setHeader(
'Set-Cookie',
cookie.serialize('__session', sessionCookie, options),
);
res.status(200).json({ success: true });
} catch (error) {
res.status(401).send('Invalid token');
}
});
},
);

View File

@@ -0,0 +1,90 @@
//NEEDS WORKS ON < PAST CODE >
import {
onCall,
HttpsError,
CallableRequest,
} from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import { allowedAdminEmails } from '../config';
import * as admin from 'firebase-admin';
interface DeleteUserData {
uid: string;
}
export const deleteUserV2 = onCall(
async (request: CallableRequest<DeleteUserData>) => {
logger.info(`deleteUser: Function invoked by user: ${request.auth?.uid}`);
// Ensure the function caller is authenticated (check request.auth)
if (!request.auth) {
logger.warn('deleteUser: Unauthenticated attempt.');
throw new HttpsError( // Use imported HttpsError
'unauthenticated',
'User must be authenticated to delete accounts.',
);
}
// Extract email from token (within request.auth)
const callerEmail = request.auth.token.email || null;
if (!callerEmail) {
logger.error(
`deleteUser: Email not found in token for UID: ${request.auth.uid}`,
);
throw new HttpsError( // Use imported HttpsError
'invalid-argument',
'Email not found in the authentication token.',
);
}
logger.info(`deleteUser: Caller email: ${callerEmail}`);
// Only allow certain emails to perform the delete action.
// Trim whitespace from list for safety
// Trim callerEmail for reliable comparison
if (!allowedAdminEmails.includes(callerEmail.trim())) {
logger.warn(
`deleteUser: Permission denied for ${callerEmail}. Not in allowed list.`,
);
throw new HttpsError( // Use imported HttpsError
'permission-denied',
'Permission denied. This user is not allowed to delete accounts.',
);
}
logger.info(`deleteUser: Permission granted for ${callerEmail}.`);
// Get the user UID to be deleted from request.data
// Type safety provided by CallableRequest<DeleteUserData>
const userUidToDelete = request.data.uid;
logger.info(`deleteUser: Attempting to delete UID: ${userUidToDelete}`);
// Validate the UID from the client
if (!userUidToDelete || typeof userUidToDelete !== 'string') {
logger.error(
'deleteUser: Invalid or missing UID in request data.',
request.data,
);
throw new HttpsError( // Use imported HttpsError
'invalid-argument',
'The function must be called with a valid string UID of the user to delete.',
);
}
try {
logger.info(
`deleteUser: Calling admin.auth().deleteUser for ${userUidToDelete}`,
);
await admin.auth().deleteUser(userUidToDelete);
logger.info(`deleteUser: Successfully deleted user ${userUidToDelete}`);
return { success: true };
} catch (error) {
logger.error(
`deleteUser: Error deleting user ${userUidToDelete}:`,
error,
);
// You could check error.code here for more specific errors like 'auth/user-not-found'
throw new HttpsError('internal', 'Failed to delete the user.');
}
},
);

View File

@@ -0,0 +1,7 @@
export * from './createSession';
export * from './logoutSession';
export * from './registerUser';
export * from './checkEmail';
export * from './changePassword';
export * from './deleteUser';
export * from './verifySession';

View File

@@ -0,0 +1,28 @@
import { onRequest } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import * as cookie from 'cookie';
import { corsMiddlewareHandler } from '../config'; // or wherever it's defined
export const logout = onRequest(
{ region: 'asia-northeast3' },
async (req, res) => {
return corsMiddlewareHandler(req, res, async () => {
logger.info('logout: CORSenabled function called');
if (req.method !== 'POST') {
res.status(405).send({ message: 'Method Not Allowed' });
return;
}
const expiredCookie = cookie.serialize('__session', '', {
httpOnly: true,
secure: true,
maxAge: 0,
path: '/',
});
res.setHeader('Set-Cookie', expiredCookie);
res.status(200).json({ success: true });
});
},
);

View File

@@ -0,0 +1,274 @@
//NEEDS WORKS ON < PAST CODE >
import { onRequest } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import { corsMiddlewareHandler, db, FieldValue } from '../config';
import { toErrorWithMessage } from '../utils';
import * as admin from 'firebase-admin';
// --- Function 1: registerNewUser (Admin/Role-Protected Registration) ---
export const registerNewUserV2 = onRequest(async (request, response) => {
return corsMiddlewareHandler(request, response, async () => {
logger.info('registerNewUser: Function invoked via CORS Middleware.');
const token = request.get('Authorization')?.split('Bearer ')[1];
if (token == null || token === '') {
logger.warn('registerNewUser: No auth token provided.');
response.status(401).send({
error: 'unauthenticated',
message: 'You must be logged in to register a user.',
});
return;
}
let decodedToken;
try {
decodedToken = await admin.auth().verifyIdToken(token);
logger.info(
`registerNewUser: Token verified for admin UID: ${decodedToken.uid}`,
);
} catch (error: unknown) {
const errorMsg = toErrorWithMessage(error).message;
logger.error(`registerNewUser: Invalid token: ${errorMsg}`);
response.status(401).send({
error: 'unauthenticated',
message: 'Invalid token.',
details: errorMsg,
});
return;
}
// Fetch the user's role
let userRole = 0;
try {
const userDoc = await db.collection('users').doc(decodedToken.uid).get();
if (userDoc.exists) {
userRole = userDoc.data()?.role || 0;
logger.info(`registerNewUser: Requesting admin role is ${userRole}.`);
} else {
logger.warn(
`registerNewUser: Admin user document not found for UID: ${decodedToken.uid}`,
);
}
} catch (error: unknown) {
const errorMsg = toErrorWithMessage(error).message;
logger.error(
`registerNewUser: Failed to fetch admin role for UID ${decodedToken.uid}: ${errorMsg}`,
);
response.status(500).send({
error: 'internal',
message: "Failed to fetch user's role.",
details: errorMsg,
});
return;
}
// Role Check
const requiredAdminRole = 5;
if (userRole < requiredAdminRole) {
logger.warn(
`registerNewUser: Permission denied for UID ${decodedToken.uid} with role ${userRole}.`,
);
response.status(403).send({
error: 'permission-denied',
message: 'Insufficient role level to register a new user.',
});
return;
}
logger.info(
`registerNewUser: Permission granted for UID ${decodedToken.uid}. Proceeding.`,
);
// Proceed with user registration
try {
const { email, password, name, phone, membership, ...rest } =
request.body;
if (!email || !password || !name) {
logger.error(
'registerNewUser: Missing required fields (email, password, name).',
);
response.status(400).send({
error: 'missing-fields',
message: 'Missing required fields: email, password, name.',
});
return;
}
logger.info(
`registerNewUser: Attempting to create user with email: ${email}`,
);
// Corrected: Pass user data object to createUser
const userCred = await admin.auth().createUser({
email: email,
password: password,
displayName: name,
});
logger.info(
`registerNewUser: User created successfully with UID: ${userCred.uid}`,
);
await db
.collection('users')
.doc(userCred.uid)
.set({
docId: userCred.uid,
name: name,
phone: phone || null,
email: email,
membership: membership || null,
role: 1, // Default role
isActive: true,
created: FieldValue.serverTimestamp(),
...rest,
});
logger.info(
`registerNewUser: User data stored in Firestore for UID: ${userCred.uid}`,
);
response.send({ success: true, uid: userCred.uid });
} catch (error: unknown) {
const errorMsg = toErrorWithMessage(error).message;
logger.error(
`registerNewUser: Failed during user creation/write: ${errorMsg}`,
{ error },
);
if (
error instanceof Error &&
'code' in error &&
error.code === 'auth/email-already-exists'
) {
response.status(409).send({
error: 'email-already-exists',
message: 'The email address is already in use by another account.',
details: errorMsg,
});
} else {
response.status(500).send({
error: 'internal',
message: 'Failed to register user.',
details: errorMsg,
});
}
}
}); // End of corsMiddlewareHandler callback
}); // End of registerNewUser onRequest
// --- Function 2: visitorRegister (Public Registration) ---
export const visitorRegisterV2 = onRequest(async (request, response) => {
return corsMiddlewareHandler(request, response, async () => {
// Note: Add 'as any' assertion here too if needed for TS build error workaround
// return corsMiddlewareHandler(request as any, response as any, async () => {
logger.info('visitorRegister: Function invoked via CORS Middleware.'); // Log updated
if (request.method !== 'POST') {
logger.warn(`visitorRegister: Method not allowed: ${request.method}`);
response.status(405).send({ message: 'Method Not Allowed.' });
return;
}
const { email, password, name, membership, uid, ...rest } = request.body;
if (!email || !password || !name) {
logger.warn(
'visitorRegister: Missing essential fields (email, password, name).',
);
response.status(400).send({
error: 'missing-fields',
message: 'Missing essential registration fields.',
});
return;
}
try {
logger.info(`visitorRegister: Attempting to create user: ${email}`);
const userCred = await admin.auth().createUser({
email: email,
password: password,
displayName: name,
});
logger.info(`visitorRegister: User created with UID: ${userCred.uid}`);
if (uid && typeof uid === 'string') {
logger.info(
`visitorRegister: Attempting to link phone number from temp UID: ${uid}`,
);
try {
const phoneUser = await admin.auth().getUser(uid);
logger.info(
`visitorRegister: Fetched phone user details for temp UID ${uid}. Phone: ${phoneUser.phoneNumber}`,
);
if (phoneUser.phoneNumber) {
await admin.auth().updateUser(userCred.uid, {
phoneNumber: phoneUser.phoneNumber,
});
logger.info(
`visitorRegister: Phone number ${phoneUser.phoneNumber} linked to new user UID: ${userCred.uid}`,
);
} else {
logger.warn(
`visitorRegister: Temp user UID ${uid} exists but has no phone number.`,
);
}
await admin.auth().deleteUser(uid);
logger.info(
`visitorRegister: Deleted temporary phone auth user UID: ${uid}`,
);
} catch (linkError: unknown) {
const linkErrorMsg = toErrorWithMessage(linkError).message;
logger.error(
`visitorRegister: Error during phone linking/deletion for temp UID ${uid} (non-fatal): ${linkErrorMsg}`,
{ linkError },
);
}
}
logger.info(
`visitorRegister: Storing user data in Firestore for UID: ${userCred.uid}`,
);
await db
.collection('users')
.doc(userCred.uid)
.set({
docId: userCred.uid,
name: name,
email: email,
membership: membership || null,
role: 1,
isActive: true,
created: FieldValue.serverTimestamp(),
...rest,
});
logger.info(
`visitorRegister: Stored user data in Firestore for UID: ${userCred.uid}`,
);
response.send({ success: true, uid: userCred.uid });
} catch (error: unknown) {
const errorMsg = toErrorWithMessage(error).message;
logger.error(
`visitorRegister: Failed during registration for ${email}: ${errorMsg}`,
{ error },
);
if (
error instanceof Error &&
'code' in error &&
error.code === 'auth/email-already-exists'
) {
response.status(409).send({
error: 'email-already-exists',
message: 'The email address is already in use by another account.',
details: errorMsg,
});
} else {
response.status(500).send({
error: 'registration-failed',
message: 'Failed to register the user.',
details: errorMsg,
});
}
}
}); // End of corsMiddlewareHandler callback
}); // End of visitorRegister onRequest

View File

@@ -0,0 +1,47 @@
import { onRequest } from 'firebase-functions/v2/https';
import * as cookie from 'cookie';
import { getAuth } from 'firebase-admin/auth';
import { initializeApp, getApps } from 'firebase-admin/app';
import { corsMiddlewareHandler } from '../config';
import { logger } from 'firebase-functions/v2';
// ✅ Ensure Admin SDK is initialized
if (!getApps().length) {
initializeApp();
}
export const verifySession = onRequest(
{ region: 'asia-northeast3' },
async (req, res) => {
return corsMiddlewareHandler(req, res, async () => {
logger.info('verifySession: Function invoked');
if (req.method !== 'GET') {
res.status(405).send('Method Not Allowed');
return;
}
const cookies = cookie.parse(req.headers.cookie || '');
const session = cookies.__session;
if (!session) {
res.status(401).send({ message: 'No session cookie' });
return;
}
try {
const decoded = await getAuth().verifySessionCookie(session, true);
const role = decoded.role || 0;
res.status(200).json({
uid: decoded.uid,
email: decoded.email,
role,
});
} catch (err) {
logger.warn('verifySession: Session invalid or expired', err);
res.status(401).send({ message: 'Invalid session' });
}
});
},
);

View File

@@ -0,0 +1,101 @@
import { onRequest } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import {
getFirestore,
CollectionReference,
Query,
} from 'firebase-admin/firestore';
import { initializeApp, getApps } from 'firebase-admin/app';
import type { DocumentData } from 'firebase-admin/firestore';
import { corsMiddlewareHandler, ALLOWED_COLLECTIONS } from '../config';
import {
verifySessionFromRequest,
buildQueryWithAccessControl,
} from '../utils';
import type { BoardAccessMode } from '../types/boardItem';
if (!getApps().length) initializeApp();
const db = getFirestore();
export const countBoards = onRequest(
{ region: 'asia-northeast3' },
async (request, response) => {
return corsMiddlewareHandler(request, response, async () => {
logger.info('countBoards: CORSenabled function called');
/* 1. method guard ---------------------------------------------------- */
if (request.method !== 'POST') {
response.status(405).send({ message: 'Method Not Allowed' });
return;
}
/* 2. optional auth --------------------------------------------------- */
let userRole = 0;
let userId: string | null = null;
const authUser = await verifySessionFromRequest(request);
if (authUser) {
userId = authUser.uid;
userRole = authUser.role;
}
/* 3. body params & validation --------------------------------------- */
const { collection = '', access = 'public' } = request.body as Partial<{
collection: string;
access: string;
}>;
// collection allowlist
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (!ALLOWED_COLLECTIONS.has(collection as any)) {
response.status(400).send({ message: 'Invalid collection' });
return;
}
// access string check
if (!['public', 'private', 'admin'].includes(access)) {
response.status(400).send({ message: 'Bad access mode' });
return;
}
const accessParam: BoardAccessMode = access as BoardAccessMode;
const effectiveAccess: BoardAccessMode = userId ? accessParam : 'public';
logger.info(
`countBoards: uid=${userId ?? 'anon'}, role=${userRole}, ` +
`requested=${accessParam}, effective=${effectiveAccess}`,
);
/* 4. build filtered query ------------------------------------------- */
let q: Query<DocumentData>;
try {
q = buildQueryWithAccessControl(
db.collection(collection) as CollectionReference<DocumentData>,
effectiveAccess,
userId,
userRole,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
response
.status(err?.message?.includes('Unauthorized') ? 401 : 403)
.send({
message: err instanceof Error ? err.message : 'Access denied',
});
return;
}
/* 5. run count() ----------------------------------------------------- */
try {
const aggSnap = await q.count().get();
const count =
typeof aggSnap.data().count === 'number' ? aggSnap.data().count : 0;
response.status(200).send({ count });
} catch (err) {
logger.error('countBoards: Firestore error', err);
response.status(500).send({ message: 'Internal Server Error' });
}
});
},
);

View File

@@ -0,0 +1,154 @@
import { onRequest } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import { getFirestore, Query, DocumentData } from 'firebase-admin/firestore';
import { initializeApp, getApps } from 'firebase-admin/app';
import { corsMiddlewareHandler, ALLOWED_COLLECTIONS } from '../config';
import {
isValidBoardItem,
encodeCursor,
decodeCursor,
buildQueryWithAccessControl,
verifySessionFromRequest,
} from '../utils';
import type { BoardItem, BoardAccessMode } from '../types/boardItem';
if (!getApps().length) {
initializeApp();
}
const db = getFirestore();
export const fetchBoards = onRequest(
{ region: 'asia-northeast3' },
async (request, response) => {
return corsMiddlewareHandler(request, response, async () => {
logger.info('fetchBoards: CORSenabled function called');
/* 1.Method guard ------------------------------------------------------------------ */
if (request.method !== 'GET') {
response.status(405).send({ message: 'Method Not Allowed' });
return;
}
/* 2.Auth (optional) ---------------------------------------------------------------- */
let userRole = 0;
let userId: string | null = null;
const authUser = await verifySessionFromRequest(request);
if (authUser) {
userId = authUser.uid;
userRole = authUser.role;
}
/* 3.Read & validate query params --------------------------------------------------- */
const {
collection = '',
sortOrder = 'desc',
itemsPerPage = '10',
access = 'public',
pageToken,
} = request.query as Record<string, string>;
// ❶collection allowlist
if (
!ALLOWED_COLLECTIONS.has(
collection as unknown as typeof ALLOWED_COLLECTIONS extends Set<
infer U
>
? U
: never,
)
) {
response.status(400).send({ message: 'Invalid collection' });
return;
}
// ❷access must be one of three strings
if (!['public', 'private', 'admin'].includes(access)) {
response.status(400).send({ message: 'Bad access mode' });
return;
}
// ❸numeric arguments
const limit = Math.max(1, Math.min(Number(itemsPerPage) || 10, 100));
const order = sortOrder === 'asc' ? 'asc' : 'desc';
/* 4.Build query with access control ----------------------------------------------- */
const accessParam = (access ?? 'public') as BoardAccessMode;
const effectiveAccess = userId ? accessParam : 'public'; // force public if anonymous
let queryRef: Query<DocumentData>;
try {
queryRef = buildQueryWithAccessControl(
db.collection(collection),
effectiveAccess,
userId,
userRole,
);
} catch (err: Error | unknown) {
response
.status(
err instanceof Error && err.message?.includes('Unauthorized')
? 401
: 403,
)
.send({
message:
err instanceof Error ? err.message : 'Access control failed',
});
return;
}
queryRef = queryRef
.orderBy('announcement', 'desc')
.orderBy('created', order)
.orderBy('__name__', 'asc'); // tiebreaker for deterministic order
/* 5. Apply cursor if we got one ---------------------------------------- */
if (pageToken) {
try {
const [ann, createdTs, docId] = decodeCursor(pageToken);
queryRef = queryRef.startAfter(ann, createdTs, docId);
} catch {
response.status(400).send({ message: 'Invalid pageToken' });
return;
}
} /* 6. Execute query & build response ------------------------------------ */
try {
const snap = await queryRef.limit(limit).get();
const items: BoardItem[] = snap.docs
.map((d) => {
const data = d.data();
if (!isValidBoardItem(data)) {
logger.warn(`fetchBoardsCursor: skip invalid ${d.id}`);
return null;
}
return { docId: d.id, ...data };
})
.filter(<T,>(x: T): x is NonNullable<T> => x !== null);
// nextPageToken logic
const lastDoc = snap.docs[snap.docs.length - 1];
const nextPageToken =
snap.size === limit && lastDoc
? encodeCursor(
lastDoc.get('announcement'),
lastDoc.get('created'),
lastDoc.id,
)
: null;
if (effectiveAccess === 'public')
response.set('Cache-Control', 'public,max-age=60');
response.status(200).send({ items, nextPageToken });
} catch (err) {
logger.error('fetchBoards: Firestore error', err);
response.status(500).send({ message: 'Internal Server Error' });
}
});
},
);

View File

@@ -0,0 +1,2 @@
export * from './countBoard';
export * from './fetchBoard';

189
functions/src/config.ts Normal file
View File

@@ -0,0 +1,189 @@
import * as admin from 'firebase-admin';
import corsMiddleware from 'cors';
import { HttpsOptions } from 'firebase-functions/v2/https';
try {
admin.initializeApp();
} catch (e) {
console.warn('admin.initializeApp() already called or failed:', e);
}
export const db = admin.firestore();
export const storage = admin.storage();
export const FieldValue = admin.firestore.FieldValue;
// --------------------------------------AUTH CONFIGURATION--------------------------------------
export const allowedAdminEmails = ['mooove@nate.com'];
const allowedOrigins = [
'http://localhost:3001',
'http://localhost:3000',
'https://normadbobu.web.app',
'https://normadbobu.firebaseapp.com',
'https://normadbobu.com',
];
export const ROLE_THRESHOLD = {
MASTER: 9,
ADMIN: 7,
MANAGER: 5,
TEACHER: 3,
USER: 1,
PUBLIC: 0,
} as const;
export type BoardAccessMode = 'public' | 'private' | 'admin';
export const cookieExpiration = 60 * 60 * 24 * 5 * 1000; // 5 days
// --------------------------------------SERVERS CONFIGURATION--------------------------------------
// Define common settings
const DEFAULT_REGION = 'asia-northeast3';
const DEFAULT_MEMORY = '256MiB';
const STANDARD_MAX_INSTANCES = 1000;
const LOW_TRAFFIC_MAX_INSTANCES = 100;
const DEFAULT_TIMEOUT_SECONDS = 60;
const allowCredentials = true;
export const corsV2Origins = allowedOrigins;
// --- V1/Middleware CORS Configuration (Keep for backward compatibility or specific needs) ---
const corsMiddlewareOptions = {
origin: function (
origin: string | undefined,
callback: (err: Error | null, allow?: boolean) => void,
) {
if (origin === undefined || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
console.warn(`CORS Middleware: Origin '${origin}' not allowed.`);
callback(new Error('Not allowed by CORS!'));
}
},
credentials: allowCredentials,
};
export const corsMiddlewareHandler = corsMiddleware(corsMiddlewareOptions); // Renamed export
export const commonV2RuntimeOptions: HttpsOptions = {
region: DEFAULT_REGION,
memory: DEFAULT_MEMORY,
maxInstances: STANDARD_MAX_INSTANCES,
timeoutSeconds: DEFAULT_TIMEOUT_SECONDS,
};
export const lowTrafficV2RuntimeOptions: HttpsOptions = {
region: DEFAULT_REGION,
memory: '128MiB',
maxInstances: LOW_TRAFFIC_MAX_INSTANCES,
timeoutSeconds: DEFAULT_TIMEOUT_SECONDS,
};
// --------------------------------------UPLOAD SETTINGS--------------------------------------
// Upload Settings
const MAX_FILE_SIZE_MB = 20;
const MAX_TOTAL_FILES = 10;
export const ALLOWED_COLLECTIONS = new Set([
'notices',
'projects' /* … */,
] as const);
const ALLOWED_IMAGE_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/jpg',
];
const ALLOWED_VIDEO_TYPES = [
'video/mp4',
'video/avi',
'video/mpeg',
'video/quicktime',
'video/x-ms-wmv',
];
export const ALLOWED_DOCUMENT_TYPES = [
// Word Documents
'application/msword', // .doc
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
// Excel Sheets
'application/vnd.ms-excel', // .xls
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
// PowerPoint Presentations
'application/vnd.ms-powerpoint', // .ppt
'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx
// PDFs
'application/pdf',
// Hangul Word Processor (Korean)
'application/x-hwp', // .hwp (standardized older type)
'application/x-hwpx', // .hwpx (newer format)
];
const ALLOWED_FILE_TYPES = [
...ALLOWED_IMAGE_TYPES,
...ALLOWED_DOCUMENT_TYPES,
...ALLOWED_VIDEO_TYPES,
];
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const isImageFile = (file: File): boolean =>
ALLOWED_IMAGE_TYPES.includes(file.type);
const isVideoFile = (file: File): boolean =>
ALLOWED_VIDEO_TYPES.includes(file.type);
const isDocumentFile = (file: File): boolean =>
ALLOWED_DOCUMENT_TYPES.includes(file.type);
const isValidFile = (file: File): boolean => {
return (
ALLOWED_FILE_TYPES.includes(file.type) && file.size <= MAX_FILE_SIZE_BYTES
);
};
const getInvalidFileReasonKey = (file: File): string | null => {
if (!ALLOWED_FILE_TYPES.includes(file.type)) return 'upload.invalid_type';
if (file.size > MAX_FILE_SIZE_BYTES) return 'upload.exceeds_size';
return null;
};
// Error Messages
const UploadErrorMessages: Record<string, string> = {
'upload.invalid_type': '허용되지 않은 파일 형식입니다.',
'upload.exceeds_size': `파일 크기는 ${MAX_FILE_SIZE_MB}MB 이하만 가능합니다.`,
};
export const CourseCategories = [
'동료지원가 양성과정',
'정신건강 바로알기',
'회복으로 가는 길',
// '연주',
// '실용음악',
// '국악',
// '융합교육',
// '프로그램'
] as const;
export const ProgramCategories = [
'자격증 과정',
'일반 과정',
'심화 과정',
] as const;
// Video Providers
export const VideoProviders = ['youtube', 'vimeo', 'unknown'] as const;
// --------------------------------------EXPORT EVERYTHING--------------------------------------
// Export
export const UploadSettings = {
MAX_FILE_SIZE_MB,
MAX_TOTAL_FILES,
MAX_FILE_SIZE_BYTES,
ALLOWED_FILE_TYPES,
ALLOWED_IMAGE_TYPES,
ALLOWED_VIDEO_TYPES,
ALLOWED_DOCUMENT_TYPES,
ALLOWED_EXTENSIONS: ALLOWED_FILE_TYPES.join(','),
cookieExpiration,
isImageFile,
isVideoFile,
isDocumentFile,
isValidFile,
getInvalidFileReasonKey,
UploadErrorMessages,
};
export type CourseCategory = (typeof CourseCategories)[number];
export type ProgramCategory = (typeof ProgramCategories)[number];
export type VideoProvider = (typeof VideoProviders)[number];

4
functions/src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import './config';
export * from './storage';
export * from './board';
export * from './auth';

View File

@@ -0,0 +1,221 @@
//NEEDS WORKS ON < PAST CODE >, SERVER SIDE RESIZE IMAGE
import {
onCall,
HttpsError,
CallableRequest,
} from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import { storage } from '../config';
import { toErrorWithMessage } from '../utils';
import { URL } from 'url';
import * as path from 'path';
interface HandleCkeditorImageData {
temporaryUrl: string;
targetBoard: string;
targetDocId: string;
}
/**
* @param {CallableRequest<HandleCkeditorImageData>} request - The request object containing auth and data.
* @param {string} request.data.temporaryUrl - The full HTTPS URL of the temporary image.
* @param {string} request.data.targetBoard - Identifier for the board/category (e.g., 'faq').
* @param {string} request.data.targetDocId - The unique ID of the document being saved.
*
* @returns {Promise<{success: boolean, newUrl?: string}>} Result object.
*/
export const handleCkeditorImageV2 = onCall(
{ region: 'asia-northeast3' },
// No options object - using defaults for region/memory etc.
// Use CallableRequest with the specific data type
async (request: CallableRequest<HandleCkeditorImageData>) => {
// Use request.data to access client payload
const { temporaryUrl, targetBoard, targetDocId } = request.data;
logger.info('handleCkeditorImage: Function invoked.', {
data: request.data,
auth: request.auth?.uid,
});
// --- Input Validation ---
if (!temporaryUrl || !targetBoard || !targetDocId) {
logger.error('handleCkeditorImage: Missing required parameters.', {
data: request.data,
});
throw new HttpsError( // Use imported HttpsError
'invalid-argument',
'Missing required parameters: temporaryUrl, targetBoard, targetDocId must be provided.',
);
}
if (!request.auth) {
logger.error('handleCkeditorImage: User is not authenticated.');
throw new HttpsError(
'unauthenticated',
'The function must be called while authenticated.',
);
}
const userId = request.auth.uid;
logger.info(`handleCkeditorImage: Authenticated user: ${userId}`);
try {
// --- 1. Parse Temporary URL ---
let parsedUrl: URL;
let bucketName: string | undefined;
let tempFilePath: string | undefined;
try {
parsedUrl = new URL(temporaryUrl);
const pathSegments = parsedUrl.pathname.split('/');
if (
parsedUrl.hostname === 'firebasestorage.googleapis.com' &&
pathSegments[3]
) {
bucketName = pathSegments[3];
tempFilePath = decodeURIComponent(
pathSegments.slice(5).join('/'),
).split('?')[0];
} else if (
parsedUrl.hostname === 'storage.googleapis.com' &&
pathSegments[1]
) {
bucketName = pathSegments[1];
tempFilePath = pathSegments.slice(2).join('/');
} else {
throw new Error(
'Could not parse bucket/path from temporary URL hostname: ' +
parsedUrl.hostname,
);
}
if (!bucketName || !tempFilePath) {
throw new Error('Failed to extract bucket or path from URL.');
}
logger.info(
`handleCkeditorImage: Parsed URL: Bucket='${bucketName}', TempPath='${tempFilePath}'`,
);
} catch (urlError: unknown) {
const errorMsg = toErrorWithMessage(urlError).message;
logger.error('handleCkeditorImage: Invalid temporaryUrl format:', {
temporaryUrl,
errorMsg,
});
throw new HttpsError( // Use imported HttpsError
'invalid-argument',
`Invalid temporaryUrl format: ${errorMsg}`,
);
}
// --- 2. Define Destination ---
const tempFileName = path.basename(tempFilePath); // Extract filename
// Construct the destination path
const ckEditorFilePath = `boards/${targetBoard}/${targetDocId}/${tempFileName}`;
logger.info(
`handleCkeditorImage: Destination Path: '${ckEditorFilePath}'`,
);
// --- Get file references using imported storage ---
const bucket = storage.bucket(bucketName); // Use storage from config
const tempFile = bucket.file(tempFilePath);
const destFile = bucket.file(ckEditorFilePath);
// --- 3. Check if Temporary File Exists ---
const [tempExists] = await tempFile.exists();
if (!tempExists) {
logger.error(
`handleCkeditorImage: Temporary file does not exist: ${tempFilePath}`,
);
throw new HttpsError( // Use imported HttpsError
'not-found',
`Temporary file not found at ${tempFilePath}. It might have been already processed or deleted.`,
);
}
// --- 4. Copy File ---
logger.info(
`handleCkeditorImage: Attempting to copy from '${tempFilePath}' to '${ckEditorFilePath}'`,
);
try {
await tempFile.copy(destFile);
logger.info('handleCkeditorImage: File copied successfully.');
} catch (copyError: unknown) {
const errorMsg = toErrorWithMessage(copyError).message;
logger.error('handleCkeditorImage: Error copying file:', {
source: tempFilePath,
dest: ckEditorFilePath,
errorMsg,
});
throw new HttpsError( // Use imported HttpsError
'internal',
`Failed to copy file: ${errorMsg}`,
);
}
// --- 5. Make Destination File Publicly Readable ---
// IMPORTANT: Still makes the file public. Ensure this is the desired behavior.
try {
await destFile.makePublic();
logger.info(
`handleCkeditorImage: Made '${ckEditorFilePath}' publicly readable.`,
);
} catch (publicError: unknown) {
const errorMsg = toErrorWithMessage(publicError).message;
logger.error(
'handleCkeditorImage: Error making destination file public:',
{ dest: ckEditorFilePath, errorMsg },
);
throw new HttpsError( // Use imported HttpsError
'internal',
`File moved, but failed to make public: ${errorMsg}`,
);
}
// --- 6. Get Public URL of Destination File ---
const newUrl = destFile.publicUrl();
logger.info(`handleCkeditorImage: New public URL: ${newUrl}`);
// --- 7. Delete Temporary File ---
logger.info(
`handleCkeditorImage: Attempting to delete temporary file: '${tempFilePath}'`,
);
try {
await tempFile.delete();
logger.info(
'handleCkeditorImage: Temporary file deleted successfully.',
);
} catch (deleteError: unknown) {
// Log the error, but don't fail the whole operation.
logger.error(
'handleCkeditorImage: Error deleting temporary file (non-fatal):',
{
tempFile: tempFilePath,
error: toErrorWithMessage(deleteError).message,
},
);
}
// --- 8. Return Success ---
return { success: true, newUrl: newUrl };
} catch (error: unknown) {
logger.error('handleCkeditorImage: Unhandled error in main try block:', {
error,
});
// Check if it's already an HttpsError, otherwise wrap it
// Use HttpsError imported from v2
if (error instanceof HttpsError) {
throw error;
} else {
throw new HttpsError(
'internal',
toErrorWithMessage(error).message ||
'An unexpected error occurred handling the image.',
);
}
}
}, // End of async handler
); // End of onCall

View File

@@ -0,0 +1 @@
export * from './handleCKeditorImage';

View File

@@ -0,0 +1,260 @@
import type { Timestamp } from 'firebase-admin/firestore';
import type { CourseCategory, ProgramCategory, VideoProvider } from '../config';
export type BoardAccessMode = 'public' | 'private' | 'admin';
export interface CursorResponse<T> {
items: T[];
nextPageToken: string | null;
}
export interface UseBoardListOptions {
title?: string;
itemsPerPage?: number;
defaultSort?: OrderByDirection;
access?: BoardAccessMode;
loadingMessage?: string;
}
//config
// Management Page
/////////////////////////Board Section ////////////////////////////
export type boardState = {
state: 'processing' | 'pending' | 'queued' | 'completed' | 'error';
error?: string;
};
// Board : Elements
export type FileItem = {
name: string;
url: string;
uuid?: string; // ? for old data structure
};
export type ImageUsage = {
boardId: string;
type: 'ckeditor' | 'thumbnail';
};
export type ResizeOptions = 'w200' | 'w500' | 'w1280';
export type ResizedImageVersion = {
url: string; // Publicly accessible HTTPS download URL for this WebP version.
path: string; // Full path in Firebase Storage to this WebP version.
width: number; // Actual width of this WebP version after resizing.
height: number; // Actual height of this WebP version after resizing.
format: 'webp'; // Explicitly 'webp' as all resized versions will be this format.
label: ResizeOptions; // The label used in the filename, e.g., "200x200", "500x500".
};
export type ResizeStatus =
| 'pending_resize'
| 'resizing'
| 'resized'
| 'resize_error'
| 'archived';
export type ImageAsset = {
uuid: string; // UUID of the image asset and as docID
storagePath: string; // e.g., "boards/general/board123/ckeditor/uuid-abc/original/myphoto.jpg"
originalFileNameWithExt: string; // e.g., "myphoto.jpg"
userId: string; // ID of the user who uploaded the image.
createdAt: Timestamp;
boardReferences: ImageUsage[];
originalSize?: {
width: number;
height: number;
format?: string; // .jpg, .png, etc.
};
resizedVersions?: ResizedImageVersion[];
lastProcessedAt?: Timestamp; // resize function last processed this image.
status: ResizeStatus;
processingError?: string; // Stores an error message if 'status' is 'resize_error'.
};
export type ImageItem = FileItem; // Depreciated : Original Image Item
// depreciated : old data structure
export interface UploadFilesOptions {
newBoardData: BoardItem;
newThumbnail: File | null;
newFiles: File[];
oldThumbnailUrl?: string;
oldFiles?: FileItem[];
deleteThumbnail?: boolean;
deleteFileIndexes?: number[];
currentBoard: string; // which indices of old files to delete
}
// depreciated : old data structure
export interface UploadFileData {
input: File | null;
preview: string;
deleteTrigger: boolean;
oldPreviewState: boolean;
uploadState: boolean;
previewState: boolean;
displayedName: string;
}
// Board : Finally
export type BoardItem = {
//metadata
docId: string;
userId: string;
title: string;
created: Timestamp | string;
boardState: boardState;
//contents
description: string;
announcement?: boolean;
//files
files?: FileItem[];
thumbnailUuids?: string; //New field for thumbnailAssetUuid
ckAssetUuids?: string[]; // Stores an array of imageAssetUuids used in description
//depricated
boards_number?: number; //depriciated
ishidden?: boolean; // depriciated, we can use this for later admin control
thumbnail?: ImageItem; //Leave it for previous data structure
};
// Board : Types
export interface ProjectBoard extends BoardItem {
subtitle: string;
displayDate?: string;
author?: string;
}
export interface QuizBoard extends BoardItem {
question: string;
answers: {
text: string;
correct: boolean;
}[];
}
export interface VideoInfo {
url: string;
duration: number;
provider: VideoProvider;
vimeoId?: string;
}
export interface VideoBoard extends BoardItem {
video: VideoInfo;
}
export interface CourseBoard extends BoardItem {
category: CourseCategory;
isStrict: boolean;
courseElements?: CourseElement[];
headline?: string;
video?: VideoInfo;
expiresWhen?: string;
keywords?: string[];
}
// Board : Types Elements
export interface CourseElement {
docId: string;
order: number;
title: string;
type: CourseElementType;
duration?: number;
}
export interface CategoryItem {
id: number;
name: string;
}
export type CourseElementType = 'video' | 'quiz' | 'docs';
// Program
export interface CourseInProgram {
docId: string;
title: string;
order: number;
}
export interface ProgramBoard extends BoardItem {
courses: CourseInProgram[];
category: ProgramCategory;
isStrict: boolean;
headline?: string;
video?: VideoInfo;
expiresWhen?: string;
keywords?: string[];
}
// past
export interface ProgramWithProgress extends ProgramBoard {
progressPercentage: number;
completed: boolean;
lastCourseProgressDocId: string | null;
}
// Course
export interface Coursecreators {
id: number;
name: string;
route: { name: string };
initial: string;
current: boolean;
}
export type CourseNavItem = {
id: number;
name: string;
route: { name: string };
current: boolean;
docId: string;
lessonFinish: boolean;
type: string;
progressId: string;
courseId: string;
};
export interface Lesson {
docId: string;
order: number;
title: string;
type: string;
duration?: number;
}
// Progress
export type Progress = {
docId: string;
boards_number: number;
courseCategory: string;
courseId: string;
created: Timestamp;
lessons: LessonProgress[]; // ✅ Fixed here
userId: string;
courseFinish: Timestamp;
courseTitle: string;
userSurveyed?: boolean;
ishidden?: boolean;
finishedDate?: Timestamp;
complete?: boolean;
extended?: boolean;
};
export type LessonProgress = {
currentTime?: number;
docId: string;
duration?: number;
lessonFinish: boolean;
order: number;
type: string;
title: string;
};
export interface ProgressWithRuntimeData
extends Omit<Progress, 'courseTitle' | 'courseCategory' | 'percentage'> {
totalDuration?: number;
currentTime?: number;
percentage?: number;
courseTitle: string;
category?: string;
courseThumbnail?: string;
courseHeadline?: string;
courseCategory: string;
}
// Nav
export type SortOption = {
sortId: number;
name: string;
rule: OrderByDirection;
};
export type OrderByDirection = 'asc' | 'desc';

View File

@@ -0,0 +1 @@
export * from './boardItem';

View File

@@ -0,0 +1,31 @@
import { CollectionReference, Query } from 'firebase-admin/firestore';
import { ROLE_THRESHOLD, BoardAccessMode } from '../config';
export function buildQueryWithAccessControl<T = FirebaseFirestore.DocumentData>(
baseRef: CollectionReference<T>,
access: BoardAccessMode,
userId: string | null,
userRole: number,
): Query<T> {
if (access === 'private') {
if (!userId) {
throw new Error('Unauthorized: Missing userId for private access');
}
return baseRef.where('userId', '==', userId);
}
if (access === 'admin') {
if (userRole < ROLE_THRESHOLD.ADMIN) {
throw new Error('Forbidden: Admin access only');
}
return baseRef;
}
// ✅ Public access → only show non-hidden documents unless elevated
if (userRole < ROLE_THRESHOLD.MANAGER) {
return baseRef.where('ishidden', '==', false);
}
// Managers+ can see everything in public mode
return baseRef;
}

View File

@@ -0,0 +1,20 @@
import { Timestamp } from 'firebase-admin/firestore';
export const encodeCursor = (
announcement: boolean,
created: Timestamp,
docId: string,
) =>
Buffer.from(
JSON.stringify([announcement, created.toMillis(), docId]),
).toString('base64');
export const decodeCursor = (token: string): [boolean, Timestamp, string] => {
try {
const [a, c, id] = JSON.parse(
Buffer.from(token, 'base64').toString('utf8'),
);
return [!!a, Timestamp.fromMillis(c), id];
} catch {
throw new Error('Bad pageToken');
}
};

View File

@@ -0,0 +1,4 @@
export * from './boardCursor';
export * from './accessControl';
export * from './verifyHelper';
export * from './validation';

View File

@@ -0,0 +1,37 @@
import type { BoardItem } from '../types/boardItem';
// src/utils.ts
export type ErrorWithMessage = {
message: string;
};
export function isErrorWithMessage(error: unknown): error is ErrorWithMessage {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as Record<string, unknown>).message === 'string'
);
}
export function toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
if (isErrorWithMessage(maybeError)) return maybeError;
try {
return new Error(JSON.stringify(maybeError));
} catch {
// fallback in case there's an error stringifying the maybeError
// like with circular references for example.
return new Error(String(maybeError));
}
}
export function isValidBoardItem(
data: Record<string, unknown>,
): data is Omit<BoardItem, 'docId'> {
return (
typeof data.userId === 'string' &&
typeof data.title === 'string' &&
typeof data.description === 'string' &&
typeof data.created !== 'undefined'
);
}

View File

@@ -0,0 +1,33 @@
import { getAuth } from 'firebase-admin/auth';
import type { Request } from 'express';
import * as cookie from 'cookie';
export type DecodedAuthInfo = {
uid: string;
role: number;
};
/**
* Verifies the session cookie from the request and returns the decoded auth info.
* Throws an error if invalid or missing.
* @param {Request} req - The Express request object containing the session cookie
* @param {Response} [res] - Optional Express response object for sending error responses
*/
export async function verifySessionFromRequest(
req: Request,
): Promise<DecodedAuthInfo | null> {
const cookies = cookie.parse(req.headers.cookie || '');
const session = cookies.__session;
if (!session) {
return null;
}
try {
const decoded = await getAuth().verifySessionCookie(session, true);
const role = (decoded.role || 0) as number;
return { uid: decoded.uid, role };
} catch (err) {
console.error('[Auth] Session cookie verification failed:', err);
return null;
}
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": false
},
"include": [
"eslint.config.mjs"
]
}

15
functions/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "es2020",
"removeComments": true
},
"compileOnSave": true,
"include": ["src"]
}