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: {} ---