Enhance security and localization features across the application

- Added rehype-sanitize plugin to the markdown configuration for improved security against XSS attacks.
- Updated environment variables in the codebase to include new configurations for SMTP and monitoring.
- Implemented secure headers in server and Nginx configurations to bolster security.
- Refactored email handling to prevent spoofing by ensuring safe sender addresses.
- Improved localization by updating language persistence and button components for better user experience.
- Enhanced the uptime API and contact form with better error handling and logging practices.
- Updated dependencies in package.json and package-lock.json for better performance and security.
This commit is contained in:
2025-10-19 21:13:15 +02:00
parent 6257a223b2
commit a767dbb115
26 changed files with 4931 additions and 833 deletions

52
DESIGN_REVIEW.md Normal file
View File

@@ -0,0 +1,52 @@
## Quick wins (top 5)
1) Add `rehype-sanitize` (already applied) and remove unnecessary `set:html` usages to prevent XSS and improve content robustness.
2) Eliminate CDN for Mermaid; use local dependency (already applied) to improve performance, privacy, and CSP compliance.
3) Optimize hero and large images using `src/components/common/Image.astro` everywhere; enforce `width`/`height` and lazy loading for non-LCP images.
4) Reduce JS on static pages by avoiding unnecessary client-side logs and inline scripts; prefer islands.
5) Improve form UX: ensure visible error messages, labels, and aria-live regions are consistent; keep focus management for spam/manual-review flows.
## Issues table
| ID | Page/Component | Problem | Impact | Fix |
|---|---|---|---|---|
| D1 | `Hero/Hero2/HeroText` | `set:html` in multiple slots | Med | Potential rendering inconsistencies and sanitization risk | Prefer rendering plain strings or sanitized HTML only |
| D2 | `Image.astro` usage | Not consistently used across pages | High | LCP/CLS and bandwidth | Replace `<img>` with `Image.astro` wrapper for optimization |
| D3 | `Footer.astro` | email obfuscation used `innerHTML` | Low | Minor XSS risk | Switched to `textContent` (applied) |
| D4 | Typography | Some lines exceed 85ch | Low | Readability | Use `max-w-prose` or `ch`-based widths in content wrappers |
| D5 | Forms | Inline scripts manage state | Med | Maintainability/perf | Extract to small islands if complexity grows |
## Before/After snippets
Replace `set:html` for simple line breaks:
```astro
<!-- Before -->
<div set:html={text.replace(/\n/g, '<br/>')}></div>
<!-- After -->
<div>
{text.split('\n').map((line) => (<>
{line}<br/>
</>))}
</div>
```
Avoid `innerHTML` when setting modal titles:
```js
// Before
titleEl.innerHTML = title;
// After
titleEl.textContent = String(title);
```
## Asset and typography guidance
- Use `src/components/common/Image.astro` for all images. Ensure: `alt` always set, `loading="lazy"` for non-LCP, explicit `width/height` to prevent CLS.
- Fonts: prefer `font-display: swap` (already via Inter variable). Limit weights/axes for performance.
- Containers: keep readable line length using `max-w-prose` or `max-w-[75ch]` for long text sections.
- Hydration: ensure islands only where needed (forms, uptime widgets). Keep content pages server-rendered.

View File

@@ -65,4 +65,29 @@ AstroWind is an open-source project created and maintained by [onWidget](https:/
- 💬 Community discussions: [AstroWind Discussions](https://github.com/onwidget/astrowind/discussions)
- 📄 License: [MIT License](https://github.com/onwidget/astrowind/blob/main/LICENSE.md)
Weve adapted the template to reflect the unique mission and brand identity of 365DevNet, while continuing to follow the project's great structure and standards.
Weve adapted the template to reflect the unique mission and brand identity of 365DevNet, while continuing to follow the project's great structure and standards.
---
## 🔐 Environment Variables
Create a .env file and keep it out of version control. Example:
```env
# Email
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
ADMIN_EMAIL=
WEBSITE_NAME=365DevNet Support
# Monitoring
UPTIME_KUMA_URL=
# SCM
GITEA_TOKEN=
# AI (optional)
GEMINI_API_KEY=
```

135
SECURITY_AUDIT.md Normal file
View File

@@ -0,0 +1,135 @@
## Executive summary
Astro SSR is enabled; endpoints handle contact, uptime, and commits. Main risks were HTML injection and missing headers. Minimal edits applied: enable `rehype-sanitize`, drop Mermaid CDN, avoid `innerHTML` where not needed, reduce PII in logs, soften Gemini init, and add headers/CSP snippets for both static hosts and Nginx.
## Risk table
| ID | File/Path | Issue | Severity | Why it matters | Fix |
|---|---|---|---|---|---|
| S1 | `astro.config.ts` | MD/MDX not sanitized | High | XSS via content/slots | Add `rehype-sanitize` |
| S2 | `src/pages/[lang]/topdesk.astro` | CDN Mermaid + `innerHTML` | High | CSP bypass/XSS | Use local `mermaid`, keep injection controlled |
| S3 | `src/components/widgets/ModernEducation.astro` | Title via `innerHTML` | Med | XSS if title contains HTML | Use `textContent` |
| S4 | `src/pages/[lang]/index.astro` | `set:html` for line breaks | Low | Avoidable HTML injection | Render lines with `<br/>` |
| S5 | `src/pages/api/contact.ts` | PII in logs | Med | Privacy/legal | Remove PII logs |
| S6 | `src/utils/gemini-spam-check.ts` | Throws when key missing | Med | Startup failure | Lazy init, fail open |
| S7 | `public/_headers`, `nginx/nginx.conf` | Missing headers/CSP | High | XSS/MIME sniffing | Add strict headers and CSP |
| S8 | `src/middleware.ts` | 301 for lang redirect | Low | Sticky cache | Use 302 |
| S9 | `src/pages/api/uptime.ts` | Verbose error detail | Low | Info leakage | Return generic details |
## Detailed findings with references
- S1: `astro.config.ts` now includes `rehype-sanitize` alongside existing plugins.
- S2: `src/pages/[lang]/topdesk.astro` imports `mermaid` locally and renders SVG without remote CDN.
- S3/S4: Components/pages adjusted to avoid `innerHTML`/`set:html` for simple cases.
- S5/S6/S9: API/util logs are minimal; no PII or stack traces in prod responses.
- S7: Headers added for both Nginx and static hosting.
- S8: Middleware redirect switched to 302.
## Applied patches (concise)
1) Enable sanitize in Astro markdown
```diff
--- a/astro.config.ts
+++ b/astro.config.ts
@@
markdown: {
remarkPlugins: [readingTimeRemarkPlugin],
- rehypePlugins: [responsiveTablesRehypePlugin, lazyImagesRehypePlugin],
+ rehypePlugins: [rehypeSanitize, responsiveTablesRehypePlugin, lazyImagesRehypePlugin],
},
```
2) Remove CDN and render Mermaid locally
```diff
--- a/src/pages/[lang]/topdesk.astro
+++ b/src/pages/[lang]/topdesk.astro
@@
- const mermaid = await import('https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs');
+ const mermaidModule = await import('mermaid');
+ const mermaid = mermaidModule.default || mermaidModule;
@@
- const { svg } = await mermaid.default.render(id, mermaidCode);
+ const { svg } = await mermaid.render(id, mermaidCode);
```
3) Avoid `innerHTML` for titles; use `textContent`
```diff
--- a/src/components/widgets/ModernEducation.astro
+++ b/src/components/widgets/ModernEducation.astro
@@
- const tempDiv = document.createElement('div');
- tempDiv.innerHTML = title;
- const cleanTitle = tempDiv.textContent || tempDiv.innerText || title;
+ const cleanTitle = String(title);
@@
- document.getElementById('educationModalTitle').innerHTML = title;
+ const titleEl = document.getElementById('educationModalTitle');
+ if (titleEl) titleEl.textContent = cleanTitle;
```
4) Replace trivial `set:html`
```diff
--- a/src/pages/[lang]/index.astro
+++ b/src/pages/[lang]/index.astro
@@
- <div class="text-lg pt-4 method-highlights" set:html={explosive.approach.highlights.replace(/\n/g, '<br/>')}>
- </div>
+ <div class="text-lg pt-4 method-highlights">
+ {explosive.approach.highlights.split('\n').map((line) => (
+ <>
+ {line}
+ <br />
+ </>
+ ))}
+ </div>
```
5) Reduce PII logging; lazy Gemini init
```diff
--- a/src/pages/api/contact.ts
+++ b/src/pages/api/contact.ts
@@
- console.log('Client IP:', ipAddress);
+ // avoid logging IP in production
@@
- console.log('Rate limit exceeded');
+ console.warn('Rate limit exceeded for contact endpoint');
```
```diff
--- a/src/utils/gemini-spam-check.ts
+++ b/src/utils/gemini-spam-check.ts
@@
-const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
-if (!GEMINI_API_KEY) { throw new Error('...') }
-const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
+const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
+const genAI = GEMINI_API_KEY ? new GoogleGenerativeAI(GEMINI_API_KEY) : null;
```
6) Headers/CSP
- Static host `_headers` additions in `public/_headers` (applied).
- Nginx secure headers added in `nginx/nginx.conf` (applied).
Starter CSP (static host file already contains a commented safer baseline):
```text
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.pixabay.com https://raw.githubusercontent.com; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
```
If you keep inline scripts, use Astro nonces or move to external files to avoid `'unsafe-inline'` for scripts entirely.
## Follow-up tasks
- Add CI with: `npm ci`, `npm run check`, `npm run build`, `npm audit --omit=dev`.
- Consider switching `output: 'server'` to prerender where feasible for brochure pages; keep APIs SSR.
- Review all remaining `set:html` occurrences and ensure content sources are trusted/sanitized.
- Document `.env` variables; `.env` example added to README.

