Initial commit: site code, layouts, content (images added separately)

Fixes duplicate placeholder cover image on the 3 newest blog posts,
broken aspect-ratio classes on the albums listing page, and a
non-responsive fixed sidebar on the chat page.
This commit is contained in:
Dr. Jones
2026-07-08 06:18:17 +00:00
commit d9961a76ad
80 changed files with 9438 additions and 0 deletions

112
scripts/CHAT_API_README.md Normal file
View File

@@ -0,0 +1,112 @@
# Chat API Server
This Flask-based API server provides a secure interface for the chat feature, connecting users to the Ollama model.
## Features
- **Secure**: Input sanitization, rate limiting, CORS protection
- **Rate Limited**: 10 requests per minute per IP address
- **Context Aware**: Maintains conversation history (last 5 messages)
- **Auto-restart**: Runs as systemd service with automatic restart
## Configuration
**Environment Variables:**
- `OLLAMA_HOST`: Ollama endpoint (default: `http://10.30.20.110:11434`)
- `OLLAMA_MODEL`: Model name (default: `drjones-posts-to-much`)
- `PORT`: API server port (default: `5000`)
- `CHAT_API_KEY`: Optional API key for additional security (not set by default)
## Service Management
```bash
# Start service
sudo systemctl start hugo-chat-api.service
# Stop service
sudo systemctl stop hugo-chat-api.service
# Enable at boot
sudo systemctl enable hugo-chat-api.service
# Check status
sudo systemctl status hugo-chat-api.service
# View logs
sudo journalctl -u hugo-chat-api.service -f
```
## API Endpoints
### POST `/api/chat`
Send a chat message and get AI response.
**Request:**
```json
{
"message": "How do I maintain sterile conditions?",
"history": [
{"role": "user", "content": "Previous message"},
{"role": "assistant", "content": "Previous response"}
]
}
```
**Response:**
```json
{
"response": "AI response text...",
"model": "drjones-posts-to-much"
}
```
### GET `/api/health`
Health check endpoint.
**Response:**
```json
{
"status": "healthy",
"model": "drjones-posts-to-much",
"ollama_host": "http://10.30.20.110:11434"
}
```
## Security Features
1. **Rate Limiting**: 10 requests per minute per IP address
2. **Input Sanitization**: Removes dangerous characters, limits length to 1000 chars
3. **CORS Protection**: Only allows requests from configured origins
4. **Error Handling**: Graceful error handling with user-friendly messages
## Installation
1. Install dependencies:
```bash
pip3 install flask flask-cors ollama
```
2. Enable and start service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable hugo-chat-api.service
sudo systemctl start hugo-chat-api.service
```
## Troubleshooting
**Service won't start:**
- Check logs: `sudo journalctl -u hugo-chat-api.service -n 50`
- Verify Ollama is accessible: `curl http://10.30.20.110:11434/api/tags`
- Check port 5000 is available: `sudo lsof -i :5000`
**Rate limit errors:**
- Normal behavior - users are limited to 10 requests per minute
- Rate limit resets after 1 minute
**Connection errors:**
- Verify Ollama endpoint is correct and accessible
- Check firewall rules allow connections to port 5000

171
scripts/README.md Normal file
View File

