Convert to Next.js with security features

- Migrate from Vite to Next.js 15 for server-side API support
- Add dynamic API route at /api/filaments that fetches from Confluence
- Implement security measures:
  - API credentials only accessible server-side
  - Security scan script to detect credential leaks
  - Tests to ensure no sensitive data exposure
  - Build-time security checks in CI/CD
- Update AWS Amplify configuration for Next.js deployment
- Update Terraform to use WEB_COMPUTE platform for Next.js
- Add Jest tests for API security
- Remove static JSON approach in favor of dynamic API

This provides real-time data updates while keeping credentials secure on the server.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DaX
2025-06-19 00:11:19 +02:00
parent 4b39190251
commit 21f6577592
22 changed files with 5306 additions and 578 deletions

2
.gitignore vendored
View File

@@ -9,6 +9,8 @@ coverage/
# Production
dist/
build/
.next/
out/
# Vite
.vite/

View File

@@ -0,0 +1,59 @@
// Mock Next.js server components
jest.mock('next/server', () => ({
NextResponse: {
json: (data: any, init?: ResponseInit) => ({
json: async () => data,
...init
})
}
}));
// Mock confluence module
jest.mock('../src/server/confluence', () => ({
fetchFromConfluence: jest.fn()
}));
import { GET } from '../app/api/filaments/route';
import { fetchFromConfluence } from '../src/server/confluence';
describe('API Security Tests', () => {
const originalEnv = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('should not expose credentials in error responses', async () => {
// Simulate missing environment variables
delete process.env.CONFLUENCE_TOKEN;
const response = await GET();
const data = await response.json();
// Check that response doesn't contain sensitive information
expect(JSON.stringify(data)).not.toContain('ATATT');
expect(JSON.stringify(data)).not.toContain('token');
expect(JSON.stringify(data)).not.toContain('password');
expect(data.error).toBe('Server configuration error');
});
it('should not expose internal error details', async () => {
// Set invalid environment to trigger error
process.env.CONFLUENCE_API_URL = 'invalid-url';
process.env.CONFLUENCE_TOKEN = 'test-token';
process.env.CONFLUENCE_PAGE_ID = 'test-page';
const response = await GET();
const data = await response.json();
// Should get generic error, not specific details
expect(data.error).toBe('Failed to fetch filaments');
expect(data).not.toHaveProperty('stack');
expect(data).not.toHaveProperty('message');
});
});

View File

@@ -4,14 +4,16 @@ frontend:
preBuild:
commands:
- npm ci
- npm run security:check
build:
commands:
- npx tsx scripts/build-data.js
- npm run build
- npm run test
artifacts:
baseDirectory: dist
baseDirectory: .next
files:
- '**/*'
cache:
paths:
- node_modules/**/*
- node_modules/**/*
- .next/cache/**/*

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { fetchFromConfluence } from '../../../src/server/confluence';
export async function GET() {
try {
// Get environment variables from server-side only
const env = {
CONFLUENCE_API_URL: process.env.CONFLUENCE_API_URL,
CONFLUENCE_TOKEN: process.env.CONFLUENCE_TOKEN,
CONFLUENCE_PAGE_ID: process.env.CONFLUENCE_PAGE_ID,
};
// Validate environment variables
if (!env.CONFLUENCE_API_URL || !env.CONFLUENCE_TOKEN || !env.CONFLUENCE_PAGE_ID) {
console.error('Missing Confluence environment variables');
return NextResponse.json(
{ error: 'Server configuration error' },
{ status: 500 }
);
}
const filaments = await fetchFromConfluence(env);
return NextResponse.json(filaments, {
headers: {
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
},
});
} catch (error) {
console.error('API Error:', error);
// Never expose internal error details to client
return NextResponse.json(
{ error: 'Failed to fetch filaments' },
{ status: 500 }
);
}
}

19
app/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
import '../src/styles/index.css'
export const metadata: Metadata = {
title: 'Filamenteka',
description: 'Automatsko praćenje filamenata sa kodiranjem bojama',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="sr">
<body>{children}</body>
</html>
)
}

View File

