Compare commits

...

11 Commits
0.1.0 ... main

Author SHA1 Message Date
5b0b30d69c Fix CSP Error
All checks were successful
Docker Build and Publish / build (push) Successful in 6s
Docker Build and Publish / build (release) Successful in 7s
Getting rid of inline onclick calls and registering the handler in the
primary script ensure securty (XSS).
2024-12-08 18:03:44 -05:00
9022facac5 EXIF Error handling 2024-11-14 18:56:46 -05:00
07725c99b4 Add release process
All checks were successful
Docker Build and Publish / build (push) Successful in 6s
Docker Build and Publish / build (release) Successful in 7s
2024-11-05 23:49:09 -05:00
05a184fcf7 Sizing Fix
All checks were successful
Docker Build and Publish / build (push) Successful in 7s
2024-11-05 22:11:45 -05:00
13e61b7bef remove duplicate secret key handling
All checks were successful
Docker Build and Publish / build (push) Successful in 6s
2024-11-05 19:40:29 -05:00
4c993ebacd Secret Key
All checks were successful
Docker Build and Publish / build (push) Successful in 6s
2024-11-05 19:36:44 -05:00
9c1e6f0e94 More frontend changes, ready for deploy
All checks were successful
Docker Build and Publish / build (push) Successful in 7s
2024-11-05 19:25:52 -05:00
9abdd18f33 Checkpoint, configuration can now be done through DB and the site. Server settings are still on the file system 2024-11-05 19:03:04 -05:00
b46ec98115 Reverse Proxy Changes 2024-11-05 17:16:32 -05:00
905e3c3977 Fix startup and init
All checks were successful
Docker Build and Publish / build (push) Successful in 6s
2024-11-05 15:03:32 -05:00
6f2ecd9775 fix requirements
All checks were successful
Docker Build and Publish / build (push) Successful in 21s
2024-11-05 14:54:20 -05:00
15 changed files with 946 additions and 412 deletions

View File

@ -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
View File

@ -10,6 +10,7 @@ lib64
uploads/ uploads/
thumbnails/ thumbnails/
images/ images/
static/
config.toml config.toml
__pycache__/ __pycache__/

63
Makefile Normal file
View 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"

View File

@ -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
View File

@ -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"])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>

View File

@ -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">&times;</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>