@@ -0,0 +1,171 @@
# HydroLord Website Automation Scripts
This directory contains automation scripts for the HydroLord Hugo website.
## Scripts
### `generate_blog_post.py`
Generates a new blog post using Ollama's AI model and adds it to the Hugo blog.
**Configuration:**
- **Ollama Endpoint:** `http://10.30.20.110:11434`
- **Model:** `drjones-posts-to-much`
- **Output Directory:** `/root/hydro-sterile/content/blog/`
- **Schedule:** Runs automatically every 3 days via systemd timer
- **Randomization:** Each run uses a randomly varied prompt to ensure unique content
**Features:**
- Automatically finds the next available post number
- Generates title and summary from content
- Creates proper Hugo frontmatter
- Fails quietly if Ollama endpoint is unavailable (will retry in 3 days)
**Usage:**
```bash
python3 generate_blog_post.py
```
**Error Handling:**
If the Ollama endpoint is down, the script exits gracefully with code 0 (no error) to prevent systemd from logging failures. It will automatically retry in 3 days when the timer runs again.
### `start_hugo_server.sh`
Starts the Hugo development server for the website.
**Configuration:**
- **Port:** 1313 (configurable via `HUGO_PORT` env var)
- **Bind Address:** 0.0.0.0 (all interfaces, configurable via `HUGO_BIND` env var)
- **Base URL:** Read from `hugo.yaml`
**Usage:**
```bash
./start_hugo_server.sh
```
## Systemd Services
### Hugo Website Service
**Service:** `hugo-hydrolord.service`
Starts the Hugo server at boot and keeps it running.
**Commands:**
```bash
# Enable service to start at boot
sudo systemctl enable hugo-hydrolord.service
# Start service now
sudo systemctl start hugo-hydrolord.service
# Check status
sudo systemctl status hugo-hydrolord.service
# View logs
sudo journalctl -u hugo-hydrolord.service -f
```
### Blog Post Generator
**Service:** `hugo-blog-generator.service`
**Timer:** `hugo-blog-generator.timer`
Automatically generates blog posts every 3 days.
**Commands:**
```bash
# Enable timer
sudo systemctl enable hugo-blog-generator.timer
# Start timer
sudo systemctl start hugo-blog-generator.timer
# Check timer status
sudo systemctl status hugo-blog-generator.timer
# List all timers
systemctl list-timers hugo-blog-generator.timer
# Run generator manually (for testing)
sudo systemctl start hugo-blog-generator.service
# View logs
sudo journalctl -u hugo-blog-generator.service -f
```
## Installation
1. **Install Python dependencies:**
```bash
pip3 install ollama
```
2. **Make scripts executable:**
```bash
chmod +x /root/hydro-sterile/scripts/*.py
chmod +x /root/hydro-sterile/scripts/*.sh
```
3. **Enable and start Hugo service:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable hugo-hydrolord.service
sudo systemctl start hugo-hydrolord.service
```
4. **Enable and start blog generator timer:**
```bash
sudo systemctl enable hugo-blog-generator.timer
sudo systemctl start hugo-blog-generator.timer
```
5. **Verify everything is running:**
```bash
sudo systemctl status hugo-hydrolord.service
sudo systemctl status hugo-blog-generator.timer
```
## Troubleshooting
### Hugo Server Not Starting
- Check if port 1313 is already in use: `sudo lsof -i :1313`
- Check logs: `sudo journalctl -u hugo-hydrolord.service -n 50`
- Verify Hugo is installed: `which hugo` or check `node_modules/.bin/hugo`
### Blog Generator Not Running
- Check if Ollama endpoint is accessible: `curl http://10.30.20.110:11434/api/tags`
- Check timer status: `systemctl list-timers hugo-blog-generator.timer`
- View logs: `sudo journalctl -u hugo-blog-generator.service -n 50`
- Test script manually: `python3 /root/hydro-sterile/scripts/generate_blog_post.py`
### Ollama Endpoint Down
The script is designed to fail quietly if Ollama is unavailable. It will exit with code 0 (success) and retry automatically in 3 days. No action needed.
## Configuration Changes
To change the Ollama endpoint or model, edit `/root/hydro-sterile/scripts/generate_blog_post.py` and modify the constants at the top:
```python
OLLAMA_HOST = "http://10.30.20.110:11434"
MODEL = "drjones-posts-to-much"
```
**Note:** The script now includes random prompt variations to ensure each blog post is unique. The model generates titles with credentials, which the script automatically extracts and separates from the article content.
After making changes, restart the timer:
```bash
sudo systemctl restart hugo-blog-generator.timer
```
## Files Location
- **Scripts:** `/root/hydro-sterile/scripts/`
- **Systemd Services:** `/etc/systemd/system/hugo-*.service`
- **Systemd Timers:** `/etc/systemd/system/hugo-*.timer`
- **Blog Posts:** `/root/hydro-sterile/content/blog/`
- **Logs:** `journalctl -u hugo-hydrolord.service` or `journalctl -u hugo-blog-generator.service`

Binary file not shown.

Binary file not shown.

Binary file not shown.

348
scripts/chat_api.py Executable file
View File