View File

@@ -6,6 +6,7 @@ import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import tailwind from '@astrojs/tailwind';
import mdx from '@astrojs/mdx';
import rehypeSanitize from 'rehype-sanitize';
import react from '@astrojs/react';
import partytown from '@astrojs/partytown';
import icon from 'astro-icon';
@@ -96,7 +97,7 @@ export default defineConfig({
markdown: {
remarkPlugins: [readingTimeRemarkPlugin],
rehypePlugins: [responsiveTablesRehypePlugin, lazyImagesRehypePlugin],
rehypePlugins: [rehypeSanitize, responsiveTablesRehypePlugin, lazyImagesRehypePlugin],
},
vite: {

View File

@@ -39,6 +39,15 @@ http {
root /usr/share/nginx/html;
index index.html index.htm;
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=(), interest-cohort=()" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'nonce-astro'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.pixabay.com https://raw.githubusercontent.com; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
# Error pages
error_page 404 /404.html;
location = /404.html {

3175
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,11 +22,11 @@
"fix:prettier": "prettier -w ."
},
"dependencies": {
"@astrojs/node": "^9.2.2",
"@astrojs/node": "^9.5.0",
"@astrojs/prefetch": "^0.4.1",
"@astrojs/react": "^4.2.0",
"@astrojs/react": "^4.4.0",
"@astrojs/rss": "^4.0.11",
"@astrojs/sitemap": "^3.2.1",
"@astrojs/sitemap": "^3.6.0",
"@astrolib/analytics": "^0.6.1",
"@astrolib/seo": "^1.0.0-beta.8",
"@fontsource-variable/inter": "^5.1.1",
@@ -34,7 +34,7 @@
"@tippyjs/react": "^4.2.6",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"astro": "^5.2.3",
"astro": "^5.14.6",
"astro-embed": "^0.9.0",
"astro-icon": "^1.1.5",
"compression": "^1.7.4",
@@ -45,9 +45,10 @@
"jsonwebtoken": "^9.0.2",
"limax": "4.1.0",
"lodash.merge": "^4.6.2",
"luxon": "^3.6.1",
"luxon": "^3.7.2",
"mermaid": "^11.12.0",
"node-fetch": "^3.3.2",
"nodemailer": "^6.10.0",
"nodemailer": "^7.0.9",
"rate-limiter-flexible": "^5.0.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -56,36 +57,44 @@
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^4.0.8",
"@astrojs/mdx": "^4.3.7",
"@astrojs/partytown": "^2.1.3",
"@astrojs/tailwind": "^6.0.0",
"@eslint/js": "^9.18.0",
"@eslint/js": "^9.38.0",
"@iconify-json/circle-flags": "^1.2.6",
"@iconify-json/flat-color-icons": "^1.2.1",
"@iconify-json/tabler": "^1.2.14",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/typography": "^0.5.19",
"@types/eslint__js": "^8.42.3",
"@types/js-yaml": "^4.0.9",
"@types/jsonwebtoken": "^9.0.9",
"@types/lodash.merge": "^4.6.9",
"@types/mdx": "^2.0.13",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
"@typescript-eslint/eslint-plugin": "8.46.1",
"@typescript-eslint/parser": "8.46.1",
"astro-compress": "2.3.6",
"astro-eslint-parser": "^1.1.0",
"eslint": "^9.18.0",
"eslint": "^9.38.0",
"eslint-plugin-astro": "^1.3.1",
"globals": "^15.14.0",
"globals": "^16.4.0",
"js-yaml": "^4.1.0",
"mdast-util-to-string": "^4.0.0",
"prettier": "^3.4.2",
"prettier": "^3.6.2",
"rehype-sanitize": "^6.0.0",
"prettier-plugin-astro": "^0.14.1",
"reading-time": "^1.5.0",
"sharp": "0.33.5",
"tailwind-merge": "^2.6.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "8.46.1",
"unist-util-visit": "^5.0.0"
},
"overrides": {
"devalue": "^5.3.2",
"ws": "^8.17.1",
"tar-fs": "^2.1.5",
"axios": "^1.12.0",
"typescript-eslint": "8.46.1"
}
}

View File

@@ -1,2 +1,12 @@
/_astro/*
Cache-Control: public, max-age=31536000, immutable
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), camera=(), microphone=(), interest-cohort=()
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# Content-Security-Policy starter (enable after auditing inline scripts)
# Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.pixabay.com https://raw.githubusercontent.com; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'

View File

@@ -11,6 +11,22 @@ const PORT = process.env.PORT || 3000;
// Enable gzip/brotli compression
app.use(compression());
// Set minimal secure headers for SSR responses
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
// Gate SSR CSP to avoid breaking inline scripts unless explicitly enabled
if (process.env.ENABLE_SSR_CSP === '1') {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.pixabay.com https://raw.githubusercontent.com; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
);
}
next();
});
// Serve static assets from the dist/client directory
app.use(express.static(path.join(__dirname, 'dist/client')));

View File

@@ -0,0 +1,72 @@
---
// Create this file as: src/assets/images/topdesk-api-docs.png placeholder
// You can replace this with an actual image file
// For now, you can create a simple placeholder or use an existing image
// The image import in the main file should be:
// import TOPDeskImage from '~/assets/images/topdesk-api-docs.png';
// If you want to create the image programmatically or use a placeholder,
// you can create an SVG file instead:
---
<svg width="800" height="600" viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="50%" style="stop-color:#764ba2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#f093fb;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background -->
<rect width="800" height="600" fill="url(#grad1)"/>
<!-- Grid pattern -->
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
</pattern>
</defs>
<rect width="800" height="600" fill="url(#grid)"/>
<!-- Main content area -->
<rect x="100" y="100" width="600" height="400" rx="20" fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.2)" stroke-width="2"/>
<!-- Header -->
<rect x="120" y="120" width="560" height="80" rx="10" fill="rgba(255,255,255,0.15)"/>
<text x="400" y="170" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-size="24" font-weight="bold">API Documentation</text>
<!-- Content blocks -->
<rect x="140" y="220" width="160" height="100" rx="8" fill="rgba(255,255,255,0.1)"/>
<rect x="320" y="220" width="160" height="100" rx="8" fill="rgba(255,255,255,0.1)"/>
<rect x="500" y="220" width="160" height="100" rx="8" fill="rgba(255,255,255,0.1)"/>
<!-- Content block icons -->
<circle cx="220" cy="250" r="15" fill="rgba(255,255,255,0.3)"/>
<circle cx="400" cy="250" r="15" fill="rgba(255,255,255,0.3)"/>
<circle cx="580" cy="250" r="15" fill="rgba(255,255,255,0.3)"/>
<!-- Content lines -->
<rect x="150" y="280" width="140" height="4" rx="2" fill="rgba(255,255,255,0.4)"/>
<rect x="150" y="290" width="100" height="4" rx="2" fill="rgba(255,255,255,0.3)"/>
<rect x="150" y="300" width="120" height="4" rx="2" fill="rgba(255,255,255,0.3)"/>
<rect x="330" y="280" width="140" height="4" rx="2" fill="rgba(255,255,255,0.4)"/>
<rect x="330" y="290" width="100" height="4" rx="2" fill="rgba(255,255,255,0.3)"/>
<rect x="330" y="300" width="120" height="4" rx="2" fill="rgba(255,255,255,0.3)"/>
<rect x="510" y="280" width="140" height="4" rx="2" fill="rgba(255,255,255,0.4)"/>
<rect x="510" y="290" width="100" height="4" rx="2" fill="rgba(255,255,255,0.3)"/>
<rect x="510" y="300" width="120" height="4" rx="2" fill="rgba(255,255,255,0.3)"/>
<!-- Footer -->
<rect x="120" y="420" width="560" height="60" rx="10" fill="rgba(255,255,255,0.1)"/>
<text x="400" y="455" text-anchor="middle" fill="rgba(255,255,255,0.8)" font-family="Arial, sans-serif" font-size="14">Technical Integration Guide</text>
<!-- Floating elements -->
<circle cx="150" cy="150" r="8" fill="rgba(255,255,255,0.2)"/>
<circle cx="650" cy="180" r="12" fill="rgba(255,255,255,0.15)"/>
<circle cx="180" cy="450" r="6" fill="rgba(255,255,255,0.25)"/>
<circle cx="620" cy="420" r="10" fill="rgba(255,255,255,0.2)"/>
</svg>

View File

@@ -2,6 +2,37 @@
@tailwind components;
@tailwind utilities;
/* Global focus ring for accessibility */
:root {
--ring-color: 0 0 0;
}
:focus-visible {
outline: 2px solid rgb(59 130 246 / 0.8);
outline-offset: 2px;
}
/* Readable measure for long-form content */
.prose-measure {
max-width: 75ch;
}
/* Typography clamps */
h1 { font-size: clamp(2rem, 5vw, 3.5rem); }
h2 { font-size: clamp(1.5rem, 3.5vw, 2.25rem); }
h3 { font-size: clamp(1.25rem, 3vw, 1.75rem); }
p, li { font-size: clamp(1rem, 2.5vw, 1.125rem); line-height: 1.7; }
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
@layer base {
p,
li,

View File

@@ -25,7 +25,7 @@ declare global {
// Set cookie with a long expiration (1 year)
const expirationDate = new Date();
expirationDate.setFullYear(expirationDate.getFullYear() + 1);
document.cookie = `preferredLanguage=${langCode}; expires=${expirationDate.toUTCString()}; path=/; SameSite=Lax`;
document.cookie = `preferredLanguage=${langCode}; expires=${expirationDate.toUTCString()}; path=/; SameSite=Lax; Secure`;
}
// Function to get language from cookie

View File

@@ -13,11 +13,13 @@ const {
...rest
} = Astro.props;
const baseBtn = 'inline-flex items-center justify-center rounded-full font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-60 disabled:cursor-not-allowed';
const sizes = 'px-6 py-3 text-base';
const variants = {
primary: 'btn-primary',
secondary: 'btn-secondary',
tertiary: 'btn btn-tertiary',
link: 'cursor-pointer hover:text-primary',
primary: `${baseBtn} ${sizes} bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline-blue-600`,
secondary: `${baseBtn} ${sizes} bg-white/90 dark:bg-slate-800 text-slate-900 dark:text-slate-100 border border-slate-200 dark:border-slate-700 hover:bg-white dark:hover:bg-slate-700 focus-visible:outline-slate-600`,
tertiary: `${baseBtn} ${sizes} bg-transparent text-blue-600 hover:bg-blue-50 dark:hover:bg-slate-800 focus-visible:outline-blue-600`,
link: 'cursor-pointer underline underline-offset-4 hover:no-underline text-blue-600 dark:text-blue-400',
};
const ariaLabel = !text || (typeof text === 'string' && text.trim() === '') ? Astro.props['aria-label'] : undefined;

View File

@@ -88,20 +88,16 @@ const {
<!-- Business Information (Dutch Law Requirements) -->
<div class="text-sm text-white-500 space-y-1">
<a id="obfuscated-email" href="#" class="hover:underline">info [at] 365devnet [dot] eu</a>
<script is:inline>
// Obfuscated email: info@365devnet@+@eu
// @+@ will be replaced with a dot (.)
<script is:inline nonce="astro">
const user = "info";
const domain = "365devnet";
const tld = "eu";
const email = `${user}@${domain}@+@${tld}`;
const subject = "Message&nbsp;from&nbsp;website";
const realEmail = email.replace("@+@", "."); // Converts to info@365devnet.eu
const mailto = `mailto:${realEmail}?subject=${subject}`;
const realEmail = `${user}@${domain}.${tld}`;
const mailto = `mailto:${realEmail}?subject=Message%20from%20website`;
const link = document.getElementById("obfuscated-email");
if (link) {
link.setAttribute("href", mailto);
link.innerHTML = realEmail;
link.textContent = realEmail;
}
</script>
</div>

View File

@@ -240,9 +240,8 @@ const getEducationStyle = (title: string) => {
const title = element.dataset.educationTitle;
const description = element.dataset.educationDescription;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = title;
const cleanTitle = tempDiv.textContent || tempDiv.innerText || title;
// Sanitize potential HTML in title by using textContent fallback only
const cleanTitle = String(title);
const titleLower = cleanTitle.toLowerCase();
let statusText = '';
@@ -259,7 +258,8 @@ const getEducationStyle = (title: string) => {
statusClass = 'text-purple-600 dark:text-purple-400';
}
document.getElementById('educationModalTitle').innerHTML = title;
const titleEl = document.getElementById('educationModalTitle');
if (titleEl) titleEl.textContent = cleanTitle;
document.getElementById('educationModalDescription').textContent = description || 'No additional information available.';
document.getElementById('educationModalStatus').textContent = statusText;
document.getElementById('educationModalStatus').className = `text-sm font-medium ${statusClass}`;

12
src/env.d.ts vendored
View File

@@ -5,7 +5,17 @@
/// <reference types="../vendor/integration/types.d.ts" />
interface ImportMetaEnv {
readonly GITLAB_TOKEN: string;
readonly GITEA_TOKEN?: string;
readonly UPTIME_KUMA_URL?: string;
readonly SITE?: string;
readonly SMTP_HOST?: string;
readonly SMTP_PORT?: string;
readonly SMTP_USER?: string;
readonly SMTP_PASS?: string;
readonly ADMIN_EMAIL?: string;
readonly WEBSITE_NAME?: string;
readonly GEMINI_API_KEY?: string;
readonly MANUAL_REVIEW_SECRET?: string;
}
interface ImportMeta {

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,8 @@ import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
// Only redirect if we're at the root path
if (context.url.pathname === '/') {
return context.redirect('/en', 301);
// Use 302 to avoid permanent redirect caching during content negotiation
return context.redirect('/en', 302);
}
return next();

View File

@@ -425,10 +425,10 @@ const metadata = {
{mainT.hero.subtitle}
</div>
<div class="cta-container">
<a href="#about" class="cta-primary-enhanced">
<a href="#about" class="inline-flex items-center justify-center rounded-full font-semibold transition-colors px-6 py-3 text-base bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
{t.about.actions.learnMore}
</a>
<a href="#resume" class="cta-secondary-enhanced">
<a href="#resume" class="inline-flex items-center justify-center rounded-full font-semibold transition-colors px-6 py-3 text-base bg-white/90 dark:bg-slate-800 text-slate-900 dark:text-slate-100 border border-slate-200 dark:border-slate-700 hover:bg-white dark:hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-slate-600">
{t.about.actions.viewExperience}
</a>
</div>

View File

@@ -491,12 +491,12 @@ const metadata = {
text: explosive.actions.learnMore,
href: '#services',
class: 'cta-explosive'
variant: 'primary'
},
{
text: explosive.actions.contactMe,
text: explosive.actions.contactMe,
href: '#contact',
class: 'cta-secondary-explosive'
variant: 'secondary'
},
]}
image={{
@@ -598,7 +598,13 @@ const metadata = {
))}
</div>
<div class="text-lg pt-4 method-highlights" set:html={explosive.approach.highlights.replace(/\n/g, '<br/>')}>
<div class="text-lg pt-4 method-highlights">
{explosive.approach.highlights.split('\n').map((line) => (
<>
{line}
<br />
</>
))}
</div>
</div>
</Fragment>
@@ -612,7 +618,7 @@ const metadata = {
text: explosive.cta.button,
href: '#contact',
icon: 'tabler:rocket',
class: 'cta-explosive'
variant: 'primary'
}}
classes={{
container: 'glass-vibrant'

View File

@@ -0,0 +1,948 @@
---
export const prerender = true;
import Layout from '~/layouts/PageLayout.astro';
import StructuredData from '~/components/common/StructuredData.astro';
import Hero from '~/components/widgets/Hero.astro';
import Content from '~/components/widgets/Content.astro';
import TOPDeskImage from '~/assets/images/topdesk-api-docs.png';
import { getTranslation } from '~/i18n/translations';
import { getTOPDeskDocsTranslation, supportedLanguages } from '~/i18n/translations.topdesk-docs';
import { SITE } from 'astrowind:config';
export async function getStaticPaths() {
return supportedLanguages.map((lang) => ({
params: { lang },
}));
}
const { lang } = Astro.params;
if (!supportedLanguages.includes(lang)) {
return Astro.redirect('/en/topdesk-api-docs');
}
// Get main translations for shared components (navigation, footer, etc.)
const mainT = getTranslation(lang);
// Get TOPDesk docs specific translations
const t = getTOPDeskDocsTranslation(lang);
const metadata = {
title: t.metadata.title,
description: t.metadata.description
};
---
<style>
/* Modern animated background with floating elements - matching aboutme.astro style */
.animated-hero-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
background-size: 300% 300%;
animation: gradientShift 15s ease infinite;
position: relative;
overflow: hidden;
}
.animated-hero-bg::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
background-size: 50px 50px;
animation: backgroundMove 20s linear infinite;
}
.floating-elements {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: hidden;
}
.floating-shape {
position: absolute;
opacity: 0.15;
animation: float 20s infinite linear;
}
.shape-1 {
top: 20%;
left: 10%;
width: 80px;
height: 80px;
background: linear-gradient(135deg, rgba(255,255,255,0.2), rgba(255,255,255,0.05));
border-radius: 50%;
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.1);
animation-delay: 0s;
}
.shape-2 {
top: 60%;
right: 15%;
width: 120px;
height: 120px;
background: linear-gradient(135deg, rgba(255,255,255,0.15), rgba(255,255,255,0.03));
border-radius: 30px;
backdrop-filter: blur(15px);
border: 1px solid rgba(255,255,255,0.1);
animation-delay: -7s;
}
.shape-3 {
bottom: 30%;
left: 20%;
width: 60px;
height: 60px;
background: linear-gradient(135deg, rgba(255,255,255,0.25), rgba(255,255,255,0.08));
border-radius: 50%;
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.15);
animation-delay: -14s;
}
.shape-4 {
top: 40%;
right: 35%;
width: 40px;
height: 40px;
background: linear-gradient(135deg, rgba(255,255,255,0.2), rgba(255,255,255,0.05));
border-radius: 8px;
backdrop-filter: blur(12px);
border: 1px solid rgba(255,255,255,0.1);
animation-delay: -10s;
}
@keyframes gradientShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes backgroundMove {
0% { transform: translateX(0) translateY(0); }
100% { transform: translateX(-50px) translateY(-50px); }
}
@keyframes float {
0%, 100% {
transform: translateY(0px) rotate(0deg) scale(1);
opacity: 0.15;
}
25% {
transform: translateY(-30px) rotate(90deg) scale(1.1);
opacity: 0.25;
}
50% {
transform: translateY(-15px) rotate(180deg) scale(1.05);
opacity: 0.2;
}
75% {
transform: translateY(-25px) rotate(270deg) scale(1.15);
opacity: 0.3;
}
}
/* Enhanced glassmorphism effects for all sections */
:global(.glass-enhanced) {
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(20px) !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(255, 255, 255, 0.05) inset !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
:global(.glass-enhanced:hover) {
transform: translateY(-8px) !important;
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.15),
0 0 0 1px rgba(255, 255, 255, 0.1) inset !important;
background: rgba(255, 255, 255, 0.98) !important;
}
/* Dark mode enhancements */
:global(.dark .glass-enhanced) {
background: rgba(30, 30, 50, 0.95) !important;
border-color: rgba(255, 255, 255, 0.1) !important;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.03) inset !important;
}
:global(.dark .glass-enhanced:hover) {
background: rgba(35, 35, 60, 0.98) !important;
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05) inset !important;
}
/* Hero title styling */
.hero-title-enhanced {
font-size: clamp(2.5rem, 6vw, 4.5rem);
font-weight: 900;
background: linear-gradient(135deg,
#667eea 0%,
#764ba2 25%,
#f093fb 50%,
#f5576c 75%,
#4facfe 100%);
background-size: 300% 300%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: gradientShift 8s ease-in-out infinite;
letter-spacing: -0.02em;
line-height: 1.1;
margin-bottom: 1.5rem;
text-shadow: 0 0 30px rgba(102, 126, 234, 0.3);
}
/* Full-width subtitle */
.hero-subtitle-enhanced {
font-size: 1.2rem;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 1.5rem 2rem;
color: #1a1a1a;
width: 100%;
max-width: none;
margin: 0 0 2rem 0;
line-height: 1.6;
font-weight: 500;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
text-align: center;
}
:global(.dark) .hero-subtitle-enhanced {
background: rgba(30, 30, 50, 0.9);
color: #e2e8f0;
border-color: rgba(255, 255, 255, 0.1);
}
/* CTA buttons */
.cta-container {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 1.5rem;
}
.cta-primary-enhanced {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
padding: 0.875rem 2rem;
border-radius: 50px;
text-decoration: none;
font-weight: 600;
font-size: 1rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
border: 2px solid transparent;
position: relative;
overflow: hidden;
}
.cta-primary-enhanced::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.cta-primary-enhanced:hover::before {
left: 100%;
}
.cta-primary-enhanced:hover {
transform: translateY(-5px) scale(1.05);
box-shadow: 0 25px 50px rgba(102, 126, 234, 0.5);
}
/* Documentation content styling - UPDATED FOR FULL WIDTH */
.docs-content {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.7;
color: #333;
width: 100%;
max-width: 75ch;
margin: 0 auto;
padding: 0 1rem;
text-align: left;
}
/* Headings */
.docs-content h2,
.docs-content h3,
.docs-content h4 {
text-align: left;
margin-left: 0;
margin-right: 0;
width: 100%;
}
.docs-content h2 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
margin-top: 40px;
margin-bottom: 20px;
}
.docs-content h3 {
color: #34495e;
border-left: 4px solid #3498db;
padding-left: 15px;
margin-top: 30px;
margin-bottom: 15px;
text-align: left; /* Left align h3 for better readability */
}
.docs-content h4 {
color: #2c3e50;
margin-top: 25px;
margin-bottom: 10px;
text-align: left; /* Left align h4 for better readability */
}
/* Full width content containers - UPDATED */
.content-wrapper {
width: 100%;
max-width: none !important;
margin: 0;
padding: 0;
}
.full-width-content {
width: 100%;
max-width: none !important;
margin: 0 auto;
padding: 0 2rem;
}
.docs-content .mermaid {
text-align: center;
margin: 30px auto;
background: #fafafa;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
width: 100%;
max-width: 100%;
overflow-x: auto;
}
:global(.dark) .docs-content .mermaid {
background: rgba(30, 30, 50, 0.5);
border-color: rgba(255, 255, 255, 0.1);
}
.docs-content pre {
background: #f8f8f8;
border: 1px solid #ddd;
border-radius: 6px;
padding: 15px;
overflow-x: auto;
font-size: 0.9em;
width: 100%;
}
:global(.dark) .docs-content pre {
background: rgba(30, 30, 50, 0.8);
border-color: rgba(255, 255, 255, 0.1);
color: #e2e8f0;
}
.docs-content code {
background: #f1f1f1;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.9em;
}
:global(.dark) .docs-content code {
background: rgba(255, 255, 255, 0.1);
color: #e2e8f0;
}
.docs-content table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
:global(.dark) .docs-content table {
background: rgba(30, 30, 50, 0.8);
}
.docs-content th,
.docs-content td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
:global(.dark) .docs-content th,
:global(.dark) .docs-content td {
border-color: rgba(255, 255, 255, 0.1);
}
.docs-content th {
background: #f5f5f5;
font-weight: 600;
color: #333;
}
:global(.dark) .docs-content th {
background: rgba(255, 255, 255, 0.05);
color: #e2e8f0;
}
.alert {
padding: 15px;
margin: 20px 0;
border-radius: 6px;
border-left: 4px solid;
width: 100%;
}
.alert-warning {
background: #fff3cd;
border-left-color: #ffc107;
color: #856404;
}
:global(.dark) .alert-warning {
background: rgba(255, 193, 7, 0.1);
color: #ffc107;
}
.endpoint {
background: #e8f4f8;
border: 1px solid #b8dce8;
border-radius: 6px;
padding: 15px;
margin: 15px 0;
width: 100%;
}
:global(.dark) .endpoint {
background: rgba(30, 30, 50, 0.8);
border-color: rgba(255, 255, 255, 0.1);
}
.endpoint-method {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-weight: bold;
font-size: 0.8em;
margin-right: 10px;
}
.method-get { background: #28a745; color: white; }
.method-post { background: #007bff; color: white; }
.method-patch { background: #ffc107; color: #212529; }
/* Section spacing */
.content-section {
margin: 4rem 0;
position: relative;
width: 100%;
}
.content-section:first-of-type {
margin-top: 2rem;
}
/* Override any container max-width restrictions */
:global(.container) {
max-width: none !important;
width: 100% !important;
}
/* Custom content class overrides */
:global(.content-max-none) {
max-width: none !important;
width: 100% !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.hero-subtitle-enhanced {
font-size: 1.1rem;
padding: 1.25rem 1.5rem;
margin: 0 0 1.5rem 0;
}
.cta-container {
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 0 1rem;
}
.cta-primary-enhanced {
width: 100%;
max-width: 260px;
text-align: center;
padding: 0.875rem 1.5rem;
}
.content-section {
margin: 3rem 0;
}
.docs-content,
.full-width-content {
padding: 0 1rem;
}
}
@media (min-width: 769px) {
.docs-content,
.full-width-content {
padding: 0 3rem;
}
}
@media (min-width: 1200px) {
.docs-content,
.full-width-content {
padding: 0 4rem;
}
}
</style>
<Layout metadata={metadata}>
<Fragment slot="announcement"></Fragment>
<!-- Structured Data for SEO -->
<StructuredData
slot="structured-data"
data={{
'@context': 'https://schema.org',
'@type': 'TechnicalArticle',
name: t.metadata.title,
description: t.metadata.description,
author: {
'@type': 'Person',
name: 'Richard Bergsma'
},
datePublished: '2025-08-01',
dateModified: '2025-08-01',
inLanguage: lang
}}
/>
<!-- Enhanced Hero Section -->
<Hero id="hero" isDark={false}>
<Fragment slot="bg">
<div class="animated-hero-bg">
<div class="floating-elements">
<div class="floating-shape shape-1"></div>
<div class="floating-shape shape-2"></div>
<div class="floating-shape shape-3"></div>
<div class="floating-shape shape-4"></div>
</div>
</div>
</Fragment>
<Fragment slot="title">
<span class="hero-title-enhanced">{t.hero.title}</span>
</Fragment>
<Fragment slot="subtitle">
<div class="hero-subtitle-container">
<div class="hero-subtitle-enhanced">
{t.hero.subtitle}
</div>
<div class="cta-container">
<a href="#overview" class="inline-flex items-center justify-center rounded-full font-semibold transition-colors px-6 py-3 text-base bg-blue-600 text-white hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
{t.hero.cta.primary}
</a>
</div>
</div>
</Fragment>
</Hero>
<!-- Documentation Content -->
<div id="overview" class="content-section">
<Content
id="overview"
columns={1}
items={[]}
classes={{
container: 'glass-enhanced content-max-none',
content: 'content-max-none'
}}
>
<Fragment slot="content">
<div class="docs-content">
<h2>{t.sections.overview.title}</h2>
<div class="full-width-content">
<h3>{t.sections.overview.whatIs.title}</h3>
<p>{t.sections.overview.whatIs.content}</p>
<h3>{t.sections.overview.apiOverview.title}</h3>
<ul>
<li><strong>{t.sections.overview.apiOverview.host}:</strong> <code>your-api-host.com</code></li>
<li><strong>{t.sections.overview.apiOverview.basePath}:</strong> <code>/api/v1</code></li>
<li><strong>{t.sections.overview.apiOverview.protocol}:</strong> {t.sections.overview.apiOverview.protocolValue}</li>
<li><strong>{t.sections.overview.apiOverview.authentication}:</strong> {t.sections.overview.apiOverview.authValue}</li>
</ul>
<h3>{t.sections.overview.capabilities.title}</h3>
<ul>
{t.sections.overview.capabilities.items.map((item) => (
<li><strong>{item.title}</strong> {item.description}</li>
))}
</ul>
</div>
</div>
</Fragment>
<Fragment slot="bg">
<div class="absolute inset-0 bg-gradient-to-br from-blue-50/50 to-purple-50/50 dark:from-gray-900/50 dark:to-gray-800/50"></div>
</Fragment>
</Content>
</div>
<!-- Concepts Section -->
<div id="concepts" class="content-section">
<Content
id="concepts"
columns={1}
items={[]}
classes={{
container: 'glass-enhanced content-max-none',
content: 'content-max-none'
}}
>
<Fragment slot="content">
<div class="docs-content">
<h2>{t.sections.concepts.title}</h2>
<div class="full-width-content">
<h3>{t.sections.concepts.entities.title}</h3>
<div class="mermaid" data-mermaid={`flowchart TD
subgraph Users ["👥 Users"]
PERSON[👤 Person<br/>End User]
OPERATOR[👨‍💼 Operator<br/>IT Support Staff]
end
subgraph Teams ["🏢 Organization"]
GROUP[👥 Operator Group<br/>IT Team]
DEPT[🏢 Department]
end
subgraph Tickets ["🎫 Service Requests"]
INCIDENT[🎫 Incident<br/>Support Ticket]
CHANGE[🔄 Change Request<br/>Planned Modification]
end
subgraph Resources ["💼 IT Resources"]
KB[📚 Knowledge Base<br/>Solutions/Procedures]
ASSET[💻 Asset<br/>IT Equipment]
CATEGORY[🏷️ Category<br/>Classification]
end
PERSON -->|reports| INCIDENT
INCIDENT -->|assigned to| OPERATOR
OPERATOR -->|belongs to| GROUP
GROUP -->|manages| CHANGE
OPERATOR -->|uses| KB
INCIDENT -->|classified by| CATEGORY
ASSET -->|linked to| INCIDENT
KB -->|provides solutions for| INCIDENT
classDef user fill:#e3f2fd,stroke:#1976d2,color:#000
classDef team fill:#f3e5f5,stroke:#7b1fa2,color:#000
classDef ticket fill:#fff3e0,stroke:#f57c00,color:#000
classDef resource fill:#e8f5e8,stroke:#388e3c,color:#000
class PERSON,OPERATOR user
class GROUP,DEPT team
class INCIDENT,CHANGE ticket
class KB,ASSET,CATEGORY resource`}>
<!-- Mermaid diagram will be rendered here -->
</div>
<h3>{t.sections.concepts.terminology.title}</h3>
<div class="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t.sections.concepts.terminology.headers.term}</th>
<th>{t.sections.concepts.terminology.headers.description}</th>
<th>{t.sections.concepts.terminology.headers.example}</th>
</tr>
</thead>
<tbody>
{t.sections.concepts.terminology.items.map((item) => (
<tr>
<td><strong>{item.term}</strong></td>
<td>{item.description}</td>
<td>{item.example}</td>
</tr>
))}
</tbody>
</table>
</div>
<h3>{t.sections.concepts.lifecycle.title}</h3>
<div class="mermaid" data-mermaid={`flowchart LR
START[🚀 USER REPORTS<br/>PROBLEM] -->|Creates| NEW[🆕 NEW TICKET<br/>Status: New]
NEW -->|Auto-assigned| ASSIGN[👥 ASSIGNED TO TEAM<br/>Based on category]
ASSIGN -->|Operator picks up| PROGRESS[⚙️ IN PROGRESS<br/>Investigation starts]
PROGRESS -->|Solution found| RESOLVE[✅ RESOLVED<br/>Awaiting confirmation]
RESOLVE -->|User confirms| CLOSE[🔒 CLOSED<br/>Ticket complete]
PROGRESS -->|Need more info| ONHOLD[⏸️ ON HOLD<br/>Waiting for user]
ONHOLD -->|Info received| PROGRESS
classDef start fill:#e8f5e8,stroke:#4caf50,color:#000
classDef active fill:#fff3e0,stroke:#ff9800,color:#000
classDef resolved fill:#e3f2fd,stroke:#2196f3,color:#000
classDef closed fill:#f3e5f5,stroke:#9c27b0,color:#000
classDef hold fill:#fce4ec,stroke:#e91e63,color:#000
class START start
class NEW,ASSIGN,PROGRESS active
class RESOLVE resolved
class CLOSE closed
class ONHOLD hold`}>
<!-- Mermaid diagram will be rendered here -->
</div>
</div>
</div>
</Fragment>
<Fragment slot="bg">
<div class="absolute inset-0 bg-gradient-to-br from-purple-50/50 to-blue-50/50 dark:from-gray-800/50 dark:to-gray-900/50"></div>
</Fragment>
</Content>
</div>
<!-- Getting Started Section -->
<div id="getting-started" class="content-section">
<Content
id="getting-started"
columns={1}
items={[]}
classes={{
container: 'glass-enhanced content-max-none',
content: 'content-max-none'
}}
>
<Fragment slot="content">
<div class="docs-content">
<h2>{t.sections.gettingStarted.title}</h2>
<div class="full-width-content">
<h3>{t.sections.gettingStarted.authentication.title}</h3>
<p>{t.sections.gettingStarted.authentication.description}</p>
<pre><code>{t.sections.gettingStarted.authentication.headers}</code></pre>
<h3>{t.sections.gettingStarted.firstCall.title}</h3>
<p>{t.sections.gettingStarted.firstCall.description}</p>
<pre><code>{t.sections.gettingStarted.firstCall.code}</code></pre>
<h3>{t.sections.gettingStarted.dynamicDropdowns.title}</h3>
<div class="alert alert-warning">
<strong>{t.sections.gettingStarted.dynamicDropdowns.warning}</strong>
</div>
<p>{t.sections.gettingStarted.dynamicDropdowns.explanation}</p>
<h4>{t.sections.gettingStarted.dynamicDropdowns.integration.title}</h4>
<pre><code>{t.sections.gettingStarted.dynamicDropdowns.integration.wrongCode}</code></pre>
<pre><code>{t.sections.gettingStarted.dynamicDropdowns.integration.correctCode}</code></pre>
<h4>{t.sections.gettingStarted.dynamicDropdowns.whyEssential.title}</h4>
<ul>
{t.sections.gettingStarted.dynamicDropdowns.whyEssential.reasons.map((reason) => (
<li><strong>{reason.title}:</strong> {reason.description}</li>
))}
</ul>
</div>
</div>
</Fragment>
<Fragment slot="bg">
<div class="absolute inset-0 bg-gradient-to-br from-green-50/50 to-blue-50/50 dark:from-gray-900/50 dark:to-gray-800/50"></div>
</Fragment>
</Content>
</div>
<!-- API Reference Section -->
<div id="api-reference" class="content-section">
<Content
id="api-reference"
columns={1}
items={[]}
classes={{
container: 'glass-enhanced content-max-none',
content: 'content-max-none'
}}
>
<Fragment slot="content">
<div class="docs-content">
<h2>{t.sections.apiReference.title}</h2>
<div class="full-width-content">
<h3>{t.sections.apiReference.essentialEndpoints.title}</h3>
<h4>{t.sections.apiReference.essentialEndpoints.findUsers.title}</h4>
<div class="endpoint">
<span class="endpoint-method method-get">GET</span>
<code>/api/persons?query=dynamicName=="[Full Name]"</code>
<p><strong>{t.sections.apiReference.essentialEndpoints.findUsers.purpose}:</strong> {t.sections.apiReference.essentialEndpoints.findUsers.purposeText}</p>
<p><strong>{t.sections.apiReference.essentialEndpoints.findUsers.returns}:</strong> {t.sections.apiReference.essentialEndpoints.findUsers.returnsText}</p>
</div>
<h4>{t.sections.apiReference.essentialEndpoints.dropdownOptions.title}</h4>
{t.sections.apiReference.essentialEndpoints.dropdownOptions.endpoints.map((endpoint) => (
<div class="endpoint">
<span class="endpoint-method method-get">GET</span>
<code>{endpoint.path}</code>
<p>{endpoint.description}</p>
</div>
))}
<h4>{t.sections.apiReference.essentialEndpoints.createTicket.title}</h4>
<div class="endpoint">
<span class="endpoint-method method-post">POST</span>
<code>/api/incidents</code>
<p><strong>{t.sections.apiReference.essentialEndpoints.createTicket.purpose}:</strong> {t.sections.apiReference.essentialEndpoints.createTicket.purposeText}</p>
<p><strong>{t.sections.apiReference.essentialEndpoints.createTicket.requires}:</strong> {t.sections.apiReference.essentialEndpoints.createTicket.requiresText}</p>
</div>
<h4>{t.sections.apiReference.essentialEndpoints.searchKnowledgeBase.title}</h4>
<div class="endpoint">
<span class="endpoint-method method-get">GET</span>
<code>/api/knowledgeItems/search?query=[search terms]&lang=en&status=active</code>
<p><strong>{t.sections.apiReference.essentialEndpoints.searchKnowledgeBase.purpose}:</strong> {t.sections.apiReference.essentialEndpoints.searchKnowledgeBase.purposeText}</p>
</div>
</div>
</div>
</Fragment>
<Fragment slot="bg">
<div class="absolute inset-0 bg-gradient-to-br from-orange-50/50 to-red-50/50 dark:from-gray-800/50 dark:to-gray-900/50"></div>
</Fragment>
</Content>
</div>
<!-- Contact Section -->
<div id="contact" class="content-section">
<Content
id="contact"
columns={1}
items={[]}
classes={{
container: 'glass-enhanced content-max-none',
content: 'content-max-none'
}}
>
<Fragment slot="content">
<div class="docs-content">
<div class="full-width-content text-center">
<h2>{t.sections.contact.title}</h2>
<p><strong>{t.sections.contact.team}:</strong> {t.sections.contact.teamName}</p>
<p><strong>{t.sections.contact.email}:</strong> <a href="mailto:support@example.com">support@example.com</a></p>
<p><strong>{t.sections.contact.website}:</strong> <a href="#" target="_blank">www.example.com</a></p>
<p>{t.sections.contact.supportText}</p>
</div>
</div>
</Fragment>
<Fragment slot="bg">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-50/50 to-purple-50/50 dark:from-gray-900/50 dark:to-gray-800/50"></div>
</Fragment>
</Content>
</div>
</Layout>
<script>
// Initialize Mermaid diagrams with proper client-side setup
document.addEventListener('DOMContentLoaded', async () => {
try {
// Import Mermaid dynamically from local dependency (no remote CDN)
const mermaidModule = await import('mermaid');
const mermaid = mermaidModule.default || mermaidModule;
// Initialize Mermaid with configuration
mermaid.initialize({
startOnLoad: false,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'default',
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: 'basis'
},
themeVariables: {
fontFamily: 'Arial, sans-serif'
}
});
// Find all mermaid elements and render them
const mermaidElements = document.querySelectorAll('.mermaid');
for (let i = 0; i < mermaidElements.length; i++) {
const element = mermaidElements[i];
const mermaidCode = element.textContent.trim();
try {
// Create a unique ID for each diagram
const id = `mermaid-${i}`;
// Render the diagram
const { svg } = await mermaid.render(id, mermaidCode);
// Replace the element content with the rendered SVG
element.innerHTML = svg;
} catch (error) {
console.error('Error rendering mermaid diagram:', error);
element.innerHTML = `<div style="color: red; padding: 20px; border: 1px solid red; border-radius: 4px;">
<strong>Diagram Error:</strong> Unable to render diagram. Please check the syntax.
</div>`;
}
}
} catch (error) {
console.error('Error loading Mermaid:', error);
}
});
// Re-render mermaid diagrams when theme changes or page loads
document.addEventListener('astro:page-load', () => {
// Trigger re-initialization if needed
setTimeout(() => {
const event = new Event('DOMContentLoaded');
document.dispatchEvent(event);
}, 100);
});
// Theme change handler
const themeToggle = document.querySelector('[data-aw-toggle-color-scheme]');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
setTimeout(() => {
const event = new Event('DOMContentLoaded');
document.dispatchEvent(event);
}, 200);
});
}
</script>

