diff --git a/apps/website/blog.php b/apps/website/blog.php
index de439ab..86d2cb5 100644
--- a/apps/website/blog.php
+++ b/apps/website/blog.php
@@ -1,5 +1,6 @@
[
@@ -589,6 +592,79 @@ function renderStackSourceLinks(string $stackKey, array $sourceLinks, string $so
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/website/ideas_helper.php b/apps/website/ideas_helper.php
new file mode 100644
index 0000000..4915905
--- /dev/null
+++ b/apps/website/ideas_helper.php
@@ -0,0 +1,125 @@
+]*>.*?<\s*\/\s*\1\s*>/is', '', $value) ?? $value;
+ $value = strip_tags($value);
+ $value = str_replace(["\r\n", "\r"], "\n", $value);
+ $value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value) ?? '';
+ $value = preg_replace('/[ \t]+/u', ' ', $value) ?? $value;
+ $value = preg_replace("/\n{3,}/", "\n\n", $value) ?? $value;
+ $value = trim($value);
+ if (strlen($value) > $maxLength) {
+ $value = substr($value, 0, $maxLength);
+ }
+ return $value;
+}
+
+function visitor_ideas_recently_submitted(int $seconds = 45): bool {
+ if (!visitor_ideas_ensure_dir()) {
+ return true;
+ }
+
+ $file = visitor_ideas_rate_file();
+ $key = hash('sha256', $_SERVER['REMOTE_ADDR'] ?? 'unknown');
+ $now = time();
+ $handle = fopen($file, 'c+');
+ if (!$handle) {
+ return true;
+ }
+
+ flock($handle, LOCK_EX);
+ $raw = stream_get_contents($handle);
+ $rates = json_decode($raw ?: '{}', true);
+ if (!is_array($rates)) {
+ $rates = [];
+ }
+
+ foreach ($rates as $rateKey => $timestamp) {
+ if (!is_int($timestamp) && !ctype_digit((string) $timestamp)) {
+ unset($rates[$rateKey]);
+ continue;
+ }
+ if ($now - (int) $timestamp > 86400) {
+ unset($rates[$rateKey]);
+ }
+ }
+
+ $last = (int) ($rates[$key] ?? 0);
+ $blocked = $now - $last < $seconds;
+ if (!$blocked) {
+ $rates[$key] = $now;
+ rewind($handle);
+ ftruncate($handle, 0);
+ fwrite($handle, json_encode($rates, JSON_UNESCAPED_SLASHES));
+ }
+ fflush($handle);
+ flock($handle, LOCK_UN);
+ fclose($handle);
+
+ return $blocked;
+}
+
+function visitor_ideas_append(string $name, string $idea): bool {
+ if (!visitor_ideas_ensure_dir()) {
+ return false;
+ }
+
+ $entry = [
+ 'name' => $name !== '' ? $name : 'Anonymous visitor',
+ 'idea' => $idea,
+ 'created_at' => gmdate('c'),
+ ];
+
+ return file_put_contents(
+ visitor_ideas_file(),
+ json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n",
+ FILE_APPEND | LOCK_EX
+ ) !== false;
+}
+
+function visitor_ideas_read(int $limit = 6): array {
+ $file = visitor_ideas_file();
+ if (!is_readable($file)) {
+ return [];
+ }
+
+ $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+ if (!$lines) {
+ return [];
+ }
+
+ $ideas = [];
+ foreach (array_reverse($lines) as $line) {
+ $entry = json_decode($line, true);
+ if (!is_array($entry) || !isset($entry['idea'], $entry['name'], $entry['created_at'])) {
+ continue;
+ }
+ $ideas[] = [
+ 'name' => visitor_idea_clean((string) $entry['name'], 80),
+ 'idea' => visitor_idea_clean((string) $entry['idea'], 600),
+ 'created_at' => visitor_idea_clean((string) $entry['created_at'], 40),
+ ];
+ if (count($ideas) >= $limit) {
+ break;
+ }
+ }
+
+ return $ideas;
+}
diff --git a/apps/website/lang/en.php b/apps/website/lang/en.php
index e3f508b..cb96ac3 100644
--- a/apps/website/lang/en.php
+++ b/apps/website/lang/en.php
@@ -116,6 +116,17 @@ return [
'blog_todo_10' => 'Tighten TLS, SSH, and token rotation around the OCI edge, Gitea, registry, and runner credentials.',
'blog_todo_11' => 'Design the next storage step before adding more apps: NAS, replicated storage, or a clearly documented single-node tradeoff.',
'blog_todo_12' => 'Move sensitive app configuration into Sealed Secrets, External Secrets, or another explicit secret-management path.',
+ 'blog_ideas_kicker' => 'Visitor ideas',
+ 'blog_ideas_title' => 'What would you improve next?',
+ 'blog_ideas_intro' => 'Send a practical idea for the homelab backlog. Submissions are stored as plain text, limited in size, and rendered escaped.',
+ 'blog_ideas_name_label' => 'Name, optional',
+ 'blog_ideas_text_label' => 'Improvement idea',
+ 'blog_ideas_submit' => 'Send idea',
+ 'blog_ideas_recent_title' => 'Recent visitor ideas',
+ 'blog_idea_status_thanks' => 'Thanks. Your idea was added to the backlog suggestions.',
+ 'blog_idea_status_invalid' => 'That idea was too short or too large. Please send a concise plain-text suggestion.',
+ 'blog_idea_status_slow' => 'Please wait a moment before sending another idea.',
+ 'blog_idea_status_error' => 'The idea could not be saved right now. Please try again later.',
'tree_kicker' => 'Fun architecture mode',
'tree_title' => 'The Homelab Christmas Tree',
'tree_subtitle' => 'Same platform, less serious outfit: every part of the homelab becomes a tree part, from the public DNS star down to the storage roots and backup gifts.',
diff --git a/apps/website/save_idea.php b/apps/website/save_idea.php
new file mode 100644
index 0000000..d0e6934
--- /dev/null
+++ b/apps/website/save_idea.php
@@ -0,0 +1,43 @@
+ 4096) {
+ idea_redirect($lang, 'invalid');
+}
+
+if (visitor_idea_clean((string) ($_POST['company_site'] ?? ''), 80) !== '') {
+ idea_redirect($lang, 'thanks');
+}
+
+$name = visitor_idea_clean((string) ($_POST['visitor_name'] ?? ''), 80);
+$idea = visitor_idea_clean((string) ($_POST['visitor_idea'] ?? ''), 600);
+
+if (strlen($idea) < 10) {
+ idea_redirect($lang, 'invalid');
+}
+
+if (visitor_ideas_recently_submitted()) {
+ idea_redirect($lang, 'slow');
+}
+
+if (!visitor_ideas_append($name, $idea)) {
+ idea_redirect($lang, 'error');
+}
+
+idea_redirect($lang, 'thanks');
diff --git a/apps/website/styles.css b/apps/website/styles.css
index 808510a..b340b68 100644
--- a/apps/website/styles.css
+++ b/apps/website/styles.css
@@ -640,6 +640,139 @@ body {
background: #fff;
}
+.visitor-ideas {
+ margin-top: 24px;
+ padding-top: 24px;
+ border-top: 1px solid #e6edf5;
+}
+
+.visitor-ideas h3 {
+ margin: 0 0 10px;
+ color: #102a43;
+ font-size: 1.35rem;
+}
+
+.idea-status {
+ margin: 0 0 16px;
+ padding: 12px 14px;
+ border-radius: 8px;
+ border: 1px solid #bcccdc;
+ background: #f8fbff;
+ color: #334e68;
+ font-weight: 700;
+}
+
+.idea-status-thanks {
+ border-color: #b7ebc6;
+ background: #effcf6;
+ color: #276749;
+}
+
+.idea-status-invalid,
+.idea-status-slow,
+.idea-status-error {
+ border-color: #fed7d7;
+ background: #fff5f5;
+ color: #9b2c2c;
+}
+
+.idea-form {
+ display: grid;
+ gap: 14px;
+ max-width: 720px;
+}
+
+.idea-form label {
+ display: grid;
+ gap: 7px;
+ color: #334e68;
+ font-weight: 800;
+}
+
+.idea-form input,
+.idea-form textarea {
+ width: 100%;
+ box-sizing: border-box;
+ border: 1px solid #bcccdc;
+ border-radius: 8px;
+ padding: 11px 12px;
+ background: #fff;
+ color: #102a43;
+ font: inherit;
+}
+
+.idea-form textarea {
+ resize: vertical;
+ min-height: 120px;
+}
+
+.idea-form input:focus,
+.idea-form textarea:focus {
+ outline: 3px solid #d9eaff;
+ border-color: #004085;
+}
+
+.idea-form button {
+ justify-self: start;
+ min-height: 42px;
+ padding: 0 18px;
+ border: 0;
+ border-radius: 6px;
+ background: #004085;
+ color: #fff;
+ font: inherit;
+ font-weight: 800;
+ cursor: pointer;
+}
+
+.idea-form button:hover {
+ background: #002752;
+}
+
+.idea-honey {
+ position: absolute;
+ left: -10000px;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+}
+
+.visitor-idea-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 12px;
+ margin-top: 22px;
+}
+
+.visitor-idea-list h4 {
+ grid-column: 1 / -1;
+ margin: 0;
+ color: #102a43;
+}
+
+.visitor-idea-card {
+ min-height: 120px;
+ padding: 14px;
+ border: 1px solid #e6edf5;
+ border-radius: 8px;
+ background: #fbfdff;
+}
+
+.visitor-idea-card p {
+ margin: 0 0 14px;
+ line-height: 1.5;
+}
+
+.visitor-idea-card footer {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 8px;
+ color: #627d98;
+ font-size: 0.85rem;
+ font-weight: 800;
+}
+
.tree-page {
max-width: 1120px;
margin: 0 auto;
diff --git a/apps/website/web-app.yaml b/apps/website/web-app.yaml
index 2a07f36..e934020 100644
--- a/apps/website/web-app.yaml
+++ b/apps/website/web-app.yaml
@@ -1,3 +1,16 @@
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: website-db
+ namespace: website-production
+spec:
+ accessModes:
+ - ReadWriteOnce
+ storageClassName: openebs-hostpath-retain
+ resources:
+ requests:
+ storage: 1Gi
+---
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -30,7 +43,7 @@ spec:
fsGroupChangePolicy: OnRootMismatch
affinity:
podAntiAffinity:
- preferredDuringSchedulingIgnoredDuringExecution: # requiredDuringSchedulingIgnoredDuringExecution:
+ preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
@@ -53,6 +66,8 @@ spec:
env:
- name: WEBSITE_LANG_WRITE_DIR
value: /tmp/website-lang
+ - name: WEBSITE_IDEAS_WRITE_DIR
+ value: /var/www/localhost/htdocs/db/ideas
ports:
- containerPort: 8080
name: http
@@ -89,7 +104,8 @@ spec:
- name: apache-logs
emptyDir: {}
- name: website-db
- emptyDir: {}
+ persistentVolumeClaim:
+ claimName: website-db
- name: tmp
emptyDir: {}
---