@@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""
Chat API Server for Ollama Integration
This Flask server provides a secure API endpoint for the chat interface.
It handles rate limiting, input validation, and communicates with Ollama.
Usage:
python3 chat_api.py
Configuration:
- Ollama endpoint: 10.30.20.110:11434
- Model: drjones-posts-to-much
- Port: 5000 (configurable via PORT env var)
"""
import os
import sys
import time
import re
import yaml
from pathlib import Path
from datetime import datetime, timedelta
from flask import Flask, request, jsonify
from flask_cors import CORS
from functools import wraps
import hashlib
try:
from ollama import Client
except ImportError:
print("ERROR: ollama Python library not installed. Run: pip3 install ollama", file=sys.stderr)
sys.exit(1)
try:
import pdfplumber
PDF_SUPPORT = True
except ImportError:
PDF_SUPPORT = False
print("WARNING: pdfplumber not installed. PDF text extraction disabled.", file=sys.stderr)
# Configuration
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://10.30.20.110:11434")
MODEL = os.getenv("OLLAMA_MODEL", "drjones-posts-to-much")
PORT = int(os.getenv("PORT", 5000))
API_KEY = os.getenv("CHAT_API_KEY", "") # Optional API key for additional security
LIBRARY_DIR = Path("/root/hydro-sterile/static/library")
LIBRARY_YAML = Path("/root/hydro-sterile/data/library.yaml")
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB max file size
MAX_TEXT_LENGTH = 5000 # Max characters to extract from PDF
# Rate limiting storage (in-memory, simple implementation)
rate_limits = {}
# System prompt for the chat
SYSTEM_PROMPT = """You are Dr. Jones, a sterile hydroponics expert with 20 years of experience growing cannabis in a legal state.
You provide practical, direct, and knowledgeable advice about sterile hydroponics, cannabis cultivation, and growing techniques.
Keep responses concise (2-4 paragraphs), practical, and actionable. Write in first person (I, me, my) and be conversational."""
app = Flask(__name__)
# Allow CORS from the website domain and local development
CORS(app, origins=[
"https://hydrolord.thetempleofdoom.com",
"http://hydrolord.thetempleofdoom.com",
"http://localhost:1313",
"http://127.0.0.1:1313",
"http://10.30.20.243",
"http://10.30.20.243:1313"
])
def get_client_ip():
"""Get client IP address for rate limiting."""
if request.headers.get('X-Forwarded-For'):
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
return request.remote_addr
def rate_limit(max_per_minute=10, window_minutes=1):
"""Simple rate limiting decorator."""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
client_ip = get_client_ip()
now = time.time()
window_seconds = window_minutes * 60
# Clean old entries
rate_limits[client_ip] = [
timestamp for timestamp in rate_limits.get(client_ip, [])
if now - timestamp < window_seconds
]
# Check rate limit
if len(rate_limits.get(client_ip, [])) >= max_per_minute:
return jsonify({
"error": "Rate limit exceeded. Please wait a moment before sending another message."
}), 429
# Add current request
if client_ip not in rate_limits:
rate_limits[client_ip] = []
rate_limits[client_ip].append(now)
return f(*args, **kwargs)
return decorated_function
return decorator
def sanitize_input(text):
"""Sanitize user input to prevent injection attacks."""
if not text or not isinstance(text, str):
return ""
# Remove potentially dangerous characters
text = re.sub(r'[<>]', '', text)
# Limit length
text = text[:1000].strip()
return text
def load_library_files():
"""Load library file list from YAML."""
try:
with open(LIBRARY_YAML, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
files = []
for entry in data or []:
if isinstance(entry, dict) and 'title' in entry and 'file' in entry:
file_path = entry.get('file', '')
# Convert /library/ path to actual file path
if file_path.startswith('/library/'):
filename = file_path.replace('/library/', '')
full_path = LIBRARY_DIR / filename
if full_path.exists():
files.append({
'title': entry.get('title', ''),
'filename': entry.get('filename', ''),
'file': file_path,
'category': entry.get('category', ''),
'size': full_path.stat().st_size if full_path.exists() else 0
})
return files
except Exception as e:
print(f"Error loading library: {e}", file=sys.stderr)
return []
def extract_pdf_text(file_path, max_length=MAX_TEXT_LENGTH):
"""Extract text from PDF file."""
if not PDF_SUPPORT:
return None
try:
full_path = LIBRARY_DIR / file_path.replace('/library/', '')
if not full_path.exists():
return None
if full_path.stat().st_size > MAX_FILE_SIZE:
return f"[File too large: {full_path.stat().st_size / 1024 / 1024:.1f}MB. Max size: {MAX_FILE_SIZE / 1024 / 1024}MB]"
text_parts = []
with pdfplumber.open(str(full_path)) as pdf:
for page in pdf.pages[:10]: # Limit to first 10 pages
page_text = page.extract_text()
if page_text:
text_parts.append(page_text)
if len(' '.join(text_parts)) > max_length:
break
full_text = ' '.join(text_parts)
if len(full_text) > max_length:
full_text = full_text[:max_length] + "... [truncated]"
return full_text.strip()
except Exception as e:
print(f"Error extracting PDF text: {e}", file=sys.stderr)
return None
@app.route('/api/chat', methods=['POST'])
@rate_limit(max_per_minute=10, window_minutes=1)
def chat():
"""Handle chat requests."""
try:
data = request.get_json()
if not data or 'message' not in data:
return jsonify({"error": "Message is required"}), 400
message = sanitize_input(data['message'])
if not message:
return jsonify({"error": "Message cannot be empty"}), 400
# Optional API key check
if API_KEY and data.get('api_key') != API_KEY:
return jsonify({"error": "Invalid API key"}), 401
# Get selected library files
selected_files = data.get('files', [])
file_context = ""
if selected_files:
file_context_parts = []
for file_ref in selected_files[:3]: # Limit to 3 files max
if isinstance(file_ref, str):
file_path = file_ref
elif isinstance(file_ref, dict) and 'file' in file_ref:
file_path = file_ref['file']
else:
continue
# Extract text from PDF
pdf_text = extract_pdf_text(file_path)
if pdf_text:
# Get file title
file_title = file_ref.get('title', '') if isinstance(file_ref, dict) else file_path
file_context_parts.append(f"\n\n--- Content from: {file_title} ---\n{pdf_text}\n--- End of {file_title} ---")
if file_context_parts:
file_context = "\n\n[The user has referenced the following library documents. Use this information to provide accurate, specific answers based on these sources:]\n" + "\n".join(file_context_parts)
# Get conversation history (last 5 messages for context)
history = data.get('history', [])
history_messages = []
# Build conversation history
for msg in history[-5:]: # Last 5 messages
if isinstance(msg, dict) and 'role' in msg and 'content' in msg:
history_messages.append({
"role": msg['role'],
"content": sanitize_input(msg['content'])
})
# Add current user message with file context
user_message = message
if file_context:
user_message = message + file_context
history_messages.append({
"role": "user",
"content": user_message
})
# Call Ollama
try:
client = Client(host=OLLAMA_HOST)
# Build messages for Ollama
ollama_messages = [
{"role": "system", "content": SYSTEM_PROMPT}
]
# Add conversation history
for msg in history_messages:
ollama_messages.append({
"role": msg['role'],
"content": msg['content']
})
# Generate response
response = client.chat(
model=MODEL,
messages=ollama_messages,
options={
"temperature": 0.7,
"top_p": 0.9,
}
)
assistant_response = response.message.content.strip()
return jsonify({
"response": assistant_response,
"model": MODEL
})
except Exception as e:
print(f"Ollama error: {e}", file=sys.stderr)
return jsonify({
"error": "Unable to connect to AI service. Please try again later."
}), 503
except Exception as e:
print(f"API error: {e}", file=sys.stderr)
return jsonify({
"error": "An error occurred processing your request."
}), 500
@app.route('/api/library/files', methods=['GET'])
def list_library_files():
"""List available library files."""
try:
files = load_library_files()
# Filter to only PDF files for now
pdf_files = [f for f in files if f['filename'].lower().endswith('.pdf')]
return jsonify({
"files": pdf_files,
"count": len(pdf_files)
})
except Exception as e:
print(f"Error listing files: {e}", file=sys.stderr)
return jsonify({
"error": "Failed to load library files"
}), 500
@app.route('/api/library/preview', methods=['POST'])
def preview_file():
"""Preview/extract text from a library file."""
try:
data = request.get_json()
file_path = data.get('file')
if not file_path:
return jsonify({"error": "File path required"}), 400
text = extract_pdf_text(file_path)
if text is None:
return jsonify({
"error": "Could not extract text from file. File may not be a PDF or may be corrupted."
}), 400
return jsonify({
"text": text,
"length": len(text)
})
except Exception as e:
print(f"Error previewing file: {e}", file=sys.stderr)
return jsonify({
"error": "Failed to preview file"
}), 500
@app.route('/api/health', methods=['GET'])
def health():
"""Health check endpoint."""
return jsonify({
"status": "healthy",
"model": MODEL,
"ollama_host": OLLAMA_HOST,
"pdf_support": PDF_SUPPORT
})
if __name__ == '__main__':
print(f"Starting Chat API server on port {PORT}")
print(f"Ollama endpoint: {OLLAMA_HOST}")
print(f"Model: {MODEL}")
app.run(host='0.0.0.0', port=PORT, debug=False)

320
scripts/generate_blog_post.py Executable file
View File

@@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""
Ollama Blog Post Generator
This script generates a new blog post using Ollama's drjones-posts-to-much model
and adds it to the Hugo blog. It runs automatically every 3 days via systemd timer.
The script uses random prompt variations to ensure each blog post is unique.
The model generates titles with credentials, which are automatically extracted
and separated from the article content.
Usage:
python3 generate_blog_post.py
Configuration:
- Ollama endpoint: 10.30.20.110:11434
- Model: drjones-posts-to-much
- Blog directory: /root/hydro-sterile/content/blog/
Error Handling:
If the Ollama endpoint is down, the script fails quietly (exits with code 0)
to prevent systemd from logging errors. It will retry in 3 days.
Author: Dr. Jones
"""
import os
import sys
import json
import random
from datetime import datetime, timedelta
from pathlib import Path
try:
from ollama import Client
except ImportError:
print("ERROR: ollama Python library not installed. Run: pip3 install ollama", file=sys.stderr)
sys.exit(1)
# Configuration
OLLAMA_HOST = "http://10.30.20.110:11434"
MODEL = "drjones-posts-to-much"
BLOG_DIR = Path("/root/hydro-sterile/content/blog")
SITE_DIR = Path("/root/hydro-sterile")
# System prompt for generating blog posts
SYSTEM_PROMPT = """You are Dr. Jones, a sterile hydroponics expert with 20 years of experience growing cannabis in a legal state.
Your writing style is authentic, direct, and knowledgeable. You focus on sterile hydroponics methodology,
growing techniques, and practical advice. Write blog posts that are:
- 300-500 words
- Focused on sterile hydroponics, cannabis cultivation, or related topics
- Written in first person (I, me, my)
- Practical and actionable
- Authentic and conversational
The model will generate a title with credentials, then the article content."""
def get_next_post_number():
"""Find the next available post number by checking existing posts."""
existing_posts = list(BLOG_DIR.glob("post-*.md"))
if not existing_posts:
return 1
numbers = []
for post in existing_posts:
try:
# Extract number from filename like "post-1.md"
num = int(post.stem.split("-")[1])
numbers.append(num)
except (ValueError, IndexError):
continue
return max(numbers) + 1 if numbers else 1
def generate_filename(post_number):
"""Generate the filename for a new post."""
return BLOG_DIR / f"post-{post_number}.md"
def create_frontmatter(title, summary, image_path=None):
"""Create YAML frontmatter for Hugo blog post."""
date_str = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
frontmatter = f"""---
title: "{title}"
date: {date_str}
image: "{image_path or '/images/IMG_1767.jpeg'}"
summary: "{summary}"
---
"""
return frontmatter
def generate_random_prompt():
"""Generate a random prompt variation to ensure different content each time."""
# Different ways to ask for hydroponics knowledge
prompt_variations = [
"Share your wisdom about sterile hydroponics. What insights can you offer growers today?",
"I'm seeking your expertise on hydroponic cultivation. What knowledge can you impart?",
"Please share your knowledge about sterile hydroponics systems. What should growers know?",
"I need your guidance on hydroponic growing. What can you teach me today?",
"What hydroponic wisdom can you share? I'm eager to learn from your experience.",
"Please enlighten me about sterile hydroponics. What key insights do you have?",
"I'm asking for your hydroponic expertise. What valuable knowledge can you provide?",
"Share your hydroponic knowledge with me. What should I know about sterile systems?",
"I'm seeking your hydroponic wisdom. What can you teach growers today?",
"Please provide your insights on sterile hydroponics. What's important to know?",
"What can you tell me about hydroponic cultivation? I'm looking for your expertise.",
"I need your hydroponic knowledge. What guidance can you offer?",
"Share your experience with sterile hydroponics. What should growers understand?",
"I'm asking for your hydroponic insights. What knowledge can you share?",
"Please enlighten me about hydroponic systems. What wisdom do you have?",
]
# Additional random elements to add variety
topic_hints = [
"",
" Focus on practical techniques.",
" Emphasize sterile methodology.",
" Include real-world examples.",
" Cover common mistakes to avoid.",
" Discuss nutrient management.",
" Talk about root health.",
" Share system design tips.",
" Address problem-solving.",
" Include cultivation tips.",
]
# Random opening phrases
openings = [
"",
"Today, ",
"In this post, ",
"Let me share ",
"Here's what I know: ",
]
base_prompt = random.choice(prompt_variations)
topic_hint = random.choice(topic_hints)
opening = random.choice(openings)
# Combine with some randomness
full_prompt = opening + base_prompt + topic_hint
return full_prompt
def call_ollama():
"""Call Ollama API to generate blog post content."""
client = Client(host=OLLAMA_HOST)
try:
# Generate random prompt for variety
user_prompt = generate_random_prompt()
# Generate the blog post content
response = client.chat(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}
],
options={
"temperature": 0.8 + random.uniform(0, 0.2), # Random temperature between 0.8-1.0 for more variety
"top_p": 0.9,
"repeat_penalty": 1.5, # High repeat penalty to avoid repetitive content
}
)
content = response.message.content
return content
except Exception as e:
# Fail quietly - don't raise exception, just return None
# This allows the script to exit gracefully when Ollama is down
print(f"Warning: Could not connect to Ollama at {OLLAMA_HOST}: {e}", file=sys.stderr)
return None
def extract_title_and_summary(content):
"""Extract title and summary from generated content.
The model generates title with credentials, then the article.
We need to separate these and extract just the title and article content.
"""
lines = content.strip().split('\n')
# The model typically generates: "Title - Dr. Jones, Credentials\n\nArticle content"
# Or: "Title\nDr. Jones, Credentials\n\nArticle content"
# Or: "# Title\n\nDr. Jones, Credentials\n\nArticle content"
title = ""
article_start_idx = 0
credentials_found = False
# Look for title (usually first line or first few lines)
# Title might be followed by credentials line, then blank line, then article
for i, line in enumerate(lines):
line_stripped = line.strip()
# Skip empty lines at the start
if not line_stripped:
continue
# If we haven't found title yet, this might be it
if not title:
# Remove markdown headers
potential_title = line_stripped.replace('#', '').strip()
# Check if this line contains credentials (common patterns)
if any(cred in potential_title.lower() for cred in ['dr.', 'dr ', 'phd', 'ph.d', 'credentials', 'expert', 'years']):
# This might be credentials, skip it and look for title before it
if i > 0:
# Previous line might be title
prev_line = lines[i-1].strip().replace('#', '').strip()
if prev_line:
title = prev_line
article_start_idx = i + 1
credentials_found = True
break
continue
# If line ends with common credential patterns, split it
if ' - ' in potential_title or ' | ' in potential_title:
parts = potential_title.split(' - ') if ' - ' in potential_title else potential_title.split(' | ')
title = parts[0].strip()
article_start_idx = i + 1
credentials_found = True
break
else:
# This might be the title, but check next line for credentials
title = potential_title
article_start_idx = i + 1
# Check if this is a credentials line (after title)
elif not credentials_found and any(cred in line_stripped.lower() for cred in ['dr.', 'dr ', 'phd', 'ph.d', 'expert', 'years of experience']):
credentials_found = True
article_start_idx = i + 1
break
# If we've passed potential title/credentials, this is article content
elif title and not credentials_found and line_stripped:
# No credentials found, article starts here
article_start_idx = i
break
# Clean up title
if not title:
# Fallback: use first non-empty line
for line in lines:
line_stripped = line.strip().replace('#', '').strip()
if line_stripped:
title = line_stripped
break
if len(title) > 100:
title = title[:97] + "..."
# Extract article content (skip title and credentials)
article_lines = lines[article_start_idx:]
article_content = '\n'.join(article_lines).strip()
# Generate summary from first paragraph of article
summary = ""
for line in article_lines:
line = line.strip()
if line and not line.startswith('#') and len(line) > 20:
summary = line[:150]
if len(line) > 150:
summary += "..."
break
if not summary:
summary = "A new insight on sterile hydroponics and cultivation techniques."
return title, summary, article_content
def main():
"""Main function to generate and save blog post."""
# Ensure blog directory exists
BLOG_DIR.mkdir(parents=True, exist_ok=True)
# Check if Ollama is available
content = call_ollama()
if content is None:
# Fail quietly - exit with code 0 so systemd doesn't log errors
print("Ollama endpoint unavailable. Will retry in 3 days.", file=sys.stderr)
sys.exit(0)
# Get next post number
post_number = get_next_post_number()
filename = generate_filename(post_number)
# Extract title, summary, and article content (separate from credentials)
title, summary, article_content = extract_title_and_summary(content)
# Create frontmatter
frontmatter = create_frontmatter(title, summary)
# Combine frontmatter and article content (not the full content with credentials)
full_content = frontmatter + "\n" + article_content + "\n"
# Write to file
try:
with open(filename, 'w', encoding='utf-8') as f:
f.write(full_content)
print(f"Successfully created blog post: {filename}")
print(f"Title: {title}")
return 0
except Exception as e:
print(f"ERROR: Failed to write blog post: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
sys.exit(main())

478
scripts/membership_api.py Normal file
View File

@@ -0,0 +1,478 @@
#!/usr/bin/env python3
"""
Membership API Server
Handles user authentication, membership management, download tracking,
testimonials, and secure contact form submissions.
"""
import os
import sys
import sqlite3
import hashlib
import secrets
import re
import smtplib
import time
from pathlib import Path
from datetime import datetime, timedelta
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from functools import wraps
from flask import Flask, request, jsonify, send_from_directory
from flask_cors import CORS
import bcrypt
import jwt
# Configuration
PORT = int(os.getenv("MEMBERSHIP_PORT", 80))
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")
ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "indiana.holmes8@gmail.com")
JWT_SECRET = os.getenv("JWT_SECRET", secrets.token_urlsafe(32))
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", 587))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASS = os.getenv("SMTP_PASS", "")
# Static folder for Hugo build
STATIC_FOLDER = "/root/hydro-sterile/public"
# Database path
DB_PATH = Path("/root/hydro-sterile/db/members.db")
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
app = Flask(__name__)
CORS(app, resources={r"/api/*": {"origins": "*"}})
# Static folder for Hugo build
STATIC_FOLDER = "/root/hydro-sterile/public"
# Rate limiting storage
rate_limits = {}
def get_client_ip():
"""Get client IP address."""
if request.headers.get('X-Forwarded-For'):
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
return request.remote_addr or 'unknown'
def init_db():
"""Initialize database with required tables."""
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
# Users table
c.execute('''CREATE TABLE IF NOT EXISTS users
(id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
status TEXT DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
approved_at TIMESTAMP,
approved_by TEXT)''')
# Downloads table
c.execute('''CREATE TABLE IF NOT EXISTS downloads
(id INTEGER PRIMARY KEY AUTOINCREMENT,
ip_address TEXT NOT NULL,
user_id INTEGER,
file_path TEXT NOT NULL,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id))''')
# Testimonials table
c.execute('''CREATE TABLE IF NOT EXISTS testimonials
(id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
content TEXT NOT NULL,
author_name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
approved BOOLEAN DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id))''')
# Sessions table
c.execute('''CREATE TABLE IF NOT EXISTS sessions
(id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
session_token TEXT UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id))''')
# Admin sessions table
c.execute('''CREATE TABLE IF NOT EXISTS admin_sessions
(id INTEGER PRIMARY KEY AUTOINCREMENT,
session_token TEXT UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL)''')
# Contact submissions table
c.execute('''CREATE TABLE IF NOT EXISTS contact_submissions
(id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL,
message TEXT NOT NULL,
submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id))''')
conn.commit()
conn.close()
def rate_limit(max_per_minute=10, window_minutes=1):
"""Rate limiting decorator."""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
client_ip = get_client_ip()
now = time.time()
window_seconds = window_minutes * 60
if client_ip not in rate_limits:
rate_limits[client_ip] = []
rate_limits[client_ip] = [
timestamp for timestamp in rate_limits[client_ip]
if now - timestamp < window_seconds
]
if len(rate_limits[client_ip]) >= max_per_minute:
return jsonify({"error": "Rate limit exceeded"}), 429
rate_limits[client_ip].append(now)
return f(*args, **kwargs)
return decorated_function
return decorator
def sanitize_input(text, max_length=1000):
"""Sanitize user input."""
if not text or not isinstance(text, str):
return ""
text = re.sub(r'[<>]', '', text)
return text[:max_length].strip()
def validate_email(email):
"""Validate email format."""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def send_email(to_email, subject, body_html, body_text=None):
"""Send email via SMTP."""
if not SMTP_HOST or not SMTP_USER or not SMTP_PASS:
print(f"EMAIL NOT CONFIGURED: Would send to {to_email}: {subject}", file=sys.stderr)
return False
try:
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = SMTP_USER
msg['To'] = to_email
if body_text:
msg.attach(MIMEText(body_text, 'plain'))
msg.attach(MIMEText(body_html, 'html'))
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
server.starttls()
server.login(SMTP_USER, SMTP_PASS)
server.send_message(msg)
return True
except Exception as e:
print(f"Email send error: {e}", file=sys.stderr)
return False
def require_auth(f):
"""Require user authentication."""
@wraps(f)
def decorated_function(*args, **kwargs):
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token:
return jsonify({"error": "Authentication required"}), 401
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
user_id = payload.get('user_id')
# Verify session exists
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT user_id FROM sessions WHERE session_token = ? AND expires_at > ?',
(token, datetime.utcnow()))
session = c.fetchone()
conn.close()
if not session:
return jsonify({"error": "Invalid session"}), 401
request.user_id = user_id
return f(*args, **kwargs)
except jwt.ExpiredSignatureError:
return jsonify({"error": "Session expired"}), 401
except Exception as e:
return jsonify({"error": "Invalid token"}), 401
return decorated_function
def require_admin(f):
"""Require admin authentication."""
@wraps(f)
def decorated_function(*args, **kwargs):
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token:
return jsonify({"error": "Admin authentication required"}), 401
try:
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT id FROM admin_sessions WHERE session_token = ? AND expires_at > ?',
(token, datetime.utcnow()))
session = c.fetchone()
conn.close()
if not session:
return jsonify({"error": "Invalid admin session"}), 401
return f(*args, **kwargs)
except Exception as e:
return jsonify({"error": "Invalid admin token"}), 401
return decorated_function
# Initialize database on startup
init_db()
# ============================================================================
# API Endpoints
# ============================================================================
@app.route('/api/auth/register', methods=['POST'])
@rate_limit(max_per_minute=5, window_minutes=1)
def register():
"""Register a new user."""
try:
data = request.get_json()
email = sanitize_input(data.get('email', ''), max_length=255)
password = data.get('password', '')
name = sanitize_input(data.get('name', ''), max_length=255)
if not email or not password or not name:
return jsonify({"error": "Email, password, and name are required"}), 400
if not validate_email(email):
return jsonify({"error": "Invalid email format"}), 400
if len(password) < 8:
return jsonify({"error": "Password must be at least 8 characters"}), 400
password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
try:
c.execute('INSERT INTO users (email, password_hash, name, status) VALUES (?, ?, ?, ?)',
(email, password_hash, name, 'pending'))
user_id = c.lastrowid
conn.commit()
except sqlite3.IntegrityError:
conn.close()
return jsonify({"error": "Email already registered"}), 400
conn.close()
email_body = f"<h2>New Membership Signup</h2><p>Name: {name}</p><p>Email: {email}</p>"
send_email(ADMIN_EMAIL, f"New Membership Signup: {name}", email_body)
return jsonify({"message": "Registration successful. Pending approval.", "user_id": user_id}), 201
except Exception as e:
return jsonify({"error": "Registration failed"}), 500
@app.route('/api/auth/login', methods=['POST'])
@rate_limit(max_per_minute=5, window_minutes=1)
def login():
"""User login."""
try:
data = request.get_json()
email = sanitize_input(data.get('email', ''), max_length=255)
password = data.get('password', '')
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT id, password_hash, status FROM users WHERE email = ?', (email,))
user = c.fetchone()
conn.close()
if not user or not bcrypt.checkpw(password.encode('utf-8'), user[1].encode('utf-8')):
return jsonify({"error": "Invalid email or password"}), 401
if user[2] != 'approved':
return jsonify({"error": f"Membership status: {user[2]}"}), 403
token = jwt.encode({'user_id': user[0], 'exp': datetime.utcnow() + timedelta(days=30)}, JWT_SECRET, algorithm='HS256')
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('INSERT INTO sessions (user_id, session_token, expires_at) VALUES (?, ?, ?)',
(user[0], token, datetime.utcnow() + timedelta(days=30)))
conn.commit()
conn.close()
return jsonify({"token": token, "user_id": user[0], "message": "Login successful"}), 200
except Exception as e:
return jsonify({"error": "Login failed"}), 500
@app.route('/api/auth/logout', methods=['POST'])
@require_auth
def logout():
token = request.headers.get('Authorization', '').replace('Bearer ', '')
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('DELETE FROM sessions WHERE session_token = ?', (token,))
conn.commit()
conn.close()
return jsonify({"message": "Logged out"}), 200
@app.route('/api/auth/check', methods=['GET'])
def check_auth():
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token: return jsonify({"authenticated": False}), 200
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT u.id, u.email, u.name, u.status FROM users u JOIN sessions s ON u.id = s.user_id WHERE s.session_token = ? AND s.expires_at > ?', (token, datetime.utcnow()))
user = c.fetchone()
conn.close()
if user: return jsonify({"authenticated": True, "user_id": user[0], "email": user[1], "name": user[2], "status": user[3]}), 200
except: pass
return jsonify({"authenticated": False}), 200
@app.route('/api/download/count', methods=['GET'])
def get_download_count():
ip_address = get_client_ip()
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT COUNT(*) FROM downloads WHERE ip_address = ?', (ip_address,))
count = c.fetchone()[0]
conn.close()
return jsonify({"count": count, "limit": 1, "unlimited": False}), 200
@app.route('/api/download/track', methods=['POST'])
@rate_limit(max_per_minute=10, window_minutes=1)
def track_download():
data = request.get_json()
file_path = sanitize_input(data.get('file_path', ''), max_length=500)
ip_address = get_client_ip()
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('INSERT INTO downloads (ip_address, file_path) VALUES (?, ?)', (ip_address, file_path))
conn.commit()
conn.close()
return jsonify({"message": "Tracked"}), 200
@app.route('/api/testimonials', methods=['GET', 'POST'])
def handle_testimonials():
if request.method == 'GET':
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT id, content, author_name, created_at FROM testimonials WHERE approved = 1 ORDER BY created_at DESC')
rows = c.fetchall()
conn.close()
return jsonify({"testimonials": [{"id": r[0], "content": r[1], "author_name": r[2], "created_at": r[3]} for r in rows]}), 200
else:
@require_auth
def submit():
data = request.get_json()
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('INSERT INTO testimonials (user_id, content, author_name, approved) VALUES (?, ?, ?, 0)', (request.user_id, data.get('content'), data.get('author_name')))
conn.commit()
conn.close()
return jsonify({"message": "Submitted"}), 201
return submit()
@app.route('/api/contact', methods=['POST'])
@require_auth
def contact():
data = request.get_json()
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('INSERT INTO contact_submissions (user_id, name, email, message) VALUES (?, ?, ?, ?)', (request.user_id, data.get('name'), data.get('email'), data.get('message')))
conn.commit()
conn.close()
return jsonify({"message": "Sent"}), 200
@app.route('/api/admin/login', methods=['POST'])
def admin_login():
data = request.get_json()
if data.get('password') == ADMIN_PASSWORD:
token = jwt.encode({'admin': True, 'exp': datetime.utcnow() + timedelta(hours=24)}, JWT_SECRET, algorithm='HS256')
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('INSERT INTO admin_sessions (session_token, expires_at) VALUES (?, ?)', (token, datetime.utcnow() + timedelta(hours=24)))
conn.commit()
conn.close()
return jsonify({"token": token}), 200
return jsonify({"error": "Unauthorized"}), 401
@app.route('/api/admin/pending', methods=['GET'])
@require_admin
def admin_pending():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('SELECT id, email, name, created_at FROM users WHERE status = "pending"')
rows = c.fetchall()
conn.close()
return jsonify({"members": [{"id": r[0], "email": r[1], "name": r[2], "created_at": r[3]} for r in rows]}), 200
@app.route('/api/admin/approve', methods=['POST'])
@require_admin
def admin_approve():
user_id = request.get_json().get('user_id')
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('UPDATE users SET status = "approved" WHERE id = ?', (user_id,))
conn.commit()
conn.close()
return jsonify({"message": "Approved"}), 200
@app.route('/api/health', methods=['GET'])
def health():
return jsonify({"status":"healthy"}), 200
# ============================================================================
# Static File Serving (CATCH-ALL)
# ============================================================================
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def serve(path):
# Ignore API routes
if path.startswith('api/'):
return "Not Found", 404
full_path = os.path.join(STATIC_FOLDER, path)
# If path is a directory or empty, serve index.html
if path == "" or os.path.isdir(full_path):
# Check if the directory has an index.html
if os.path.exists(os.path.join(full_path, 'index.html')):
return send_from_directory(full_path, 'index.html')
# Otherwise, check if it's a pretty URL like /about
elif os.path.exists(os.path.join(STATIC_FOLDER, path, 'index.html')):
return send_from_directory(os.path.join(STATIC_FOLDER, path), 'index.html')
return send_from_directory(STATIC_FOLDER, '404.html'), 404
# If file exists, serve it
if os.path.exists(full_path):
return send_from_directory(STATIC_FOLDER, path)
# Handle Hugo's pretty URLs: /about -> /about/index.html
if os.path.exists(os.path.join(STATIC_FOLDER, path, 'index.html')):
return send_from_directory(os.path.join(STATIC_FOLDER, path), 'index.html')
# Default to 404
if os.path.exists(os.path.join(STATIC_FOLDER, '404.html')):
return send_from_directory(STATIC_FOLDER, '404.html'), 404
return "404 Not Found", 404
if __name__ == '__main__':
print(f"Starting Membership API + Static Server on port {PORT}")
app.run(host='0.0.0.0', port=PORT, debug=False)

