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:
52
DESIGN_REVIEW.md
Normal file
52
DESIGN_REVIEW.md
Normal 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.
|
||||
|
||||
|
27
README.md
27
README.md
@@ -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)
|
||||
|
||||
We’ve adapted the template to reflect the unique mission and brand identity of 365DevNet, while continuing to follow the project's great structure and standards.
|
||||
We’ve 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
135
SECURITY_AUDIT.md
Normal 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.
|
||||
|
||||
|
@@ -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: {
|
||||
|
@@ -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
3175
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
43
package.json
43
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
@@ -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'
|
||||
|
16
server.js
16
server.js
@@ -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')));
|
||||
|
||||
|
72
src/assets/images/topdesk-api-docs.png
Normal file
72
src/assets/images/topdesk-api-docs.png
Normal 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>
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
@@ -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;
|
||||
|
@@ -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 from 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>
|
||||
|
@@ -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
12
src/env.d.ts
vendored
@@ -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 {
|
||||
|
1103
src/i18n/translations.topdesk-docs.ts
Normal file
1103
src/i18n/translations.topdesk-docs.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
@@ -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>
|
||||
|
@@ -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'
|
||||
|
948
src/pages/[lang]/topdesk.astro
Normal file
948
src/pages/[lang]/topdesk.astro
Normal 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>
|
@@ -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}`);
|
||||
|
@@ -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) {
|
||||
|
@@ -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: {
|
||||
|
@@ -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);
|
||||
|
||||
|
@@ -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) {
|
||||
|
Reference in New Issue
Block a user