Collect visitor homelab ideas safely
This commit is contained in:
parent
e242de3eec
commit
de4e9854e7
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
---
|
||||
|
|
|
|||
Loading…
Reference in New Issue