42
scripts/start_hugo_server.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
#
# Hugo Server Startup Script
#
# This script starts the Hugo development server with production-like settings.
# It's designed to be run as a systemd service.
#
# Configuration:
# - Site directory: /root/hydro-sterile
# - Port: 1313 (Hugo default)
# - Bind: 0.0.0.0 (all interfaces)
# - Base URL: From hugo.yaml config
#
set -e
SITE_DIR="/root/hydro-sterile"
PORT="${HUGO_PORT:-7575}"
BIND="${HUGO_BIND:-0.0.0.0}"
BASEURL="${HUGO_BASEURL:-$(grep '^baseURL:' hugo.yaml | sed 's/.*: //' | tr -d "'\"")}"
cd "$SITE_DIR" || exit 1
# Check if Hugo is available via npm
if command -v npm >/dev/null 2>&1 && [ -d "node_modules/.bin" ]; then
HUGO_CMD="./node_modules/.bin/hugo"
elif command -v hugo >/dev/null 2>&1; then
HUGO_CMD="hugo"
else
echo "ERROR: Hugo not found. Please install Hugo or run 'npm install' in $SITE_DIR" >&2
exit 1
fi
# Start Hugo server
# Using --noHTTPCache for development, remove for production
exec "$HUGO_CMD" server \
--bind "$BIND" \
--port "$PORT" \
--baseURL "$BASEURL" \
--appendPort=false \
--disableFastRender