@@ -1,24 +1,31 @@
'use client'
import { useState, useEffect } from 'react';
import { FilamentTable } from './components/FilamentTable';
import { Filament } from './types/filament';
import { FilamentTable } from '../src/components/FilamentTable';
import { Filament } from '../src/types/filament';
import axios from 'axios';
function App() {
export default function Home() {
const [filaments, setFilaments] = useState<Filament[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const [darkMode, setDarkMode] = useState(() => {
const saved = localStorage.getItem('darkMode');
return saved ? JSON.parse(saved) : false;
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('darkMode');
return saved ? JSON.parse(saved) : false;
}
return false;
});
useEffect(() => {
localStorage.setItem('darkMode', JSON.stringify(darkMode));
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
if (typeof window !== 'undefined') {
localStorage.setItem('darkMode', JSON.stringify(darkMode));
if (darkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
}, [darkMode]);
@@ -27,9 +34,7 @@ function App() {
setLoading(true);
setError(null);
// In development, use the API endpoint; in production, use the static JSON
const url = import.meta.env.DEV ? '/api/filaments' : '/filaments.json';
const response = await axios.get(url);
const response = await axios.get('/api/filaments');
setFilaments(response.data);
setLastUpdate(new Date());
} catch (err) {
@@ -91,6 +96,4 @@ function App() {
</div>
);
}
export default App;
}

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Filamenteka - Bambu Lab Filament Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

20
jest.config.js Normal file
View File

@@ -0,0 +1,20 @@
const nextJest = require('next/jest')
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
})
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
},
testPathIgnorePatterns: ['<rootDir>/.next/', '<rootDir>/node_modules/'],
modulePathIgnorePatterns: ['<rootDir>/.next/'],
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)

1
jest.setup.js Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7
next.config.js Normal file
View File

@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
}
module.exports = nextConfig

5036
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,21 +2,27 @@
"name": "filamenteka",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "jest",
"test:watch": "jest --watch",
"security:check": "node scripts/security-check.js"
},
"dependencies": {
"@types/cheerio": "^0.22.35",
"axios": "^1.6.2",
"cheerio": "^1.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"next": "^15.3.4",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/jest": "^30.0.0",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
@@ -26,6 +32,8 @@
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"jest": "^30.0.0",
"jest-environment-jsdom": "^30.0.0",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.0",
"tsx": "^4.20.3",

View File

@@ -1,4 +1,4 @@
export default {
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},

View File

@@ -1,387 +0,0 @@
[
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Mistletoe Green",
"refill": "",
"vakum": "vakuum x1",
"otvoreno": "otvorena x1",
"kolicina": "2",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Indingo Purple",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Black",
"refill": "",
"vakum": "",
"otvoreno": "2x otvorena",
"kolicina": "2",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Black",
"refill": "Da",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Jade White",
"refill": "",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Gray",
"refill": "",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Red",
"refill": "",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Hot Pink",
"refill": "",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Cocoa Brown",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "White",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Coton Candy Cloud",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Sunflower Yellow",
"refill": "",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Yellow",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Magenta",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Beige",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Cyan",
"refill": "",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Matte",
"boja": "Scarlet Red",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Matte",
"boja": "Mandarin Orange",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Matte",
"boja": "Marine Blue",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Matte",
"boja": "Charcoal",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Matte",
"boja": "Ivory White",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Matte",
"boja": "Ivory White",
"refill": "Da",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Matte",
"boja": "Ash Gray",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Cobalt Blue",
"refill": "Da",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Turquoise",
"refill": "Da",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Matte",
"boja": "Nardo Gray",
"refill": "Da",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Bright Green",
"refill": "Da",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Matte",
"boja": "Charcoal",
"refill": "Da",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Basic",
"boja": "Gold",
"refill": "Da",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Glow",
"boja": "Glow Green",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "Wood",
"boja": "Black Walnut",
"refill": "",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "CF",
"boja": "Black",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PLA",
"finish": "CF",
"boja": "Jeans Blue",
"refill": "",
"vakum": "",
"otvoreno": "otvorena",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "PETG",
"finish": "",
"boja": "Black",
"refill": "",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
},
{
"brand": "BambuLab",
"tip": "ABS",
"finish": "",
"boja": "Black",
"refill": "",
"vakum": "vakuum",
"otvoreno": "",
"kolicina": "",
"cena": ""
}
]

View File

@@ -1,35 +0,0 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { fetchFromConfluence } from '../src/server/confluence.ts';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function buildData() {
try {
console.log('Fetching filament data from Confluence...');
const filaments = await fetchFromConfluence({
CONFLUENCE_API_URL: process.env.CONFLUENCE_API_URL,
CONFLUENCE_TOKEN: process.env.CONFLUENCE_TOKEN,
CONFLUENCE_PAGE_ID: process.env.CONFLUENCE_PAGE_ID
});
// Create public directory if it doesn't exist
const publicDir = path.join(__dirname, '..', 'public');
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
// Write data to public directory
const dataPath = path.join(publicDir, 'filaments.json');
fs.writeFileSync(dataPath, JSON.stringify(filaments, null, 2));
console.log(`Successfully wrote ${filaments.length} filaments to ${dataPath}`);
} catch (error) {
console.error('Error building data:', error);
process.exit(1);
}
}
buildData();