View File

@@ -13,7 +13,7 @@ export const GET: APIRoute = async ({ request }) => {
headers['Authorization'] = `token ${import.meta.env.GITEA_TOKEN}`;
}
const response = await fetch(url, { headers });
const response = await fetch(url, { headers, redirect: 'follow', cache: 'no-store' });
if (!response.ok) {
throw new Error(`Gitea API responded with status: ${response.status}`);

View File

@@ -5,13 +5,11 @@ import {
checkRateLimit,
sendAdminNotification,
sendUserConfirmation,
sendEmail,
} from '../../utils/email-handler';
import { isSpamWithGemini } from "../../utils/gemini-spam-check";
import jwt from 'jsonwebtoken';
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET;
const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu';
// Enhanced email validation with more comprehensive regex
const isValidEmail = (email: string): boolean => {
@@ -128,18 +126,18 @@ export const GET: APIRoute = async ({ request }) => {
export const POST: APIRoute = async ({ request, clientAddress }) => {
try {
console.log('Contact form submission received');
// Minimal server logs; avoid PII in logs
// Get client IP address for rate limiting
const ipAddress = clientAddress || '0.0.0.0';
console.log('Client IP:', ipAddress);
// Do not log full IP or headers in production
// Check rate limit
const rateLimitCheck = await checkRateLimit(ipAddress);
console.log('Rate limit check:', rateLimitCheck);
// Log only when exceeded (and without PII)
if (rateLimitCheck.limited) {
console.log('Rate limit exceeded');
console.warn('Rate limit exceeded for contact endpoint');
return new Response(
JSON.stringify({
success: false,
@@ -159,10 +157,10 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
// Get form data
const formData = await request.formData();
console.log('Form data received');
// Form data received
// Log all form data keys
console.log('Form data keys:', [...formData.keys()]);
// Don't log field values
const name = formData.get('name')?.toString() || '';
const email = formData.get('email')?.toString() || '';
@@ -171,14 +169,7 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
const csrfToken = formData.get('csrf_token')?.toString() || '';
const domain = formData.get('domain')?.toString() || '';
console.log('Form data values:', {
name,
email,
messageLength: message.length,
disclaimer,
csrfToken: csrfToken ? 'present' : 'missing',
domain,
});
// Avoid logging PII; capture minimal telemetry only if needed
// Get user agent for logging and spam detection
const userAgent = request.headers.get('user-agent') || 'Unknown';
@@ -224,10 +215,10 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
spamReason = 'heuristic';
}
if (spamDetected) {
const token = jwt.sign({ email, message }, MANUAL_REVIEW_SECRET, { expiresIn: '1h' });
const secret = MANUAL_REVIEW_SECRET || '';
const token = secret ? jwt.sign({ email, message }, secret, { expiresIn: '1h' }) : '';
console.warn(
`[SPAM DETECTED by ${spamReason === 'Gemini' ? 'Gemini' : 'heuristic'}]`,
{ name, email, message, ip: request.headers.get('x-forwarded-for') }
`[SPAM DETECTED by ${spamReason === 'Gemini' ? 'Gemini' : 'heuristic'}]`
);
return new Response(
JSON.stringify({
@@ -256,13 +247,13 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
}
// Send emails
console.log('Attempting to send admin notification email');
// Send admin notification email
const adminEmailSent = await sendAdminNotification(name, email, message, ipAddress, userAgent, domain);
console.log('Admin email sent result:', adminEmailSent);
console.log('Attempting to send user confirmation email');
// Send user confirmation email
const userEmailSent = await sendUserConfirmation(name, email, message, domain);
console.log('User email sent result:', userEmailSent);
// Check if emails were sent successfully
if (!adminEmailSent || !userEmailSent) {

View File

@@ -89,7 +89,7 @@ function ensureUTC(dateString: string): string {
export const GET: APIRoute = async () => {
try {
if (!UPTIME_KUMA_URL) {
console.error('Missing environment variable: UPTIME_KUMA_URL');
// Avoid noisy logs in production; return typed error instead
return new Response(JSON.stringify({ error: 'Configuration error: Missing environment variable' }), {
status: 500,
headers: {
@@ -191,10 +191,10 @@ export const GET: APIRoute = async () => {
},
});
} catch (err) {
console.error('Error fetching uptime data:', err);
// Log minimal error context without leaking internals
return new Response(JSON.stringify({
error: 'Failed to fetch uptime data',
details: err instanceof Error ? err.message : 'Unknown error'
details: 'upstream error'
}), {
status: 500,
headers: {

View File

@@ -147,10 +147,10 @@ export async function sendEmail(to: string, subject: string, html: string, text:
}
try {
// Never trust user-provided domain for From header to prevent spoofing.
const safeSender = SMTP_USER || ADMIN_EMAIL;
const fromAddress = isProduction
? domain
? `"${WEBSITE_NAME}" <${SMTP_USER || 'info'}@${domain}>`
: `"${WEBSITE_NAME}" <${SMTP_USER || 'noreply@' + WEBSITE_NAME}>`
? `"${WEBSITE_NAME}" <${safeSender}>`
: `"${WEBSITE_NAME}" <${ADMIN_EMAIL}>`;
const mailOptions = {
@@ -161,9 +161,11 @@ export async function sendEmail(to: string, subject: string, html: string, text:
text,
};
// Log the connection target
console.log(`[MAILER DEBUG] Sending via: ${SMTP_HOST}:${SMTP_PORT}`);
console.log(`[MAILER DEBUG] From: ${fromAddress} → To: ${to}`);
// Log debug information only in non-production
if (!isProduction) {
console.log(`[MAILER DEBUG] Sending via: ${SMTP_HOST}:${SMTP_PORT}`);
console.log(`[MAILER DEBUG] From: ${fromAddress} → To: ${to}`);
}
await transporter.sendMail(mailOptions);

View File

@@ -1,20 +1,20 @@
import { GoogleGenerativeAI } from "@google/generative-ai";
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
if (!GEMINI_API_KEY) {
console.error("[Gemini] GEMINI_API_KEY environment variable is not set.");
throw new Error("GEMINI_API_KEY environment variable is not set.");
}
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
// Fail open without throwing at module import time; initialize lazily
const genAI = GEMINI_API_KEY ? new GoogleGenerativeAI(GEMINI_API_KEY) : null;
export async function isSpamWithGemini(message: string): Promise<boolean> {
try {
console.log('[Gemini] Attempting spam check...');
if (!genAI) {
console.warn('[Gemini] API key not set; skipping spam check.');
return false;
}
const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });
const prompt = `Is the following message spam? Reply with only 'yes' or 'no'.\n\nMessage:\n${message}`;
const result = await model.generateContent(prompt);
const response = result.response.text().trim().toLowerCase();
console.log(`[Gemini] Spam check result: ${response}`);
// Do not log PII or model responses in production
return response.startsWith("yes");
} catch (err: any) {
if (err?.response?.status === 401) {