This commit is contained in:
2025-07-15 11:23:20 +09:00
parent 368c52029a
commit 8fd4066d42
27 changed files with 5091 additions and 1847 deletions

View File

@@ -0,0 +1,73 @@
import { onRequest } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import { defineSecret } from 'firebase-functions/params';
import { initializeApp, getApps } from 'firebase-admin/app';
import sgMail from '@sendgrid/mail';
import type { BoardAccessMode } from '../types/boardItem';
import { corsMiddlewareHandler, SendEmail } from '../config';
/* ─── Boiler-plate init ───────────────────────────────────────── */
if (!getApps().length) initializeApp();
const SENDGRID_API_KEY = defineSecret('SENDGRID_API_KEY');
let isSendGridInitialized = false;
/* ─── Cloud Function entry ────────────────────────────────────── */
export const boardMail = onRequest(
{
region: 'asia-northeast3',
secrets: [SENDGRID_API_KEY], // ❷ declare dependency
},
async (req, res) => {
return corsMiddlewareHandler(req, res, async () => {
/* one-time SendGrid init per cold start */
if (!isSendGridInitialized) {
const apiKey = process.env.SENDGRID_API_KEY;
if (!apiKey) {
logger.error('SendGrid API key is undefined. Check secret setup.');
throw new Error('Missing SendGrid API key');
}
sgMail.setApiKey(apiKey);
isSendGridInitialized = true;
}
/* 1. Method guard --------------------------------------------------- */
if (req.method !== 'POST') {
res.status(405).send({ message: 'Method Not Allowed' });
return;
}
/* 2. Extract params ------------------------------------------------- */
const { access = 'public', subject, html, action = '' } = req.body ?? {};
if (!['public', 'private', 'admin'].includes(access)) {
logger.warn(`Invalid access mode: ${access}`);
res.status(400).send({ message: 'Bad access mode' });
return;
}
const effectiveAccess = access as BoardAccessMode; // ❹ always public/param
logger.info('boardMail: effectiveAccess', effectiveAccess);
if (!subject || !html) {
res.status(400).send({ message: 'Missing subject or html' });
return;
}
try {
await sgMail.send({
to: SendEmail.to,
from: SendEmail.from,
subject,
html,
});
logger.info(
`[boardMail] Email sent successfully via SendGrid [${action}]`,
);
res.status(200).send({ message: 'Email sent' });
} catch (err) {
logger.error('[boardMail] Error sending email', err as Error);
res.status(500).send({ message: 'Internal Server Error' });
}
});
},
);

View File

@@ -26,32 +26,22 @@ export const fetchBoards = onRequest(
return corsMiddlewareHandler(request, response, async () => {
logger.info('fetchBoards: CORSenabled function called');
/* 1.Method guard ------------------------------------------------------------------ */
/* 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 --------------------------------------------------- */
/* 2.Read & validate query params EARLY */
const {
collection = '',
sortOrder = 'desc',
itemsPerPage = '10',
access = 'public',
access = 'public', // Default to 'public' if not provided
pageToken,
} = request.query as Record<string, string>;
// ❶collection allowlist
// ❶ collection allow-list
if (
!ALLOWED_COLLECTIONS.has(
collection as unknown as typeof ALLOWED_COLLECTIONS extends Set<
@@ -65,25 +55,45 @@ export const fetchBoards = onRequest(
return;
}
// ❷access must be one of three strings
// ❷ access must be one of three strings. Validate BEFORE auth for basic correctness.
if (!['public', 'private', 'admin'].includes(access)) {
response.status(400).send({ message: 'Bad access mode' });
return;
}
/* 3. Authentication and Authorization Check */
let userRole = 0;
let userId: string | null = null;
let authUser = null; // Initialize authUser to null
try {
authUser = await verifySessionFromRequest(request);
} catch (err) {
logger.warn('fetchBoards: Session verification failed', err);
}
if (authUser) {
userId = authUser.uid;
userRole = authUser.role;
}
// If user is NOT logged in (userId is null) AND they are requesting 'private' or 'admin' access
if (!userId && (access === 'private' || access === 'admin')) {
response
.status(401)
.send({ message: 'Unauthorized: Login required for this resource.' });
return; // Function stops here.
}
// ❸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,
access as BoardAccessMode, // Use the original 'access'
userId,
userRole,
);
@@ -141,7 +151,7 @@ export const fetchBoards = onRequest(
)
: null;
if (effectiveAccess === 'public')
if (access === 'public')
response.set('Cache-Control', 'public,max-age=60');
response.status(200).send({ items, nextPageToken });

View File

@@ -0,0 +1,112 @@
import { onRequest } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import { getFirestore } from 'firebase-admin/firestore';
import { initializeApp, getApps } from 'firebase-admin/app';
import type { BoardItem, BoardAccessMode } from '../types/boardItem';
import { corsMiddlewareHandler, ALLOWED_COLLECTIONS } from '../config';
import {
isValidBoardItem,
verifySessionFromRequest,
buildQueryWithAccessControl,
} from '../utils';
if (!getApps().length) {
initializeApp();
}
const db = getFirestore();
export const fetchSingleItem = onRequest(
{ region: 'asia-northeast3' },
async (request, response) => {
return corsMiddlewareHandler(request, response, async () => {
logger.info('fetchSingleItem: 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. Query params
const {
collection = '',
docId = '',
access = 'public',
} = request.query as Record<string, string>;
if (!collection || !docId) {
response.status(400).send({ message: 'Missing parameters' });
return;
}
// ❶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;
}
if (!['public', 'private', 'admin'].includes(access)) {
response.status(400).send({ message: 'Bad access mode' });
return;
}
const accessParam = (access ?? 'public') as BoardAccessMode;
const effectiveAccess = userId ? accessParam : 'public'; // force public if anonymous
try {
// Build query with access control
const query = buildQueryWithAccessControl(
db.collection(collection),
effectiveAccess,
userId,
userRole,
).where('__name__', '==', docId);
const snap = await query.limit(1).get();
if (snap.empty) {
response
.status(404)
.send({ message: 'Item not found or access denied' });
return;
}
const docSnap = snap.docs[0];
const data = docSnap.data() as BoardItem;
if (!isValidBoardItem(data)) {
response.status(500).send({ message: 'Invalid item data' });
return;
}
if (access === 'public') {
response.set('Cache-Control', 'public,max-age=60');
}
response.status(200).send({ ...data, docId: docSnap.id });
return;
} catch (err) {
logger.error('fetchSingleItem: Firestore error', err);
response.status(500).send({ message: 'Internal Server Error' });
}
});
},
);

View File

@@ -1,2 +1,4 @@
export * from './countBoard';
export * from './fetchBoard';
export * from './fetchSingleItem';
export * from './boardMail';