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

@@ -66,3 +66,28 @@ AstroWind is an open-source project created and maintained by [onWidget](https:/
- 📄 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.
---
## 🔐 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,
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) {