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:
112
scripts/CHAT_API_README.md
Normal file
112
scripts/CHAT_API_README.md
Normal 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
171
scripts/README.md
Normal 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`
|
||||
|
||||
BIN
scripts/__pycache__/chat_api.cpython-310.pyc
Normal file
BIN
scripts/__pycache__/chat_api.cpython-310.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/generate_blog_post.cpython-310.pyc
Normal file
BIN
scripts/__pycache__/generate_blog_post.cpython-310.pyc
Normal file
Binary file not shown.
BIN
scripts/__pycache__/membership_api.cpython-310.pyc
Normal file
BIN
scripts/__pycache__/membership_api.cpython-310.pyc
Normal file
Binary file not shown.
348
scripts/chat_api.py
Executable file
348
scripts/chat_api.py
Executable 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
320
scripts/generate_blog_post.py
Executable 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
478
scripts/membership_api.py
Normal 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
42
scripts/start_hugo_server.sh
Executable 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
|
||||
|
||||
Reference in New Issue
Block a user