Collect visitor homelab ideas safely

This commit is contained in:
juvdiaz 2026-05-25 21:12:51 -06:00
parent e242de3eec
commit de4e9854e7
6 changed files with 406 additions and 2 deletions

View File

@ -1,5 +1,6 @@
<?php
require_once __DIR__ . '/lang_helper.php';
require_once __DIR__ . '/ideas_helper.php';
$activityKeys = [
'blog_activity_1',
@ -43,6 +44,8 @@ $stackKeys = [
];
$treeHref = 'homelab-tree.php?lang=' . urlencode($lang);
$ideaStatus = visitor_idea_clean((string) ($_GET['idea'] ?? ''), 20);
$visitorIdeas = visitor_ideas_read();
$giteaSourceBase = 'https://lab2025.duckdns.org/git/jv/my-homelab-configs/src/branch/main/';
$stackSourceLinks = [
'blog_stack_1' => [
@ -589,6 +592,79 @@ function renderStackSourceLinks(string $stackKey, array $sourceLinks, string $so
</li>
<?php endforeach; ?>
</ul>
<div class="visitor-ideas" id="visitor-ideas">
<div class="section-heading">
<p class="section-kicker"
data-translate data-key="blog_ideas_kicker"
data-en="<?php echo htmlspecialchars($en['blog_ideas_kicker']); ?>">
<?php echo $text['blog_ideas_kicker']; ?>
</p>
<h3 data-translate data-key="blog_ideas_title"
data-en="<?php echo htmlspecialchars($en['blog_ideas_title']); ?>">
<?php echo $text['blog_ideas_title']; ?>
</h3>
<p data-translate data-key="blog_ideas_intro"
data-en="<?php echo htmlspecialchars($en['blog_ideas_intro']); ?>">
<?php echo $text['blog_ideas_intro']; ?>
</p>
</div>
<?php if (in_array($ideaStatus, ['thanks', 'invalid', 'slow', 'error'], true)): ?>
<p class="idea-status idea-status-<?php echo htmlspecialchars($ideaStatus); ?>"
data-translate data-key="blog_idea_status_<?php echo htmlspecialchars($ideaStatus); ?>"
data-en="<?php echo htmlspecialchars($en['blog_idea_status_' . $ideaStatus]); ?>">
<?php echo $text['blog_idea_status_' . $ideaStatus]; ?>
</p>
<?php endif; ?>
<form class="idea-form" action="save_idea.php" method="post">
<input type="hidden" name="lang" value="<?php echo htmlspecialchars($lang); ?>">
<label>
<span data-translate data-key="blog_ideas_name_label"
data-en="<?php echo htmlspecialchars($en['blog_ideas_name_label']); ?>">
<?php echo $text['blog_ideas_name_label']; ?>
</span>
<input name="visitor_name" type="text" maxlength="80" autocomplete="name">
</label>
<label>
<span data-translate data-key="blog_ideas_text_label"
data-en="<?php echo htmlspecialchars($en['blog_ideas_text_label']); ?>">
<?php echo $text['blog_ideas_text_label']; ?>
</span>
<textarea name="visitor_idea" maxlength="600" required rows="4"></textarea>
</label>
<label class="idea-honey">
Website
<input name="company_site" type="text" tabindex="-1" autocomplete="off">
</label>
<button type="submit"
data-translate data-key="blog_ideas_submit"
data-en="<?php echo htmlspecialchars($en['blog_ideas_submit']); ?>">
<?php echo $text['blog_ideas_submit']; ?>
</button>
</form>
<?php if ($visitorIdeas): ?>
<div class="visitor-idea-list">
<h4 data-translate data-key="blog_ideas_recent_title"
data-en="<?php echo htmlspecialchars($en['blog_ideas_recent_title']); ?>">
<?php echo $text['blog_ideas_recent_title']; ?>
</h4>
<?php foreach ($visitorIdeas as $visitorIdea): ?>
<article class="visitor-idea-card">
<p><?php echo nl2br(htmlspecialchars($visitorIdea['idea'])); ?></p>
<footer>
<span><?php echo htmlspecialchars($visitorIdea['name']); ?></span>
<time datetime="<?php echo htmlspecialchars($visitorIdea['created_at']); ?>">
<?php echo htmlspecialchars(substr($visitorIdea['created_at'], 0, 10)); ?>
</time>
</footer>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</section>
</main>

View File

@ -0,0 +1,125 @@
<?php
function visitor_ideas_dir(): string {
return getenv('WEBSITE_IDEAS_WRITE_DIR') ?: sys_get_temp_dir() . '/website-ideas';
}
function visitor_ideas_file(): string {
return visitor_ideas_dir() . '/ideas.jsonl';
}
function visitor_ideas_rate_file(): string {
return visitor_ideas_dir() . '/rate.json';
}
function visitor_ideas_ensure_dir(): bool {
$dir = visitor_ideas_dir();
return is_dir($dir) || mkdir($dir, 0750, true);
}
function visitor_idea_clean(string $value, int $maxLength): string {
$value = preg_replace('/<\s*(script|style)\b[^>]*>.*?<\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;
}

View File

@ -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.',

View File

@ -0,0 +1,43 @@
<?php
require_once __DIR__ . '/ideas_helper.php';
function idea_redirect(string $lang, string $status): never {
$lang = preg_replace('/[^a-z]/', '', strtolower($lang));
if ($lang === '') {
$lang = 'en';
}
header('Location: blog.php?lang=' . rawurlencode($lang) . '&idea=' . rawurlencode($status) . '&saved=' . time() . '#visitor-ideas', true, 303);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit;
}
$lang = (string) ($_POST['lang'] ?? 'en');
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 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');

View File

@ -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;

View File

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