91
scripts/security-check.js Normal file
View File

@@ -0,0 +1,91 @@
const fs = require('fs');
const path = require('path');
// Patterns that might indicate leaked credentials
const sensitivePatterns = [
/ATATT[A-Za-z0-9+/=]{100,}/g, // Confluence tokens
/ghp_[A-Za-z0-9]{36,}/g, // GitHub tokens
/api[_-]?key[_-]?[=:]\s*["']?[A-Za-z0-9+/=]{20,}/gi,
/token[_-]?[=:]\s*["']?[A-Za-z0-9+/=]{20,}/gi,
/password[_-]?[=:]\s*["']?[^\s"']{8,}/gi,
/secret[_-]?[=:]\s*["']?[A-Za-z0-9+/=]{20,}/gi,
];
// Files to exclude from scanning
const excludePatterns = [
/node_modules/,
/\.git/,
/\.next/,
/dist/,
/build/,
/terraform\.tfvars$/,
/\.env/,
/security-check\.js$/,
];
function scanFile(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
const issues = [];
sensitivePatterns.forEach(pattern => {
const matches = content.match(pattern);
if (matches) {
matches.forEach(match => {
// Only flag if it's not a placeholder or example
if (!match.includes('example') && !match.includes('YOUR_') && !match.includes('xxx')) {
issues.push({
file: filePath,
pattern: pattern.source,
match: match.substring(0, 20) + '...',
});
}
});
}
});
return issues;
}
function scanDirectory(dir) {
const issues = [];
function walk(currentPath) {
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
const relativePath = path.relative(process.cwd(), fullPath);
// Skip excluded paths
if (excludePatterns.some(pattern => pattern.test(relativePath))) {
continue;
}
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile() && /\.(js|ts|jsx|tsx|json|yml|yaml|md)$/.test(entry.name)) {
const fileIssues = scanFile(fullPath);
issues.push(...fileIssues);
}
}
}
walk(dir);
return issues;
}
// Run the scan
console.log('🔍 Scanning for potential credential leaks...\n');
const issues = scanDirectory(process.cwd());
if (issues.length > 0) {
console.error('❌ Found potential credential leaks:\n');
issues.forEach(issue => {
console.error(`File: ${issue.file}`);
console.error(`Pattern: ${issue.pattern}`);
console.error(`Match: ${issue.match}\n`);
});
process.exit(1);
} else {
console.log('✅ No credential leaks detected');
}

View File

@@ -1,10 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -1,9 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
module.exports = {
darkMode: 'class',
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {

View File

@@ -15,11 +15,12 @@ provider "aws" {
resource "aws_amplify_app" "filamenteka" {
name = "filamenteka"
repository = var.github_repository
platform = "WEB_COMPUTE"
# GitHub access token for private repos
access_token = var.github_token
# Build settings
# Build settings for Next.js
build_spec = <<-EOT
version: 1
frontend:
@@ -27,16 +28,19 @@ resource "aws_amplify_app" "filamenteka" {
preBuild:
commands:
- npm ci
- npm run security:check
build:
commands:
- npm run build
- npm run test
artifacts:
baseDirectory: dist
baseDirectory: .next
files:
- '**/*'
cache:
paths:
- node_modules/**/*
- .next/cache/**/*
EOT
# Environment variables

View File

@@ -1,21 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,46 +0,0 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
// Dynamic import to avoid TypeScript issues
async function getConfluenceData(env: any) {
const { fetchFromConfluence } = await import('./src/server/confluence.js')
return fetchFromConfluence(env)
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
publicDir: 'public',
plugins: [
react(),
{
name: 'api-middleware',
configureServer(server) {
server.middlewares.use('/api/filaments', async (req, res, next) => {
if (req.method !== 'GET') {
res.statusCode = 405
res.end('Method not allowed')
return
}
try {
const filaments = await getConfluenceData(env)
res.setHeader('Content-Type', 'application/json')
res.setHeader('Cache-Control', 'max-age=300')
res.end(JSON.stringify(filaments))
} catch (error) {
console.error('API Error:', error)
res.statusCode = 500
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({
error: 'Failed to fetch filaments',
message: error instanceof Error ? error.message : 'Unknown error'
}))
}
})
}
}
]
}
})