Skip to content

Commit a216e83

Browse files
committed
Add GCS proxy endpoint for serving report files and MIME type mapping
1 parent d54abc0 commit a216e83

File tree

3 files changed

+139
-1
lines changed

3 files changed

+139
-1
lines changed

src/controllers/cdnController.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { Storage } from '@google-cloud/storage';
2+
3+
// Initialize GCS client (uses Application Default Credentials)
4+
const storage = new Storage();
5+
6+
// MIME type mapping for common file extensions
7+
const MIME_TYPES = {
8+
'.json': 'application/json',
9+
'.html': 'text/html',
10+
'.css': 'text/css',
11+
'.js': 'application/javascript',
12+
'.png': 'image/png',
13+
'.jpg': 'image/jpeg',
14+
'.jpeg': 'image/jpeg',
15+
'.gif': 'image/gif',
16+
'.svg': 'image/svg+xml',
17+
'.webp': 'image/webp',
18+
'.ico': 'image/x-icon',
19+
'.txt': 'text/plain',
20+
'.csv': 'text/csv',
21+
'.xml': 'application/xml',
22+
'.pdf': 'application/pdf',
23+
'.woff': 'font/woff',
24+
'.woff2': 'font/woff2',
25+
'.ttf': 'font/ttf',
26+
'.eot': 'application/vnd.ms-fontobject'
27+
};
28+
29+
/**
30+
* Get MIME type from file path
31+
*/
32+
function getMimeType(filePath) {
33+
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
34+
return MIME_TYPES[ext] || 'application/octet-stream';
35+
}
36+
37+
/**
38+
* Proxy endpoint to serve files from private GCS bucket
39+
* GET /v1/reports/*
40+
*
41+
* This serves as a proxy for files stored in gs://httparchive/reports/
42+
* The request path after /v1/reports/ maps directly to the GCS object path
43+
*/
44+
export const proxyReportsFile = async (req, res, filePath) => {
45+
try {
46+
const BUCKET_NAME = process.env.GCS_BUCKET_NAME || 'httparchive';
47+
const BASE_PATH = process.env.GCS_REPORTS_PATH || 'reports';
48+
49+
// Validate file path to prevent directory traversal
50+
if (filePath.includes('..') || filePath.includes('//')) {
51+
res.statusCode = 400;
52+
res.end(JSON.stringify({ error: 'Invalid file path' }));
53+
return;
54+
}
55+
56+
// Remove leading slash if present
57+
const cleanPath = filePath.startsWith('/') ? filePath.slice(1) : filePath;
58+
59+
// Construct the full GCS object path
60+
const objectPath = `${BASE_PATH}/${cleanPath}`;
61+
62+
// Get the file from GCS
63+
const bucket = storage.bucket(BUCKET_NAME);
64+
const file = bucket.file(objectPath);
65+
66+
// Check if file exists
67+
const [exists] = await file.exists();
68+
if (!exists) {
69+
res.statusCode = 404;
70+
res.end(JSON.stringify({ error: 'File not found' }));
71+
return;
72+
}
73+
74+
// Get file metadata for content type and caching
75+
const [metadata] = await file.getMetadata();
76+
77+
// Determine content type
78+
const contentType = metadata.contentType || getMimeType(objectPath);
79+
80+
// Set response headers
81+
res.setHeader('Content-Type', contentType);
82+
res.setHeader('Cache-Control', 'public, max-age=86400'); // 24 hours
83+
res.setHeader('Access-Control-Allow-Origin', '*');
84+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
85+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
86+
87+
if (metadata.etag) {
88+
res.setHeader('ETag', metadata.etag);
89+
}
90+
if (metadata.size) {
91+
res.setHeader('Content-Length', metadata.size);
92+
}
93+
94+
// Check for conditional request (If-None-Match)
95+
const ifNoneMatch = req.headers['if-none-match'];
96+
if (ifNoneMatch && metadata.etag && ifNoneMatch === metadata.etag) {
97+
res.statusCode = 304;
98+
res.end();
99+
return;
100+
}
101+
102+
// Stream the file content to the response
103+
res.statusCode = 200;
104+
105+
const readStream = file.createReadStream();
106+
107+
readStream.on('error', (err) => {
108+
console.error('Error streaming file from GCS:', err);
109+
if (!res.headersSent) {
110+
res.statusCode = 500;
111+
res.end(JSON.stringify({ error: 'Failed to read file' }));
112+
}
113+
});
114+
115+
readStream.pipe(res);
116+
117+
} catch (error) {
118+
console.error('Error proxying GCS file:', error);
119+
if (!res.headersSent) {
120+
res.statusCode = 500;
121+
res.end(JSON.stringify({
122+
error: 'Failed to retrieve file',
123+
details: error.message
124+
}));
125+
}
126+
}
127+
};

src/index.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ const handleRequest = async (req, res) => {
168168
const { resetCache } = await import('./utils/controllerHelpers.js');
169169
const result = resetCache();
170170
sendJSONResponse(res, result);
171+
} else if (pathname.startsWith('/v1/reports/') && req.method === 'GET') {
172+
// GCS proxy endpoint for reports files
173+
const filePath = pathname.replace('/v1/reports/', '');
174+
if (!filePath) {
175+
res.statusCode = 400;
176+
res.end(JSON.stringify({ error: 'File path required' }));
177+
return;
178+
}
179+
const { proxyReportsFile } = await getController('cdn');
180+
await proxyReportsFile(req, res, filePath);
171181
} else {
172182
// 404 Not Found
173183
res.statusCode = 404;

src/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
},
1515
"dependencies": {
1616
"@google-cloud/firestore": "7.11.6",
17-
"@google-cloud/functions-framework": "4.0.1"
17+
"@google-cloud/functions-framework": "4.0.1",
18+
"@google-cloud/storage": "7.14.0"
1819
},
1920
"devDependencies": {
2021
"@jest/transform": "^30.2.0",

0 commit comments

Comments
 (0)