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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,6 +9,8 @@ coverage/
|
|||||||
# Production
|
# Production
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
.vite/
|
.vite/
|
||||||
|
|||||||
59
__tests__/security.test.ts
Normal file
59
__tests__/security.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,14 +4,16 @@ frontend:
|
|||||||
preBuild:
|
preBuild:
|
||||||
commands:
|
commands:
|
||||||
- npm ci
|
- npm ci
|
||||||
|
- npm run security:check
|
||||||
build:
|
build:
|
||||||
commands:
|
commands:
|
||||||
- npx tsx scripts/build-data.js
|
|
||||||
- npm run build
|
- npm run build
|
||||||
|
- npm run test
|
||||||
artifacts:
|
artifacts:
|
||||||
baseDirectory: dist
|
baseDirectory: .next
|
||||||
files:
|
files:
|
||||||
- '**/*'
|
- '**/*'
|
||||||
cache:
|
cache:
|
||||||
paths:
|
paths:
|
||||||
- node_modules/**/*
|
- node_modules/**/*
|
||||||
|
- .next/cache/**/*
|
||||||
37
app/api/filaments/route.ts
Normal file
37
app/api/filaments/route.ts
Normal 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
19
app/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,25 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { FilamentTable } from './components/FilamentTable';
|
import { FilamentTable } from '../src/components/FilamentTable';
|
||||||
import { Filament } from './types/filament';
|
import { Filament } from '../src/types/filament';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
function App() {
|
export default function Home() {
|
||||||
const [filaments, setFilaments] = useState<Filament[]>([]);
|
const [filaments, setFilaments] = useState<Filament[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||||
const [darkMode, setDarkMode] = useState(() => {
|
const [darkMode, setDarkMode] = useState(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
const saved = localStorage.getItem('darkMode');
|
const saved = localStorage.getItem('darkMode');
|
||||||
return saved ? JSON.parse(saved) : false;
|
return saved ? JSON.parse(saved) : false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||||
if (darkMode) {
|
if (darkMode) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [darkMode]);
|
}, [darkMode]);
|
||||||
|
|
||||||
const fetchFilaments = async () => {
|
const fetchFilaments = async () => {
|
||||||
@@ -27,9 +34,7 @@ function App() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// In development, use the API endpoint; in production, use the static JSON
|
const response = await axios.get('/api/filaments');
|
||||||
const url = import.meta.env.DEV ? '/api/filaments' : '/filaments.json';
|
|
||||||
const response = await axios.get(url);
|
|
||||||
setFilaments(response.data);
|
setFilaments(response.data);
|
||||||
setLastUpdate(new Date());
|
setLastUpdate(new Date());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -92,5 +97,3 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
|
||||||
13
index.html
13
index.html
@@ -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
20
jest.config.js
Normal 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
1
jest.setup.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom'
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal 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
7
next.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
output: 'standalone',
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
5036
package-lock.json
generated
5036
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -2,21 +2,27 @@
|
|||||||
"name": "filamenteka",
|
"name": "filamenteka",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "next dev",
|
||||||
"build": "tsc && vite build",
|
"build": "next build",
|
||||||
"preview": "vite preview",
|
"start": "next start",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "next lint",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"security:check": "node scripts/security-check.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/cheerio": "^0.22.35",
|
"@types/cheerio": "^0.22.35",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"cheerio": "^1.1.0",
|
"cheerio": "^1.1.0",
|
||||||
"react": "^18.2.0",
|
"next": "^15.3.4",
|
||||||
"react-dom": "^18.2.0"
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
@@ -26,6 +32,8 @@
|
|||||||
"eslint": "^8.55.0",
|
"eslint": "^8.55.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"jest": "^30.0.0",
|
||||||
|
"jest-environment-jsdom": "^30.0.0",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export default {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
|
|||||||
@@ -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": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -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
91
scripts/security-check.js
Normal 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');
|
||||||
|
}
|
||||||
10
src/main.tsx
10
src/main.tsx
@@ -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>,
|
|
||||||
)
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
module.exports = {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ provider "aws" {
|
|||||||
resource "aws_amplify_app" "filamenteka" {
|
resource "aws_amplify_app" "filamenteka" {
|
||||||
name = "filamenteka"
|
name = "filamenteka"
|
||||||
repository = var.github_repository
|
repository = var.github_repository
|
||||||
|
platform = "WEB_COMPUTE"
|
||||||
|
|
||||||
# GitHub access token for private repos
|
# GitHub access token for private repos
|
||||||
access_token = var.github_token
|
access_token = var.github_token
|
||||||
|
|
||||||
# Build settings
|
# Build settings for Next.js
|
||||||
build_spec = <<-EOT
|
build_spec = <<-EOT
|
||||||
version: 1
|
version: 1
|
||||||
frontend:
|
frontend:
|
||||||
@@ -27,16 +28,19 @@ resource "aws_amplify_app" "filamenteka" {
|
|||||||
preBuild:
|
preBuild:
|
||||||
commands:
|
commands:
|
||||||
- npm ci
|
- npm ci
|
||||||
|
- npm run security:check
|
||||||
build:
|
build:
|
||||||
commands:
|
commands:
|
||||||
- npm run build
|
- npm run build
|
||||||
|
- npm run test
|
||||||
artifacts:
|
artifacts:
|
||||||
baseDirectory: dist
|
baseDirectory: .next
|
||||||
files:
|
files:
|
||||||
- '**/*'
|
- '**/*'
|
||||||
cache:
|
cache:
|
||||||
paths:
|
paths:
|
||||||
- node_modules/**/*
|
- node_modules/**/*
|
||||||
|
- .next/cache/**/*
|
||||||
EOT
|
EOT
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2017",
|
||||||
"useDefineForClassFields": true,
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"allowJs": true,
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"jsx": "preserve",
|
||||||
"jsx": "react-jsx",
|
"incremental": true,
|
||||||
"strict": true,
|
"plugins": [
|
||||||
"noUnusedLocals": true,
|
{
|
||||||
"noUnusedParameters": true,
|
"name": "next"
|
||||||
"noFallthroughCasesInSwitch": true
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
@@ -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'
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user