first commit
This commit is contained in:
10
functions/.gitignore
vendored
Normal file
10
functions/.gitignore
vendored
Normal 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
35
functions/.prettierignore
Normal 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
22
functions/.prettierrc
Normal 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
|
||||
}
|
||||
45
functions/eslint.config.mjs
Normal file
45
functions/eslint.config.mjs
Normal 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
9515
functions/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
functions/package.json
Normal file
34
functions/package.json
Normal 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
|
||||
}
|
||||
148
functions/src/auth/changePassword.ts
Normal file
148
functions/src/auth/changePassword.ts
Normal 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
|
||||
59
functions/src/auth/checkEmail.ts
Normal file
59
functions/src/auth/checkEmail.ts
Normal 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
|
||||
55
functions/src/auth/createSession.ts
Normal file
55
functions/src/auth/createSession.ts
Normal 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: CORS‑enabled 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');
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
90
functions/src/auth/deleteUser.ts
Normal file
90
functions/src/auth/deleteUser.ts
Normal 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.');
|
||||
}
|
||||
},
|
||||
);
|
||||
7
functions/src/auth/index.ts
Normal file
7
functions/src/auth/index.ts
Normal 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';
|
||||
28
functions/src/auth/logoutSession.ts
Normal file
28
functions/src/auth/logoutSession.ts
Normal 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: CORS‑enabled 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 });
|
||||
});
|
||||
},
|
||||
);
|
||||
274
functions/src/auth/registerUser.ts
Normal file
274
functions/src/auth/registerUser.ts
Normal 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
|
||||
47
functions/src/auth/verifySession.ts
Normal file
47
functions/src/auth/verifySession.ts
Normal 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' });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
101
functions/src/board/countBoard.ts
Normal file
101
functions/src/board/countBoard.ts
Normal 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: CORS‑enabled 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 allow‑list
|
||||
// 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' });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
154
functions/src/board/fetchBoard.ts
Normal file
154
functions/src/board/fetchBoard.ts
Normal 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: CORS‑enabled 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 allow‑list
|
||||
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'); // tie‑breaker 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' });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
2
functions/src/board/index.ts
Normal file
2
functions/src/board/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './countBoard';
|
||||
export * from './fetchBoard';
|
||||
189
functions/src/config.ts
Normal file
189
functions/src/config.ts
Normal 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
4
functions/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import './config';
|
||||
export * from './storage';
|
||||
export * from './board';
|
||||
export * from './auth';
|
||||
221
functions/src/storage/handleCKeditorImage.ts
Normal file
221
functions/src/storage/handleCKeditorImage.ts
Normal 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
|
||||
1
functions/src/storage/index.ts
Normal file
1
functions/src/storage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './handleCKeditorImage';
|
||||
260
functions/src/types/boardItem.ts
Normal file
260
functions/src/types/boardItem.ts
Normal 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';
|
||||
1
functions/src/types/index.ts
Normal file
1
functions/src/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './boardItem';
|
||||
31
functions/src/utils/accessControl.ts
Normal file
31
functions/src/utils/accessControl.ts
Normal 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;
|
||||
}
|
||||
20
functions/src/utils/boardCursor.ts
Normal file
20
functions/src/utils/boardCursor.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
4
functions/src/utils/index.ts
Normal file
4
functions/src/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './boardCursor';
|
||||
export * from './accessControl';
|
||||
export * from './verifyHelper';
|
||||
export * from './validation';
|
||||
37
functions/src/utils/validation.ts
Normal file
37
functions/src/utils/validation.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
33
functions/src/utils/verifyHelper.ts
Normal file
33
functions/src/utils/verifyHelper.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
functions/tsconfig.dev.json
Normal file
9
functions/tsconfig.dev.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": false
|
||||
},
|
||||
"include": [
|
||||
"eslint.config.mjs"
|
||||
]
|
||||
}
|
||||
15
functions/tsconfig.json
Normal file
15
functions/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user