Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
5b0b30d69c | |||
9022facac5 | |||
07725c99b4 | |||
05a184fcf7 | |||
13e61b7bef | |||
4c993ebacd | |||
9c1e6f0e94 | |||
9abdd18f33 | |||
b46ec98115 | |||
905e3c3977 | |||
6f2ecd9775 |
@ -3,9 +3,11 @@ name: Docker Build and Publish
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
tags: [ 'v*' ]
|
tags: [ 'v*.*.*' ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@ -20,11 +22,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: git.dws.rip/${{ github.repository }}
|
images: git.dws.rip/${{ github.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
type=sha,format=long
|
||||||
type=ref,event=pr
|
type=ref,event=pr
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=sha
|
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
- name: Login to Gitea Container Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,6 +10,7 @@ lib64
|
|||||||
uploads/
|
uploads/
|
||||||
thumbnails/
|
thumbnails/
|
||||||
images/
|
images/
|
||||||
|
static/
|
||||||
|
|
||||||
config.toml
|
config.toml
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
63
Makefile
Normal file
63
Makefile
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
.PHONY: build run clean run-docker stop-docker logs-docker
|
||||||
|
|
||||||
|
# Docker image details
|
||||||
|
IMAGE_NAME = git.dws.rip/dubey/spectra
|
||||||
|
TAG = main
|
||||||
|
|
||||||
|
# Local development settings
|
||||||
|
PYTHON = python3
|
||||||
|
PIP = pip3
|
||||||
|
PORT = 5000
|
||||||
|
|
||||||
|
build:
|
||||||
|
docker build -t $(IMAGE_NAME):$(TAG) .
|
||||||
|
|
||||||
|
run:
|
||||||
|
$(PYTHON) app.py
|
||||||
|
|
||||||
|
install:
|
||||||
|
$(PIP) install -r requirements.txt
|
||||||
|
|
||||||
|
clean:
|
||||||
|
find . -type d -name "__pycache__" -exec rm -r {} +
|
||||||
|
find . -type f -name "*.pyc" -delete
|
||||||
|
rm -rf thumbnails/*
|
||||||
|
rm -rf uploads/*
|
||||||
|
|
||||||
|
run-docker:
|
||||||
|
docker run -d \
|
||||||
|
--name spectra \
|
||||||
|
-p $(PORT):5000 \
|
||||||
|
-v $(PWD)/uploads:/app/uploads \
|
||||||
|
-v $(PWD)/thumbnails:/app/thumbnails \
|
||||||
|
-v $(PWD)/photos.db:/app/photos.db \
|
||||||
|
$(IMAGE_NAME):$(TAG)
|
||||||
|
|
||||||
|
run-docker-attached:
|
||||||
|
docker run -it \
|
||||||
|
--name spectra \
|
||||||
|
-p $(PORT):5000 \
|
||||||
|
-v $(PWD)/uploads:/app/uploads \
|
||||||
|
-v $(PWD)/thumbnails:/app/thumbnails \
|
||||||
|
-v $(PWD)/photos.db:/app/photos.db \
|
||||||
|
$(IMAGE_NAME):$(TAG)
|
||||||
|
|
||||||
|
stop-docker:
|
||||||
|
docker stop spectra
|
||||||
|
docker rm spectra
|
||||||
|
|
||||||
|
logs-docker:
|
||||||
|
docker logs -f spectra
|
||||||
|
|
||||||
|
rebuild: clean build run-docker
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Available commands:"
|
||||||
|
@echo " make build - Build Docker image"
|
||||||
|
@echo " make run - Run locally using Python"
|
||||||
|
@echo " make install - Install Python dependencies"
|
||||||
|
@echo " make clean - Remove cache files and generated content"
|
||||||
|
@echo " make run-docker - Run in Docker container"
|
||||||
|
@echo " make stop-docker - Stop and remove Docker container"
|
||||||
|
@echo " make logs-docker - View Docker container logs"
|
||||||
|
@echo " make rebuild - Clean, rebuild and run Docker container"
|
10
README.md
10
README.md
@ -114,4 +114,12 @@ spectra/
|
|||||||
|
|
||||||
- `FLASK_ENV`: Set to 'production' in production
|
- `FLASK_ENV`: Set to 'production' in production
|
||||||
- `WORKERS`: Number of Gunicorn workers (default: 4)
|
- `WORKERS`: Number of Gunicorn workers (default: 4)
|
||||||
- `PORT`: Override default port (default: 5000)
|
- `PORT`: Override default port (default: 5000)
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
To create a release:
|
||||||
|
- Create and push a tag: `git tag v1.0.0 && git push origin v1.0.0`
|
||||||
|
- Create a release in Gitea UI using that tag
|
||||||
|
- The workflow will build and push the Docker image with appropriate version tags
|
||||||
|
- The Docker image will be available at: `git.dws.rip/your-repo/image:v1.0.0`
|
||||||
|
775
app.py
775
app.py
@ -1,120 +1,91 @@
|
|||||||
from flask import Flask, request, jsonify, render_template, redirect, url_for, flash, session, send_from_directory
|
import atexit
|
||||||
from werkzeug.utils import secure_filename
|
|
||||||
from models import Session as DBSession, Photo
|
|
||||||
from config import load_or_create_config
|
|
||||||
import os
|
|
||||||
from datetime import datetime
|
|
||||||
from PIL import Image, ExifTags
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
|
||||||
import random
|
|
||||||
from colorthief import ColorThief
|
|
||||||
import colorsys
|
import colorsys
|
||||||
from steganography import embed_message, extract_message
|
|
||||||
import hashlib
|
import hashlib
|
||||||
from watchdog.observers import Observer
|
import os
|
||||||
from watchdog.events import FileSystemEventHandler
|
import random
|
||||||
import toml
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import atexit
|
from datetime import UTC
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from logging import getLogger
|
||||||
|
import logging
|
||||||
|
from logging.config import dictConfig
|
||||||
|
|
||||||
|
import toml
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from colorthief import ColorThief
|
||||||
|
from flask import (Flask, flash, g, jsonify, redirect, render_template,
|
||||||
|
request, send_from_directory, session, url_for)
|
||||||
from flask_limiter import Limiter
|
from flask_limiter import Limiter
|
||||||
from flask_limiter.util import get_remote_address
|
from flask_limiter.util import get_remote_address
|
||||||
import secrets
|
from PIL import ExifTags, Image
|
||||||
|
from watchdog.events import FileSystemEventHandler
|
||||||
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
app = Flask(__name__)
|
from models import Photo
|
||||||
app.secret_key = os.urandom(24)
|
from models import Session as DBSession
|
||||||
config = load_or_create_config()
|
from models import SiteConfig, init_db
|
||||||
|
from steganography import embed_message, extract_message
|
||||||
|
|
||||||
UPLOAD_FOLDER = config['directories']['upload']
|
# Add this function to handle secret key persistence
|
||||||
THUMBNAIL_FOLDER = config['directories']['thumbnail']
|
def get_or_create_secret_key():
|
||||||
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
"""Get existing secret key or create a new one"""
|
||||||
THUMBNAIL_SIZES = [256, 512, 768, 1024, 1536, 2048]
|
secret_key_file = Path("secret.key")
|
||||||
|
try:
|
||||||
# Create upload and thumbnail directories if they don't exist
|
if secret_key_file.exists():
|
||||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
logger.info("Loading existing secret key")
|
||||||
os.makedirs(THUMBNAIL_FOLDER, exist_ok=True)
|
return secret_key_file.read_bytes()
|
||||||
|
else:
|
||||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
logger.info("Generating new secret key")
|
||||||
app.config['THUMBNAIL_FOLDER'] = THUMBNAIL_FOLDER
|
secret_key = os.urandom(32) # Use 32 bytes for better security
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 80 * 1024 * 1024 # 80MB limit
|
secret_key_file.write_bytes(secret_key)
|
||||||
|
return secret_key
|
||||||
scheduler = BackgroundScheduler()
|
except Exception as e:
|
||||||
scheduler.start()
|
logger.error(f"Error handling secret key: {e}")
|
||||||
|
# Fallback to a memory-only key if file operations fail
|
||||||
|
return os.urandom(32)
|
||||||
|
|
||||||
DEFAULT_CONFIG = {
|
DEFAULT_CONFIG = {
|
||||||
'server': {
|
"server": {"host": "0.0.0.0", "port": 5000},
|
||||||
'host': '0.0.0.0',
|
"directories": {"upload": "uploads", "thumbnail": "thumbnails"},
|
||||||
'port': 5000
|
"admin": {"password": secrets.token_urlsafe(16)}, # Generate secure random password
|
||||||
},
|
|
||||||
'directories': {
|
|
||||||
'upload': 'uploads',
|
|
||||||
'thumbnail': 'thumbnails'
|
|
||||||
},
|
|
||||||
'admin': {
|
|
||||||
'password': 'changeme' # Default password
|
|
||||||
},
|
|
||||||
'appearance': {
|
|
||||||
'accent_color': '#007bff'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def merge_configs(default, user):
|
# Configure logging
|
||||||
"""Recursively merge user config with default config"""
|
dictConfig({
|
||||||
result = default.copy()
|
'version': 1,
|
||||||
for key, value in user.items():
|
'formatters': {
|
||||||
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
'default': {
|
||||||
result[key] = merge_configs(result[key], value)
|
'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
|
||||||
else:
|
}
|
||||||
result[key] = value
|
},
|
||||||
return result
|
'handlers': {
|
||||||
|
'console': {
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
'stream': 'ext://sys.stdout',
|
||||||
|
'formatter': 'default'
|
||||||
|
},
|
||||||
|
'file': {
|
||||||
|
'class': 'logging.FileHandler',
|
||||||
|
'filename': 'spectra.log',
|
||||||
|
'formatter': 'default'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'root': {
|
||||||
|
'level': 'INFO',
|
||||||
|
'handlers': ['console', 'file']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
class ConfigFileHandler(FileSystemEventHandler):
|
# Get logger for this module
|
||||||
def on_modified(self, event):
|
logger = getLogger(__name__)
|
||||||
if event.src_path.endswith('config.toml'):
|
|
||||||
global config
|
|
||||||
try:
|
|
||||||
new_config = load_or_create_config()
|
|
||||||
config.update(new_config)
|
|
||||||
app.logger.info("Configuration reloaded successfully")
|
|
||||||
except Exception as e:
|
|
||||||
app.logger.error(f"Error reloading configuration: {e}")
|
|
||||||
|
|
||||||
def load_or_create_config():
|
# Create Flask app with persistent secret key
|
||||||
config_path = 'config.toml'
|
app = Flask(__name__)
|
||||||
|
app.secret_key = get_or_create_secret_key()
|
||||||
try:
|
|
||||||
if os.path.exists(config_path):
|
|
||||||
with open(config_path, 'r') as f:
|
|
||||||
user_config = toml.load(f)
|
|
||||||
else:
|
|
||||||
user_config = {}
|
|
||||||
|
|
||||||
# Merge with defaults
|
|
||||||
final_config = merge_configs(DEFAULT_CONFIG, user_config)
|
|
||||||
|
|
||||||
# Save complete config back to file
|
|
||||||
with open(config_path, 'w') as f:
|
|
||||||
toml.dump(final_config, f)
|
|
||||||
|
|
||||||
return final_config
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
app.logger.error(f"Error loading config: {e}")
|
|
||||||
return DEFAULT_CONFIG.copy()
|
|
||||||
|
|
||||||
def start_config_watcher():
|
|
||||||
observer = Observer()
|
|
||||||
observer.schedule(ConfigFileHandler(), path='.', recursive=False)
|
|
||||||
observer.start()
|
|
||||||
|
|
||||||
# Register cleanup on app shutdown
|
|
||||||
def cleanup():
|
|
||||||
observer.stop()
|
|
||||||
observer.join()
|
|
||||||
|
|
||||||
atexit.register(cleanup)
|
|
||||||
|
|
||||||
start_config_watcher()
|
|
||||||
|
|
||||||
def allowed_file(filename):
|
def allowed_file(filename):
|
||||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
@ -151,166 +122,227 @@ def generate_thumbnails(filename):
|
|||||||
else:
|
else:
|
||||||
img.save(thumb_path, optimize=True, quality=85)
|
img.save(thumb_path, optimize=True, quality=85)
|
||||||
|
|
||||||
def generate_all_thumbnails():
|
def load_or_create_config():
|
||||||
for filename in os.listdir(UPLOAD_FOLDER):
|
"""Load config from file or create with defaults if missing"""
|
||||||
if allowed_file(filename):
|
config_path = Path("config.toml")
|
||||||
generate_thumbnails(filename)
|
logger.info(f"Loading config from {config_path}")
|
||||||
|
|
||||||
scheduler.add_job(generate_all_thumbnails, 'interval', minutes=5)
|
try:
|
||||||
scheduler.add_job(generate_all_thumbnails, 'date', run_date=datetime.now()) # Run once at startup
|
# If config doesn't exist or is empty, create it with defaults
|
||||||
|
if not config_path.exists() or config_path.stat().st_size == 0:
|
||||||
|
config_data = DEFAULT_CONFIG.copy()
|
||||||
|
with open(config_path, "w") as f:
|
||||||
|
toml.dump(config_data, f)
|
||||||
|
logger.info(f"Created new config file with defaults at {config_path}")
|
||||||
|
logger.warning("Please update the config file with your own values.")
|
||||||
|
return config_data
|
||||||
|
|
||||||
@app.route('/')
|
# Load existing config
|
||||||
|
with open(config_path, "r") as f:
|
||||||
|
config_data = toml.load(f)
|
||||||
|
logger.info(f"Loaded config from {config_path}")
|
||||||
|
|
||||||
|
# Verify required fields
|
||||||
|
required_sections = ["server", "directories", "admin"]
|
||||||
|
required_fields = {
|
||||||
|
"server": ["host", "port"],
|
||||||
|
"directories": ["upload", "thumbnail"],
|
||||||
|
"admin": ["password"],
|
||||||
|
}
|
||||||
|
|
||||||
|
for section in required_sections:
|
||||||
|
if section not in config_data:
|
||||||
|
raise ValueError(f"Missing required section: {section}")
|
||||||
|
for field in required_fields[section]:
|
||||||
|
if field not in config_data[section]:
|
||||||
|
raise ValueError(f"Missing required field: {section}.{field}")
|
||||||
|
|
||||||
|
logger.info("Config verification passed")
|
||||||
|
return config_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load config: {str(e)}")
|
||||||
|
raise RuntimeError(f"Failed to load config: {str(e)}")
|
||||||
|
|
||||||
|
def get_site_config():
|
||||||
|
"""Get the current site configuration"""
|
||||||
|
db_session = DBSession()
|
||||||
|
site_config = db_session.query(SiteConfig).first()
|
||||||
|
if not site_config:
|
||||||
|
site_config = SiteConfig()
|
||||||
|
db_session.add(site_config)
|
||||||
|
db_session.commit()
|
||||||
|
config_dict = {
|
||||||
|
"appearance": {
|
||||||
|
"accent_color": site_config.accent_color,
|
||||||
|
"site_title": site_config.site_title,
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"name": site_config.author_name,
|
||||||
|
"location": site_config.author_location,
|
||||||
|
"profile_image": site_config.profile_image,
|
||||||
|
"bio": site_config.bio,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
db_session.close()
|
||||||
|
return config_dict
|
||||||
|
|
||||||
|
def init_app_config():
|
||||||
|
"""Initialize application configuration"""
|
||||||
|
# Load hard config from file
|
||||||
|
hard_config = load_or_create_config()
|
||||||
|
|
||||||
|
# Load site config from database
|
||||||
|
site_config = get_site_config()
|
||||||
|
|
||||||
|
# Merge configs
|
||||||
|
config = hard_config.copy()
|
||||||
|
config.update(site_config)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
# Initialize config before any routes
|
||||||
|
config = init_app_config()
|
||||||
|
|
||||||
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||||||
|
|
||||||
|
UPLOAD_FOLDER = config["directories"]["upload"]
|
||||||
|
THUMBNAIL_FOLDER = config["directories"]["thumbnail"]
|
||||||
|
ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "gif"}
|
||||||
|
THUMBNAIL_SIZES = [256, 512, 768, 1024, 1536, 2048]
|
||||||
|
|
||||||
|
# Create upload and thumbnail directories if they don't exist
|
||||||
|
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||||
|
os.makedirs(THUMBNAIL_FOLDER, exist_ok=True)
|
||||||
|
|
||||||
|
app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
|
||||||
|
app.config["THUMBNAIL_FOLDER"] = THUMBNAIL_FOLDER
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = 80 * 1024 * 1024 # 80MB limit
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
limiter = Limiter(
|
||||||
|
app=app,
|
||||||
|
key_func=get_remote_address,
|
||||||
|
default_limits=["100 per minute"],
|
||||||
|
storage_uri="memory://",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def before_request():
|
||||||
|
g.csp_nonce = secrets.token_hex(16)
|
||||||
|
|
||||||
|
# Update the security headers middleware
|
||||||
|
@app.after_request
|
||||||
|
def add_security_headers(response):
|
||||||
|
nonce = g.csp_nonce # Use the nonce from the before_request hook
|
||||||
|
|
||||||
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
|
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||||
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
|
response.headers["Strict-Transport-Security"] = (
|
||||||
|
"max-age=31536000; includeSubDomains"
|
||||||
|
)
|
||||||
|
response.headers["Content-Security-Policy"] = (
|
||||||
|
f"default-src 'self'; "
|
||||||
|
f"script-src 'self' 'nonce-{nonce}' https://cdn.jsdelivr.net; "
|
||||||
|
f"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||||
|
f"font-src 'self' https://fonts.gstatic.com; "
|
||||||
|
f"img-src 'self' data:; "
|
||||||
|
f"connect-src 'self';"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make nonce available to templates
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Public routes
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template('index.html', accent_color=config['appearance']['accent_color'])
|
return render_template(
|
||||||
|
"index.html",
|
||||||
|
accent_color=config["appearance"]["accent_color"],
|
||||||
|
site_title=config["appearance"]["site_title"],
|
||||||
|
about=config["about"],
|
||||||
|
nonce=g.csp_nonce,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/api/images')
|
# ----------------------------------------------------------------
|
||||||
|
# API routes
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
@app.route("/api/images")
|
||||||
def get_images():
|
def get_images():
|
||||||
page = int(request.args.get('page', 1))
|
page = int(request.args.get("page", 1))
|
||||||
per_page = 20
|
per_page = 20
|
||||||
db_session = DBSession()
|
db_session = DBSession()
|
||||||
photos = db_session.query(Photo).order_by(Photo.date_taken.desc()).offset((page - 1) * per_page).limit(per_page).all()
|
photos = (
|
||||||
|
db_session.query(Photo)
|
||||||
|
.order_by(Photo.date_taken.desc())
|
||||||
|
.offset((page - 1) * per_page)
|
||||||
|
.limit(per_page)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
images = []
|
images = []
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
factor = random.randint(2, 3)
|
factor = random.randint(2, 3)
|
||||||
if photo.height < 4000 or photo.width < 4000:
|
if photo.height < 4000 and photo.width < 4000:
|
||||||
factor = 1
|
factor = 1
|
||||||
if photo.orientation == 6 or photo.orientation == 8:
|
if photo.orientation == 6 or photo.orientation == 8:
|
||||||
width, height = photo.height, photo.width
|
width, height = photo.height, photo.width
|
||||||
else:
|
else:
|
||||||
width, height = photo.width, photo.height
|
width, height = photo.width, photo.height
|
||||||
images.append({
|
images.append(
|
||||||
'imgSrc': f'/static/thumbnails/{os.path.splitext(photo.input_filename)[0]}/1536_{photo.input_filename}',
|
{
|
||||||
'width': width / factor,
|
"imgSrc": f"/static/thumbnails/{os.path.splitext(photo.input_filename)[0]}/1536_{photo.input_filename}",
|
||||||
'height': height / factor,
|
"width": width / factor,
|
||||||
'caption': photo.input_filename,
|
"height": height / factor,
|
||||||
'date': photo.date_taken.strftime('%y %m %d'),
|
"caption": photo.input_filename,
|
||||||
'technicalInfo': f"{photo.focal_length}MM | F/{photo.aperture} | {photo.shutter_speed} | ISO{photo.iso}",
|
"date": photo.date_taken.strftime("%y %m %d"),
|
||||||
'highlightColor': photo.highlight_color
|
"technicalInfo": f"{photo.focal_length}MM | F/{photo.aperture} | {photo.shutter_speed} | ISO{photo.iso}",
|
||||||
})
|
"highlightColor": photo.highlight_color,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
has_more = db_session.query(Photo).count() > page * per_page
|
has_more = db_session.query(Photo).count() > page * per_page
|
||||||
db_session.close()
|
db_session.close()
|
||||||
|
|
||||||
return jsonify({'images': images, 'hasMore': has_more})
|
|
||||||
|
|
||||||
@app.route('/admin')
|
return jsonify({"images": images, "hasMore": has_more})
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Admin routes
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
@app.route("/admin")
|
||||||
def admin():
|
def admin():
|
||||||
if 'logged_in' not in session:
|
if "logged_in" not in session:
|
||||||
return redirect(url_for('admin_login'))
|
return redirect(url_for("admin_login"))
|
||||||
|
|
||||||
db_session = DBSession()
|
db_session = DBSession()
|
||||||
photos = db_session.query(Photo).order_by(Photo.date_taken.desc()).all()
|
photos = db_session.query(Photo).order_by(Photo.date_taken.desc()).all()
|
||||||
db_session.close()
|
db_session.close()
|
||||||
|
|
||||||
return render_template('admin.html', photos=photos, accent_color=config['appearance']['accent_color'])
|
|
||||||
|
|
||||||
@app.route('/admin/login', methods=['GET', 'POST'])
|
# Pass the full config to the template
|
||||||
def admin_login():
|
return render_template(
|
||||||
if request.method == 'POST':
|
"admin.html",
|
||||||
if request.form['password'] == config['admin']['password']:
|
photos=photos,
|
||||||
session['logged_in'] = True
|
accent_color=config["appearance"]["accent_color"],
|
||||||
return redirect(url_for('admin'))
|
config=config,
|
||||||
else:
|
)
|
||||||
flash('Invalid password')
|
|
||||||
return render_template('admin_login.html', accent_color=config['appearance']['accent_color'])
|
|
||||||
|
|
||||||
@app.route('/admin/upload', methods=['POST'])
|
@app.route("/admin/logout")
|
||||||
def admin_upload():
|
|
||||||
if 'logged_in' not in session:
|
|
||||||
return redirect(url_for('admin_login'))
|
|
||||||
|
|
||||||
if 'file' not in request.files:
|
|
||||||
flash('No file part')
|
|
||||||
return redirect(url_for('admin'))
|
|
||||||
|
|
||||||
file = request.files['file']
|
|
||||||
if file.filename == '':
|
|
||||||
flash('No selected file')
|
|
||||||
return redirect(url_for('admin'))
|
|
||||||
|
|
||||||
if file and allowed_file(file.filename):
|
|
||||||
filename = secure_filename(file.filename)
|
|
||||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
|
||||||
file.save(file_path)
|
|
||||||
|
|
||||||
# Extract EXIF data
|
|
||||||
exif = None
|
|
||||||
exifraw = None
|
|
||||||
with Image.open(file_path) as img:
|
|
||||||
exifraw = img.info['exif']
|
|
||||||
width, height = img.size
|
|
||||||
exif = {
|
|
||||||
ExifTags.TAGS[k]: v
|
|
||||||
for k, v in img._getexif().items()
|
|
||||||
if k in ExifTags.TAGS
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generate a unique key for the image
|
|
||||||
unique_key = hashlib.sha256(f"{filename}{datetime.now().isoformat()}".encode()).hexdigest()[:16]
|
|
||||||
|
|
||||||
# Embed the unique key into the image
|
|
||||||
try:
|
|
||||||
embed_message(file_path, unique_key, exifraw)
|
|
||||||
except ValueError as e:
|
|
||||||
flash(f"Error embedding key: {str(e)}")
|
|
||||||
os.remove(file_path)
|
|
||||||
return redirect(url_for('admin'))
|
|
||||||
|
|
||||||
|
|
||||||
# Generate thumbnails
|
|
||||||
generate_thumbnails(filename)
|
|
||||||
|
|
||||||
# Get image dimensions
|
|
||||||
with Image.open(file_path) as img:
|
|
||||||
width, height = img.size
|
|
||||||
|
|
||||||
exposure_time = exif['ExposureTime']
|
|
||||||
if isinstance(exposure_time, tuple):
|
|
||||||
exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}"
|
|
||||||
else:
|
|
||||||
exposure_fraction = f"1/{int(1/float(exposure_time))}"
|
|
||||||
|
|
||||||
# Create database entry
|
|
||||||
db_session = DBSession()
|
|
||||||
new_photo = Photo(
|
|
||||||
input_filename=filename,
|
|
||||||
thumbnail_filename=f"{os.path.splitext(filename)[0]}/256_{filename}",
|
|
||||||
focal_length=str(exif.get('FocalLengthIn35mmFilm', exif.get('FocalLength', ''))),
|
|
||||||
aperture=str(exif.get('FNumber', '')),
|
|
||||||
shutter_speed=exposure_fraction,
|
|
||||||
date_taken=datetime.strptime(str(exif.get('DateTime', '1970:01:01 00:00:00')), '%Y:%m:%d %H:%M:%S'),
|
|
||||||
iso=int(exif.get('ISOSpeedRatings', 0)),
|
|
||||||
orientation=int(exif.get('Orientation', 1)),
|
|
||||||
width=width,
|
|
||||||
height=height,
|
|
||||||
highlight_color=get_highlight_color(THUMBNAIL_FOLDER + f"/{os.path.splitext(filename)[0]}/256_{filename}"),
|
|
||||||
unique_key=unique_key
|
|
||||||
)
|
|
||||||
db_session.add(new_photo)
|
|
||||||
db_session.commit()
|
|
||||||
db_session.close()
|
|
||||||
|
|
||||||
flash('File uploaded successfully')
|
|
||||||
return redirect(url_for('admin'))
|
|
||||||
|
|
||||||
flash('Invalid file type')
|
|
||||||
return redirect(url_for('admin'))
|
|
||||||
|
|
||||||
@app.route('/admin/logout')
|
|
||||||
def admin_logout():
|
def admin_logout():
|
||||||
session.pop('logged_in', None)
|
session.pop("logged_in", None)
|
||||||
flash('You have been logged out')
|
flash("You have been logged out")
|
||||||
return redirect(url_for('admin_login'))
|
return redirect(url_for("admin_login"))
|
||||||
|
|
||||||
@app.route('/static/thumbnails/<path:filename>')
|
@app.route("/admin/update_photo/<int:photo_id>", methods=["POST"])
|
||||||
def serve_thumbnail(filename):
|
|
||||||
return send_from_directory(THUMBNAIL_FOLDER, filename)
|
|
||||||
|
|
||||||
@app.route('/admin/update_photo/<int:photo_id>', methods=['POST'])
|
|
||||||
def update_photo(photo_id):
|
def update_photo(photo_id):
|
||||||
if 'logged_in' not in session:
|
if "logged_in" not in session:
|
||||||
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
return jsonify({"success": False, "error": "Not logged in"}), 401
|
||||||
|
|
||||||
data = request.json
|
data = request.json
|
||||||
db_session = DBSession()
|
db_session = DBSession()
|
||||||
@ -318,35 +350,35 @@ def update_photo(photo_id):
|
|||||||
|
|
||||||
if not photo:
|
if not photo:
|
||||||
db_session.close()
|
db_session.close()
|
||||||
return jsonify({'success': False, 'error': 'Photo not found'}), 404
|
return jsonify({"success": False, "error": "Photo not found"}), 404
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for field, value in data.items():
|
for field, value in data.items():
|
||||||
if field == 'date_taken':
|
if field == "date_taken":
|
||||||
value = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
|
value = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||||||
elif field == 'iso':
|
elif field == "iso":
|
||||||
value = int(value)
|
value = int(value)
|
||||||
setattr(photo, field, value)
|
setattr(photo, field, value)
|
||||||
|
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
db_session.close()
|
db_session.close()
|
||||||
return jsonify({'success': True})
|
return jsonify({"success": True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db_session.rollback()
|
db_session.rollback()
|
||||||
db_session.close()
|
db_session.close()
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/admin/delete_photo/<int:photo_id>', methods=['POST'])
|
@app.route("/admin/delete_photo/<int:photo_id>", methods=["POST"])
|
||||||
def delete_photo(photo_id):
|
def delete_photo(photo_id):
|
||||||
if 'logged_in' not in session:
|
if "logged_in" not in session:
|
||||||
return jsonify({'success': False, 'error': 'Not logged in'}), 401
|
return jsonify({"success": False, "error": "Not logged in"}), 401
|
||||||
|
|
||||||
db_session = DBSession()
|
db_session = DBSession()
|
||||||
photo = db_session.query(Photo).get(photo_id)
|
photo = db_session.query(Photo).get(photo_id)
|
||||||
|
|
||||||
if not photo:
|
if not photo:
|
||||||
db_session.close()
|
db_session.close()
|
||||||
return jsonify({'success': False, 'error': 'Photo not found'}), 404
|
return jsonify({"success": False, "error": "Photo not found"}), 404
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Delete the original file
|
# Delete the original file
|
||||||
@ -355,7 +387,9 @@ def delete_photo(photo_id):
|
|||||||
os.remove(original_path)
|
os.remove(original_path)
|
||||||
|
|
||||||
# Delete the thumbnail directory
|
# Delete the thumbnail directory
|
||||||
thumb_dir = os.path.join(THUMBNAIL_FOLDER, os.path.splitext(photo.input_filename)[0])
|
thumb_dir = os.path.join(
|
||||||
|
THUMBNAIL_FOLDER, os.path.splitext(photo.input_filename)[0]
|
||||||
|
)
|
||||||
if os.path.exists(thumb_dir):
|
if os.path.exists(thumb_dir):
|
||||||
for thumb_file in os.listdir(thumb_dir):
|
for thumb_file in os.listdir(thumb_dir):
|
||||||
os.remove(os.path.join(thumb_dir, thumb_file))
|
os.remove(os.path.join(thumb_dir, thumb_file))
|
||||||
@ -366,149 +400,208 @@ def delete_photo(photo_id):
|
|||||||
db_session.commit()
|
db_session.commit()
|
||||||
db_session.close()
|
db_session.close()
|
||||||
|
|
||||||
return jsonify({'success': True})
|
return jsonify({"success": True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db_session.rollback()
|
db_session.rollback()
|
||||||
db_session.close()
|
db_session.close()
|
||||||
return jsonify({'success': False, 'error': str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/verify/<filename>', methods=['GET'])
|
@app.route("/verify/<filename>", methods=["GET"])
|
||||||
def verify_image(filename):
|
def verify_image(filename):
|
||||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
file_path = os.path.join(app.config["UPLOAD_FOLDER"], filename)
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
return jsonify({'verified': False, 'error': 'Image not found'})
|
return jsonify({"verified": False, "error": "Image not found"})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
extracted_key = extract_message(file_path, 16)
|
extracted_key = extract_message(file_path, 16)
|
||||||
db_session = DBSession()
|
db_session = DBSession()
|
||||||
photo = db_session.query(Photo).filter_by(input_filename=filename).first()
|
photo = db_session.query(Photo).filter_by(input_filename=filename).first()
|
||||||
db_session.close()
|
db_session.close()
|
||||||
|
|
||||||
if photo and photo.unique_key == extracted_key:
|
if photo and photo.unique_key == extracted_key:
|
||||||
return jsonify({'verified': True, 'message': 'Image ownership verified'})
|
return jsonify({"verified": True, "message": "Image ownership verified"})
|
||||||
else:
|
else:
|
||||||
return jsonify({'verified': False, 'message': 'Image ownership could not be verified'})
|
return jsonify(
|
||||||
|
{"verified": False, "message": "Image ownership could not be verified"}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'verified': False, 'error': str(e)})
|
return jsonify({"verified": False, "error": str(e)})
|
||||||
|
|
||||||
limiter = Limiter(
|
|
||||||
app=app,
|
|
||||||
key_func=get_remote_address,
|
|
||||||
default_limits=["100 per minute"],
|
|
||||||
storage_uri="memory://"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate a strong secret key at startup
|
|
||||||
app.secret_key = secrets.token_hex(32)
|
|
||||||
|
|
||||||
# Add security headers middleware
|
|
||||||
@app.after_request
|
|
||||||
def add_security_headers(response):
|
|
||||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
||||||
response.headers['X-Frame-Options'] = 'SAMEORIGIN'
|
|
||||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
|
||||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
|
||||||
response.headers['Content-Security-Policy'] = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline';"
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Add rate limiting to sensitive endpoints
|
# Add rate limiting to sensitive endpoints
|
||||||
@app.route('/admin/login', methods=['POST'])
|
@app.route("/admin/login", methods=["POST", "GET"])
|
||||||
@limiter.limit("5 per minute")
|
@limiter.limit("5 per minute")
|
||||||
def admin_login():
|
def admin_login():
|
||||||
if request.method == 'POST':
|
if request.method == "POST":
|
||||||
if request.form['password'] == config['admin']['password']:
|
if request.form["password"] == config["admin"]["password"]:
|
||||||
session['logged_in'] = True
|
session["logged_in"] = True
|
||||||
return redirect(url_for('admin'))
|
return redirect(url_for("admin"))
|
||||||
else:
|
else:
|
||||||
flash('Invalid password')
|
flash("Invalid password")
|
||||||
return render_template('admin_login.html', accent_color=config['appearance']['accent_color'])
|
return render_template(
|
||||||
|
"admin_login.html", accent_color=config["appearance"]["accent_color"]
|
||||||
|
)
|
||||||
|
|
||||||
@app.route('/admin/upload', methods=['POST'])
|
@app.route("/admin/upload", methods=["POST"])
|
||||||
@limiter.limit("10 per minute")
|
@limiter.limit("10 per minute")
|
||||||
def admin_upload():
|
def admin_upload():
|
||||||
if 'logged_in' not in session:
|
if "logged_in" not in session:
|
||||||
return redirect(url_for('admin_login'))
|
return redirect(url_for("admin_login"))
|
||||||
|
|
||||||
if 'file' not in request.files:
|
if "file" not in request.files:
|
||||||
flash('No file part')
|
flash("No file part")
|
||||||
return redirect(url_for('admin'))
|
return redirect(url_for("admin"))
|
||||||
|
|
||||||
file = request.files['file']
|
file = request.files["file"]
|
||||||
if file.filename == '':
|
if file.filename == "":
|
||||||
flash('No selected file')
|
flash("No selected file")
|
||||||
return redirect(url_for('admin'))
|
return redirect(url_for("admin"))
|
||||||
|
|
||||||
if file and allowed_file(file.filename):
|
if file and allowed_file(file.filename):
|
||||||
filename = secure_filename(file.filename)
|
filename = secure_filename(file.filename)
|
||||||
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
file_path = os.path.join(app.config["UPLOAD_FOLDER"], filename)
|
||||||
file.save(file_path)
|
file.save(file_path)
|
||||||
|
|
||||||
# Extract EXIF data
|
# Extract EXIF data with error handling
|
||||||
exif = None
|
exif = {}
|
||||||
exifraw = None
|
exifraw = None
|
||||||
with Image.open(file_path) as img:
|
width = height = 0
|
||||||
exifraw = img.info['exif']
|
try:
|
||||||
width, height = img.size
|
with Image.open(file_path) as img:
|
||||||
exif = {
|
width, height = img.size
|
||||||
ExifTags.TAGS[k]: v
|
if hasattr(img, '_getexif') and img._getexif() is not None:
|
||||||
for k, v in img._getexif().items()
|
exifraw = img.info.get("exif")
|
||||||
if k in ExifTags.TAGS
|
exif = {
|
||||||
}
|
ExifTags.TAGS[k]: v
|
||||||
|
for k, v in img._getexif().items()
|
||||||
|
if k in ExifTags.TAGS
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error reading EXIF data for {filename}: {str(e)}")
|
||||||
|
|
||||||
# Generate a unique key for the image
|
# Generate a unique key for the image
|
||||||
unique_key = hashlib.sha256(f"{filename}{datetime.now().isoformat()}".encode()).hexdigest()[:16]
|
unique_key = hashlib.sha256(
|
||||||
|
f"{filename}{datetime.now().isoformat()}".encode()
|
||||||
|
).hexdigest()[:16]
|
||||||
|
|
||||||
# Embed the unique key into the image
|
# Embed the unique key into the image
|
||||||
try:
|
try:
|
||||||
embed_message(file_path, unique_key, exifraw)
|
embed_message(file_path, unique_key, exifraw)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
flash(f"Error embedding key: {str(e)}")
|
flash(f"Error embedding key: {str(e)}")
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
return redirect(url_for('admin'))
|
return redirect(url_for("admin"))
|
||||||
|
|
||||||
|
|
||||||
# Generate thumbnails
|
# Generate thumbnails
|
||||||
generate_thumbnails(filename)
|
generate_thumbnails(filename)
|
||||||
|
|
||||||
# Get image dimensions
|
|
||||||
with Image.open(file_path) as img:
|
|
||||||
width, height = img.size
|
|
||||||
|
|
||||||
exposure_time = exif['ExposureTime']
|
# Handle exposure time with error handling
|
||||||
if isinstance(exposure_time, tuple):
|
try:
|
||||||
exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}"
|
exposure_time = exif.get("ExposureTime", 0)
|
||||||
else:
|
if isinstance(exposure_time, tuple):
|
||||||
exposure_fraction = f"1/{int(1/float(exposure_time))}"
|
exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}"
|
||||||
|
else:
|
||||||
|
exposure_fraction = f"1/{int(1/float(exposure_time))}" if exposure_time else "0"
|
||||||
|
except (TypeError, ZeroDivisionError):
|
||||||
|
exposure_fraction = "0"
|
||||||
|
|
||||||
# Create database entry
|
# Create database entry with safe defaults
|
||||||
db_session = DBSession()
|
db_session = DBSession()
|
||||||
new_photo = Photo(
|
new_photo = Photo(
|
||||||
input_filename=filename,
|
input_filename=filename,
|
||||||
thumbnail_filename=f"{os.path.splitext(filename)[0]}/256_{filename}",
|
thumbnail_filename=f"{os.path.splitext(filename)[0]}/256_{filename}",
|
||||||
focal_length=str(exif.get('FocalLengthIn35mmFilm', exif.get('FocalLength', ''))),
|
focal_length=str(
|
||||||
aperture=str(exif.get('FNumber', '')),
|
exif.get("FocalLengthIn35mmFilm", exif.get("FocalLength", "0"))
|
||||||
|
),
|
||||||
|
aperture=str(exif.get("FNumber", "0")),
|
||||||
shutter_speed=exposure_fraction,
|
shutter_speed=exposure_fraction,
|
||||||
date_taken=datetime.strptime(str(exif.get('DateTime', '1970:01:01 00:00:00')), '%Y:%m:%d %H:%M:%S'),
|
date_taken=datetime.strptime(
|
||||||
iso=int(exif.get('ISOSpeedRatings', 0)),
|
str(exif.get("DateTime", "1970:01:01 00:00:00")), "%Y:%m:%d %H:%M:%S"
|
||||||
orientation=int(exif.get('Orientation', 1)),
|
),
|
||||||
|
iso=int(exif.get("ISOSpeedRatings", 0)),
|
||||||
|
orientation=int(exif.get("Orientation", 1)),
|
||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
highlight_color=get_highlight_color(THUMBNAIL_FOLDER + f"/{os.path.splitext(filename)[0]}/256_{filename}"),
|
highlight_color=get_highlight_color(
|
||||||
unique_key=unique_key
|
THUMBNAIL_FOLDER + f"/{os.path.splitext(filename)[0]}/256_{filename}"
|
||||||
|
),
|
||||||
|
unique_key=unique_key,
|
||||||
)
|
)
|
||||||
db_session.add(new_photo)
|
db_session.add(new_photo)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
db_session.close()
|
db_session.close()
|
||||||
|
|
||||||
flash('File uploaded successfully')
|
|
||||||
return redirect(url_for('admin'))
|
|
||||||
|
|
||||||
flash('Invalid file type')
|
|
||||||
return redirect(url_for('admin'))
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
flash("File uploaded successfully")
|
||||||
app.run(
|
return redirect(url_for("admin"))
|
||||||
debug=True,
|
|
||||||
port=config['server']['port'],
|
flash("Invalid file type")
|
||||||
host=config['server']['host']
|
return redirect(url_for("admin"))
|
||||||
|
|
||||||
|
# Add a new route to handle config updates
|
||||||
|
@app.route("/admin/update_config", methods=["POST"])
|
||||||
|
def update_config():
|
||||||
|
if "logged_in" not in session:
|
||||||
|
return jsonify({"success": False, "error": "Not logged in"}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = request.json
|
||||||
|
db_session = DBSession()
|
||||||
|
site_config = db_session.query(SiteConfig).first()
|
||||||
|
|
||||||
|
# Map form fields to SiteConfig attributes
|
||||||
|
field_mapping = {
|
||||||
|
"appearance.site_title": "site_title",
|
||||||
|
"appearance.accent_color": "accent_color",
|
||||||
|
"about.name": "author_name",
|
||||||
|
"about.location": "author_location",
|
||||||
|
"about.profile_image": "profile_image",
|
||||||
|
"about.bio": "bio",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update site config
|
||||||
|
for form_field, db_field in field_mapping.items():
|
||||||
|
if form_field in data:
|
||||||
|
setattr(site_config, db_field, data[form_field])
|
||||||
|
|
||||||
|
|
||||||
|
site_config.updated_at = datetime.now(UTC)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.close()
|
||||||
|
# Reload config
|
||||||
|
site_config = get_site_config()
|
||||||
|
config.update(site_config)
|
||||||
|
return jsonify({"success": True})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
# Add a new endpoint to view config history
|
||||||
|
@app.route("/admin/config_history")
|
||||||
|
def config_history():
|
||||||
|
if "logged_in" not in session:
|
||||||
|
return redirect(url_for("admin_login"))
|
||||||
|
|
||||||
|
db_session = DBSession()
|
||||||
|
site_configs = (
|
||||||
|
db_session.query(SiteConfig).order_by(SiteConfig.updated_at.desc()).all()
|
||||||
)
|
)
|
||||||
|
db_session.close()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"config_history.html",
|
||||||
|
site_configs=site_configs,
|
||||||
|
accent_color=config["appearance"]["accent_color"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Static routes
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
@app.route("/static/thumbnails/<path:filename>")
|
||||||
|
def serve_thumbnail(filename):
|
||||||
|
return send_from_directory(THUMBNAIL_FOLDER, filename)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize database tables
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=False, port=config["server"]["port"], host=config["server"]["host"])
|
||||||
|
@ -12,6 +12,7 @@ password = "changeme"
|
|||||||
|
|
||||||
[appearance]
|
[appearance]
|
||||||
accent_color = "#007bff"
|
accent_color = "#007bff"
|
||||||
|
site_title = "Spectra"
|
||||||
|
|
||||||
[security]
|
[security]
|
||||||
# Add these security settings
|
# Add these security settings
|
||||||
|
28
config.py
28
config.py
@ -1,32 +1,24 @@
|
|||||||
import toml
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
CONFIG_FILE = 'config.toml'
|
import toml
|
||||||
|
|
||||||
|
CONFIG_FILE = "config.toml"
|
||||||
|
|
||||||
|
|
||||||
def load_or_create_config():
|
def load_or_create_config():
|
||||||
if not os.path.exists(CONFIG_FILE):
|
if not os.path.exists(CONFIG_FILE):
|
||||||
admin_password = secrets.token_urlsafe(16)
|
admin_password = secrets.token_urlsafe(16)
|
||||||
config = {
|
config = {
|
||||||
'admin': {
|
"admin": {"password": admin_password},
|
||||||
'password': admin_password
|
"directories": {"upload": "uploads", "thumbnail": "thumbnails"},
|
||||||
},
|
"appearance": {"accent_color": "#ff6600"},
|
||||||
'directories': {
|
"server": {"host": "0.0.0.0", "port": 5002},
|
||||||
'upload': 'uploads',
|
|
||||||
'thumbnail': 'thumbnails'
|
|
||||||
},
|
|
||||||
'appearance': {
|
|
||||||
'accent_color': '#ff6600'
|
|
||||||
},
|
|
||||||
'server': {
|
|
||||||
'host': '0.0.0.0',
|
|
||||||
'port': 5002
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
with open(CONFIG_FILE, 'w') as f:
|
with open(CONFIG_FILE, "w") as f:
|
||||||
toml.dump(config, f)
|
toml.dump(config, f)
|
||||||
print(f"Generated new config file with admin password: {admin_password}")
|
print(f"Generated new config file with admin password: {admin_password}")
|
||||||
else:
|
else:
|
||||||
with open(CONFIG_FILE, 'r') as f:
|
with open(CONFIG_FILE, "r") as f:
|
||||||
config = toml.load(f)
|
config = toml.load(f)
|
||||||
return config
|
return config
|
||||||
|
@ -2,33 +2,21 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: .
|
image: git.dws.rip/dws/spectra:main
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.photos.rule=Host(`photos.dws.rip`)"
|
|
||||||
- "traefik.http.services.photos.loadbalancer.server.port=5000"
|
|
||||||
- "traefik.http.routers.photos.tls=true"
|
|
||||||
- "traefik.http.routers.photos.tls.certresolver=default"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
- ./thumbnails:/app/thumbnails
|
- ./thumbnails:/app/thumbnails
|
||||||
- ./config.toml:/app/config.toml
|
- ./config.toml:/app/config.toml
|
||||||
- ./photos.db:/app/photos.db
|
- ./photos.db:/app/photos.db
|
||||||
|
- ./static:/app/static
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
- WORKERS=4
|
- WORKERS=4
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
|
||||||
- traefik-public
|
|
||||||
- default
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
networks:
|
|
||||||
traefik-public:
|
|
||||||
external: true
|
|
56
models.py
56
models.py
@ -1,11 +1,15 @@
|
|||||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Float
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (Column, DateTime, Float, Integer, String, Text,
|
||||||
|
create_engine)
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
class Photo(Base):
|
class Photo(Base):
|
||||||
__tablename__ = 'photos'
|
__tablename__ = "photos"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
input_filename = Column(String, nullable=False)
|
input_filename = Column(String, nullable=False)
|
||||||
@ -19,8 +23,50 @@ class Photo(Base):
|
|||||||
height = Column(Integer)
|
height = Column(Integer)
|
||||||
highlight_color = Column(String)
|
highlight_color = Column(String)
|
||||||
orientation = Column(Integer)
|
orientation = Column(Integer)
|
||||||
unique_key = Column(String(16), nullable=False) # Add this line
|
unique_key = Column(String(16), nullable=False)
|
||||||
|
|
||||||
engine = create_engine('sqlite:///photos.db')
|
|
||||||
Base.metadata.create_all(engine)
|
class SiteConfig(Base):
|
||||||
|
__tablename__ = "site_config"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
# Appearance
|
||||||
|
site_title = Column(String, nullable=False, default="Spectra")
|
||||||
|
accent_color = Column(String, nullable=False, default="#007bff")
|
||||||
|
|
||||||
|
# About
|
||||||
|
author_name = Column(String, nullable=False, default="Your Name")
|
||||||
|
author_location = Column(String, nullable=False, default="Your Location")
|
||||||
|
profile_image = Column(String, nullable=False, default="/static/profile.jpg")
|
||||||
|
bio = Column(
|
||||||
|
Text,
|
||||||
|
nullable=False,
|
||||||
|
default="Write your bio in *markdown* here.\n\nSupports **multiple** paragraphs.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_engine("sqlite:///photos.db")
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
session = None
|
||||||
|
try:
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
# Create default site config if none exists
|
||||||
|
session = Session()
|
||||||
|
if not session.query(SiteConfig).first():
|
||||||
|
session.add(SiteConfig())
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
# Tables already exist, skip creation
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
if session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
init_db()
|
||||||
Session = sessionmaker(bind=engine)
|
Session = sessionmaker(bind=engine)
|
||||||
|
@ -2,17 +2,28 @@ APScheduler==3.10.4
|
|||||||
blinker==1.8.2
|
blinker==1.8.2
|
||||||
click==8.1.7
|
click==8.1.7
|
||||||
colorthief==0.2.1
|
colorthief==0.2.1
|
||||||
|
Deprecated==1.2.14
|
||||||
exif==1.6.0
|
exif==1.6.0
|
||||||
Flask==3.0.3
|
Flask==3.0.3
|
||||||
|
Flask-Limiter==3.8.0
|
||||||
greenlet==3.1.1
|
greenlet==3.1.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
importlib_resources==6.4.5
|
||||||
itsdangerous==2.2.0
|
itsdangerous==2.2.0
|
||||||
Jinja2==3.1.4
|
Jinja2==3.1.4
|
||||||
|
limits==3.13.0
|
||||||
|
markdown-it-py==3.0.0
|
||||||
MarkupSafe==3.0.1
|
MarkupSafe==3.0.1
|
||||||
|
mdurl==0.1.2
|
||||||
numpy==2.1.2
|
numpy==2.1.2
|
||||||
|
ordered-set==4.1.0
|
||||||
|
packaging==24.1
|
||||||
piexif==1.1.3
|
piexif==1.1.3
|
||||||
pillow==11.0.0
|
pillow==11.0.0
|
||||||
plum-py==0.8.7
|
plum-py==0.8.7
|
||||||
|
Pygments==2.18.0
|
||||||
pytz==2024.2
|
pytz==2024.2
|
||||||
|
rich==13.9.4
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
SQLAlchemy==2.0.36
|
SQLAlchemy==2.0.36
|
||||||
toml==0.10.2
|
toml==0.10.2
|
||||||
@ -20,4 +31,4 @@ typing_extensions==4.12.2
|
|||||||
tzlocal==5.2
|
tzlocal==5.2
|
||||||
watchdog==6.0.0
|
watchdog==6.0.0
|
||||||
Werkzeug==3.0.4
|
Werkzeug==3.0.4
|
||||||
gunicorn
|
wrapt==1.16.0
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
from PIL import Image
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
def string_to_binary(message):
|
def string_to_binary(message):
|
||||||
return ''.join(format(ord(char), '08b') for char in message)
|
return "".join(format(ord(char), "08b") for char in message)
|
||||||
|
|
||||||
|
|
||||||
def embed_message(image_path, message, exifraw):
|
def embed_message(image_path, message, exifraw):
|
||||||
# Open the image
|
# Open the image
|
||||||
@ -15,7 +17,7 @@ def embed_message(image_path, message, exifraw):
|
|||||||
|
|
||||||
# Convert message to binary
|
# Convert message to binary
|
||||||
binary_message = string_to_binary(message)
|
binary_message = string_to_binary(message)
|
||||||
|
|
||||||
# Check if the message can fit in the image
|
# Check if the message can fit in the image
|
||||||
if len(binary_message) > len(flat_array):
|
if len(binary_message) > len(flat_array):
|
||||||
raise ValueError("Message is too long to be embedded in this image")
|
raise ValueError("Message is too long to be embedded in this image")
|
||||||
@ -28,11 +30,12 @@ def embed_message(image_path, message, exifraw):
|
|||||||
stego_array = flat_array.reshape(img_array.shape)
|
stego_array = flat_array.reshape(img_array.shape)
|
||||||
|
|
||||||
# Create a new image from the modified array
|
# Create a new image from the modified array
|
||||||
stego_img = Image.fromarray(stego_array.astype('uint8'), img.mode)
|
stego_img = Image.fromarray(stego_array.astype("uint8"), img.mode)
|
||||||
|
|
||||||
# Save the image
|
# Save the image
|
||||||
stego_img.save(image_path, exif=exifraw)
|
stego_img.save(image_path, exif=exifraw)
|
||||||
|
|
||||||
|
|
||||||
def extract_message(image_path, message_length):
|
def extract_message(image_path, message_length):
|
||||||
# Open the image
|
# Open the image
|
||||||
img = Image.open(image_path)
|
img = Image.open(image_path)
|
||||||
@ -43,9 +46,16 @@ def extract_message(image_path, message_length):
|
|||||||
flat_array = img_array.flatten()
|
flat_array = img_array.flatten()
|
||||||
|
|
||||||
# Extract the binary message
|
# Extract the binary message
|
||||||
binary_message = ''.join([str(pixel & 1) for pixel in flat_array[:message_length * 8]])
|
binary_message = "".join(
|
||||||
|
[str(pixel & 1) for pixel in flat_array[: message_length * 8]]
|
||||||
|
)
|
||||||
|
|
||||||
# Convert binary to string
|
# Convert binary to string
|
||||||
message = ''.join([chr(int(binary_message[i:i+8], 2)) for i in range(0, len(binary_message), 8)])
|
message = "".join(
|
||||||
|
[
|
||||||
|
chr(int(binary_message[i : i + 8], 2))
|
||||||
|
for i in range(0, len(binary_message), 8)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
return message
|
return message
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -115,6 +116,42 @@
|
|||||||
.delete-btn:hover {
|
.delete-btn:hover {
|
||||||
background-color: #ff1a1a;
|
background-color: #ff1a1a;
|
||||||
}
|
}
|
||||||
|
.config-section {
|
||||||
|
margin: 2rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.config-editor fieldset {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.config-editor legend {
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.form-group textarea {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -166,15 +203,65 @@
|
|||||||
<td class="editable" data-field="iso">{{ photo.iso }}</td>
|
<td class="editable" data-field="iso">{{ photo.iso }}</td>
|
||||||
<td>{{ photo.width }}x{{ photo.height }}</td>
|
<td>{{ photo.width }}x{{ photo.height }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button onclick="saveChanges(this)">Save</button>
|
<button id="save-btn">Save</button>
|
||||||
<button onclick="deletePhoto(this)" class="delete-btn">Delete</button>
|
<button class="delete-btn" id="delete-btn">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="config-section">
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
<div class="config-editor">
|
||||||
|
<form id="configForm">
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Appearance -->
|
||||||
|
<fieldset>
|
||||||
|
<legend>Appearance</legend>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="appearance.accent_color">Accent Color:</label>
|
||||||
|
<input style="min-height: 2rem;" type="color" id="appearance.accent_color" name="appearance.accent_color" value="{{ config.appearance.accent_color }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="appearance.site_title">Site Title:</label>
|
||||||
|
<input type="text" id="appearance.site_title" name="appearance.site_title" value="{{ config.appearance.site_title }}">
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- About Section -->
|
||||||
|
<fieldset>
|
||||||
|
<legend>About</legend>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="about.name">Name:</label>
|
||||||
|
<input type="text" id="about.name" name="about.name" value="{{ config.about.name }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="about.location">Location:</label>
|
||||||
|
<input type="text" id="about.location" name="about.location" value="{{ config.about.location }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="about.profile_image">Profile Image:</label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem;">
|
||||||
|
<img id="profile-preview" src="/static/profile.jpeg" alt="Profile" style="width: 100px; height: 100px; object-fit: cover; border-radius: 50%;">
|
||||||
|
<input type="file" id="profile_image_upload" accept="image/jpeg,image/png" style="flex: 1;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="about.bio">Bio (Markdown):</label>
|
||||||
|
<textarea id="about.bio" name="about.bio" rows="4">{{ config.about.bio }}</textarea>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Save Configuration</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="config-history-link" style="margin-top: 1rem;">
|
||||||
|
<a href="{{ url_for('config_history') }}">View Configuration History</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script nonce="{{ g.csp_nonce }}">
|
||||||
function makeEditable(element) {
|
function makeEditable(element) {
|
||||||
const value = element.textContent;
|
const value = element.textContent;
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@ -250,6 +337,65 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.getElementById('delete-btn').addEventListener('click', deletePhoto);
|
||||||
|
document.getElementById('save-btn').addEventListener('click', saveChanges);
|
||||||
|
|
||||||
|
document.getElementById('profile_image_upload').addEventListener('change', async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('profile_image', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/upload_profile', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
document.getElementById('profile-preview').src = '/static/profile.jpeg?' + new Date().getTime();
|
||||||
|
} else {
|
||||||
|
alert('Error uploading profile image: ' + result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error uploading profile image: ' + error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('configForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = {};
|
||||||
|
const inputs = e.target.querySelectorAll('input:not([type="file"]), textarea');
|
||||||
|
|
||||||
|
inputs.forEach(input => {
|
||||||
|
formData[input.name] = input.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/admin/update_config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert('Configuration saved successfully');
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Error saving configuration: ' + result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error saving configuration: ' + error);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
57
templates/config_history.html
Normal file
57
templates/config_history.html
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Site Configuration History</title>
|
||||||
|
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
|
||||||
|
<style>
|
||||||
|
.config-entry {
|
||||||
|
background: white;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.config-date {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logout">
|
||||||
|
<a href="{{ url_for('admin') }}">Back to Admin</a> |
|
||||||
|
<a href="{{ url_for('admin_logout') }}">Logout</a>
|
||||||
|
</div>
|
||||||
|
<h1>Site Configuration History</h1>
|
||||||
|
|
||||||
|
{% for config in site_configs %}
|
||||||
|
<div class="config-entry">
|
||||||
|
<div class="config-date">
|
||||||
|
Updated: {{ config.updated_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}
|
||||||
|
</div>
|
||||||
|
<pre>{
|
||||||
|
"appearance": {
|
||||||
|
"site_title": "{{ config.site_title }}",
|
||||||
|
"accent_color": "{{ config.accent_color }}"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"name": "{{ config.author_name }}",
|
||||||
|
"location": "{{ config.author_location }}",
|
||||||
|
"profile_image": "{{ config.profile_image }}",
|
||||||
|
"bio": {{ config.bio | tojson }}
|
||||||
|
}
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -3,10 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Tanishq Dubey Photography</title>
|
<title>{{ site_title }}</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap" rel="stylesheet">
|
||||||
|
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@ -172,31 +173,119 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0,0,0,0.5);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
margin: 10% auto;
|
||||||
|
padding: 20px;
|
||||||
|
width: 80%;
|
||||||
|
max-width: 600px;
|
||||||
|
border-radius: 8px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-image {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-location {
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-bio {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
position: absolute;
|
||||||
|
right: 20px;
|
||||||
|
top: 20px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="sidebar">
|
<header>
|
||||||
<div class="sidebar-nav noto-sans-mono-font">
|
<div class="sidebar">
|
||||||
<div class="nav-toggle">☰</div>
|
<div class="sidebar-nav noto-sans-mono-font">
|
||||||
<nav>
|
<div class="nav-toggle">☰</div>
|
||||||
<ul>
|
<nav>
|
||||||
<li><a href="/">Home</a></li>
|
<ul>
|
||||||
<li><hr></li>
|
<li><a href="/">Home</a></li>
|
||||||
<li>Powered by <a href="https://dws.rip">DWS</a></li>
|
<li><a href="#" id="aboutLink">About</a></li>
|
||||||
</ul>
|
<li><hr></li>
|
||||||
</nav>
|
<li>Powered by <a href="https://dws.rip">DWS</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-title noto-sans-mono-font">
|
||||||
|
<h1>{{ site_title }}</h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-title noto-sans-mono-font">
|
</header>
|
||||||
<h1>Tanishq Dubey Photography</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<div class="grid-container" id="polaroid-grid"></div>
|
<div class="grid-container" id="polaroid-grid"></div>
|
||||||
<div id="loading">Loading more images...</div>
|
<div id="loading">Loading more images...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<div id="aboutModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</span>
|
||||||
|
<div class="modal-header">
|
||||||
|
<img src="{{ about.profile_image }}" alt="{{ about.name }}" class="profile-image">
|
||||||
|
<div class="profile-info">
|
||||||
|
<h2 class="profile-name">{{ about.name }}</h2>
|
||||||
|
<p class="profile-location">{{ about.location }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-bio" id="bio-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js" nonce="{{ nonce }}"></script>
|
||||||
|
|
||||||
|
<script nonce="{{ nonce }}">
|
||||||
const gridContainer = document.getElementById('polaroid-grid');
|
const gridContainer = document.getElementById('polaroid-grid');
|
||||||
const loadingIndicator = document.getElementById('loading');
|
const loadingIndicator = document.getElementById('loading');
|
||||||
const baseSize = 110; // Size of one grid cell in pixels
|
const baseSize = 110; // Size of one grid cell in pixels
|
||||||
@ -345,6 +434,32 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupNavToggle();
|
setupNavToggle();
|
||||||
|
|
||||||
|
// Modal functionality
|
||||||
|
const modal = document.getElementById('aboutModal');
|
||||||
|
const aboutLink = document.getElementById('aboutLink');
|
||||||
|
const closeBtn = document.querySelector('.close');
|
||||||
|
const bioContent = document.getElementById('bio-content');
|
||||||
|
|
||||||
|
// Render markdown content
|
||||||
|
bioContent.innerHTML = marked.parse(`{{ about.bio | safe }}`);
|
||||||
|
|
||||||
|
aboutLink.onclick = function() {
|
||||||
|
modal.style.display = 'block';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeBtn.onclick = function() {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onclick = function(event) {
|
||||||
|
if (event.target == modal) {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
document.body.style.overflow = 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
Loading…
Reference in New Issue
Block a user