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:
push:
branches: [ main ]
tags: [ 'v*' ]
tags: [ 'v*.*.*' ]
pull_request:
branches: [ main ]
release:
types: [published]
jobs:
build:
@ -20,11 +22,11 @@ jobs:
with:
images: git.dws.rip/${{ github.repository }}
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=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Login to Gitea Container Registry
uses: docker/login-action@v2

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ lib64
uploads/
thumbnails/
images/
static/
config.toml
__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

@ -115,3 +115,11 @@ spectra/
- `FLASK_ENV`: Set to 'production' in production
- `WORKERS`: Number of Gunicorn workers (default: 4)
- `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`

727
app.py
View File

@ -1,120 +1,91 @@
from flask import Flask, request, jsonify, render_template, redirect, url_for, flash, session, send_from_directory
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 atexit
import colorsys
from steganography import embed_message, extract_message
import hashlib
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import toml
import os
import random
import secrets
import threading
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.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__)
app.secret_key = os.urandom(24)
config = load_or_create_config()
from models import Photo
from models import Session as DBSession
from models import SiteConfig, init_db
from steganography import embed_message, extract_message
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()
# Add this function to handle secret key persistence
def get_or_create_secret_key():
"""Get existing secret key or create a new one"""
secret_key_file = Path("secret.key")
try:
if secret_key_file.exists():
logger.info("Loading existing secret key")
return secret_key_file.read_bytes()
else:
logger.info("Generating new secret key")
secret_key = os.urandom(32) # Use 32 bytes for better security
secret_key_file.write_bytes(secret_key)
return secret_key
except Exception as e:
logger.error(f"Error handling secret key: {e}")
# Fallback to a memory-only key if file operations fail
return os.urandom(32)
DEFAULT_CONFIG = {
'server': {
'host': '0.0.0.0',
'port': 5000
},
'directories': {
'upload': 'uploads',
'thumbnail': 'thumbnails'
},
'admin': {
'password': 'changeme' # Default password
},
'appearance': {
'accent_color': '#007bff'
}
"server": {"host": "0.0.0.0", "port": 5000},
"directories": {"upload": "uploads", "thumbnail": "thumbnails"},
"admin": {"password": secrets.token_urlsafe(16)}, # Generate secure random password
}
def merge_configs(default, user):
"""Recursively merge user config with default config"""
result = default.copy()
for key, value in user.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = merge_configs(result[key], value)
else:
result[key] = value
return result
# Configure logging
dictConfig({
'version': 1,
'formatters': {
'default': {
'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
}
},
'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):
def on_modified(self, event):
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}")
# Get logger for this module
logger = getLogger(__name__)
def load_or_create_config():
config_path = 'config.toml'
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()
# Create Flask app with persistent secret key
app = Flask(__name__)
app.secret_key = get_or_create_secret_key()
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@ -151,166 +122,227 @@ def generate_thumbnails(filename):
else:
img.save(thumb_path, optimize=True, quality=85)
def generate_all_thumbnails():
for filename in os.listdir(UPLOAD_FOLDER):
if allowed_file(filename):
generate_thumbnails(filename)
def load_or_create_config():
"""Load config from file or create with defaults if missing"""
config_path = Path("config.toml")
logger.info(f"Loading config from {config_path}")
scheduler.add_job(generate_all_thumbnails, 'interval', minutes=5)
scheduler.add_job(generate_all_thumbnails, 'date', run_date=datetime.now()) # Run once at startup
try:
# 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():
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():
page = int(request.args.get('page', 1))
page = int(request.args.get("page", 1))
per_page = 20
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 = []
for photo in photos:
factor = random.randint(2, 3)
if photo.height < 4000 or photo.width < 4000:
if photo.height < 4000 and photo.width < 4000:
factor = 1
if photo.orientation == 6 or photo.orientation == 8:
width, height = photo.height, photo.width
else:
width, height = photo.width, photo.height
images.append({
'imgSrc': f'/static/thumbnails/{os.path.splitext(photo.input_filename)[0]}/1536_{photo.input_filename}',
'width': width / factor,
'height': height / factor,
'caption': photo.input_filename,
'date': photo.date_taken.strftime('%y %m %d'),
'technicalInfo': f"{photo.focal_length}MM | F/{photo.aperture} | {photo.shutter_speed} | ISO{photo.iso}",
'highlightColor': photo.highlight_color
})
images.append(
{
"imgSrc": f"/static/thumbnails/{os.path.splitext(photo.input_filename)[0]}/1536_{photo.input_filename}",
"width": width / factor,
"height": height / factor,
"caption": photo.input_filename,
"date": photo.date_taken.strftime("%y %m %d"),
"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
db_session.close()
return jsonify({'images': images, 'hasMore': has_more})
return jsonify({"images": images, "hasMore": has_more})
@app.route('/admin')
# ----------------------------------------------------------------
# Admin routes
# ----------------------------------------------------------------
@app.route("/admin")
def admin():
if 'logged_in' not in session:
return redirect(url_for('admin_login'))
if "logged_in" not in session:
return redirect(url_for("admin_login"))
db_session = DBSession()
photos = db_session.query(Photo).order_by(Photo.date_taken.desc()).all()
db_session.close()
return render_template('admin.html', photos=photos, accent_color=config['appearance']['accent_color'])
@app.route('/admin/login', methods=['GET', 'POST'])
def admin_login():
if request.method == 'POST':
if request.form['password'] == config['admin']['password']:
session['logged_in'] = True
return redirect(url_for('admin'))
else:
flash('Invalid password')
return render_template('admin_login.html', accent_color=config['appearance']['accent_color'])
@app.route('/admin/upload', methods=['POST'])
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
# Pass the full config to the template
return render_template(
"admin.html",
photos=photos,
accent_color=config["appearance"]["accent_color"],
config=config,
)
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')
@app.route("/admin/logout")
def admin_logout():
session.pop('logged_in', None)
flash('You have been logged out')
return redirect(url_for('admin_login'))
session.pop("logged_in", None)
flash("You have been logged out")
return redirect(url_for("admin_login"))
@app.route('/static/thumbnails/<path:filename>')
def serve_thumbnail(filename):
return send_from_directory(THUMBNAIL_FOLDER, filename)
@app.route('/admin/update_photo/<int:photo_id>', methods=['POST'])
@app.route("/admin/update_photo/<int:photo_id>", methods=["POST"])
def update_photo(photo_id):
if 'logged_in' not in session:
return jsonify({'success': False, 'error': 'Not logged in'}), 401
if "logged_in" not in session:
return jsonify({"success": False, "error": "Not logged in"}), 401
data = request.json
db_session = DBSession()
@ -318,35 +350,35 @@ def update_photo(photo_id):
if not photo:
db_session.close()
return jsonify({'success': False, 'error': 'Photo not found'}), 404
return jsonify({"success": False, "error": "Photo not found"}), 404
try:
for field, value in data.items():
if field == 'date_taken':
value = datetime.strptime(value, '%Y-%m-%d %H:%M:%S')
elif field == 'iso':
if field == "date_taken":
value = datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
elif field == "iso":
value = int(value)
setattr(photo, field, value)
db_session.commit()
db_session.close()
return jsonify({'success': True})
return jsonify({"success": True})
except Exception as e:
db_session.rollback()
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):
if 'logged_in' not in session:
return jsonify({'success': False, 'error': 'Not logged in'}), 401
if "logged_in" not in session:
return jsonify({"success": False, "error": "Not logged in"}), 401
db_session = DBSession()
photo = db_session.query(Photo).get(photo_id)
if not photo:
db_session.close()
return jsonify({'success': False, 'error': 'Photo not found'}), 404
return jsonify({"success": False, "error": "Photo not found"}), 404
try:
# Delete the original file
@ -355,7 +387,9 @@ def delete_photo(photo_id):
os.remove(original_path)
# 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):
for thumb_file in os.listdir(thumb_dir):
os.remove(os.path.join(thumb_dir, thumb_file))
@ -366,17 +400,17 @@ def delete_photo(photo_id):
db_session.commit()
db_session.close()
return jsonify({'success': True})
return jsonify({"success": True})
except Exception as e:
db_session.rollback()
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):
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):
return jsonify({'verified': False, 'error': 'Image not found'})
return jsonify({"verified": False, "error": "Image not found"})
try:
extracted_key = extract_message(file_path, 16)
@ -385,78 +419,69 @@ def verify_image(filename):
db_session.close()
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:
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:
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
return jsonify({"verified": False, "error": str(e)})
# Add rate limiting to sensitive endpoints
@app.route('/admin/login', methods=['POST'])
@app.route("/admin/login", methods=["POST", "GET"])
@limiter.limit("5 per minute")
def admin_login():
if request.method == 'POST':
if request.form['password'] == config['admin']['password']:
session['logged_in'] = True
return redirect(url_for('admin'))
if request.method == "POST":
if request.form["password"] == config["admin"]["password"]:
session["logged_in"] = True
return redirect(url_for("admin"))
else:
flash('Invalid password')
return render_template('admin_login.html', accent_color=config['appearance']['accent_color'])
flash("Invalid password")
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")
def admin_upload():
if 'logged_in' not in session:
return redirect(url_for('admin_login'))
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'))
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'))
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_path = os.path.join(app.config["UPLOAD_FOLDER"], filename)
file.save(file_path)
# Extract EXIF data
exif = None
# Extract EXIF data with error handling
exif = {}
exifraw = None
width = height = 0
try:
with Image.open(file_path) as img:
exifraw = img.info['exif']
width, height = img.size
if hasattr(img, '_getexif') and img._getexif() is not None:
exifraw = img.info.get("exif")
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
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
try:
@ -464,51 +489,119 @@ def admin_upload():
except ValueError as e:
flash(f"Error embedding key: {str(e)}")
os.remove(file_path)
return redirect(url_for('admin'))
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']
# Handle exposure time with error handling
try:
exposure_time = exif.get("ExposureTime", 0)
if isinstance(exposure_time, tuple):
exposure_fraction = f"{exposure_time[0]}/{exposure_time[1]}"
else:
exposure_fraction = f"1/{int(1/float(exposure_time))}"
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()
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', '')),
focal_length=str(
exif.get("FocalLengthIn35mmFilm", exif.get("FocalLength", "0"))
),
aperture=str(exif.get("FNumber", "0")),
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)),
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
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("File uploaded successfully")
return redirect(url_for("admin"))
flash('Invalid file type')
return redirect(url_for('admin'))
flash("Invalid file type")
return redirect(url_for("admin"))
if __name__ == '__main__':
app.run(
debug=True,
port=config['server']['port'],
host=config['server']['host']
# 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]
accent_color = "#007bff"
site_title = "Spectra"
[security]
# Add these security settings

View File

@ -1,32 +1,24 @@
import toml
import os
import secrets
CONFIG_FILE = 'config.toml'
import toml
CONFIG_FILE = "config.toml"
def load_or_create_config():
if not os.path.exists(CONFIG_FILE):
admin_password = secrets.token_urlsafe(16)
config = {
'admin': {
'password': admin_password
},
'directories': {
'upload': 'uploads',
'thumbnail': 'thumbnails'
},
'appearance': {
'accent_color': '#ff6600'
},
'server': {
'host': '0.0.0.0',
'port': 5002
"admin": {"password": admin_password},
"directories": {"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)
print(f"Generated new config file with admin password: {admin_password}")
else:
with open(CONFIG_FILE, 'r') as f:
with open(CONFIG_FILE, "r") as f:
config = toml.load(f)
return config

View File

@ -2,33 +2,21 @@ version: '3.8'
services:
web:
build: .
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"
image: git.dws.rip/dws/spectra:main
volumes:
- ./uploads:/app/uploads
- ./thumbnails:/app/thumbnails
- ./config.toml:/app/config.toml
- ./photos.db:/app/photos.db
- ./static:/app/static
environment:
- PYTHONUNBUFFERED=1
- FLASK_ENV=production
- WORKERS=4
restart: unless-stopped
networks:
- traefik-public
- default
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
interval: 30s
timeout: 10s
retries: 3
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.orm import sessionmaker
Base = declarative_base()
class Photo(Base):
__tablename__ = 'photos'
__tablename__ = "photos"
id = Column(Integer, primary_key=True, autoincrement=True)
input_filename = Column(String, nullable=False)
@ -19,8 +23,50 @@ class Photo(Base):
height = Column(Integer)
highlight_color = Column(String)
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)

View File

@ -2,17 +2,28 @@ APScheduler==3.10.4
blinker==1.8.2
click==8.1.7
colorthief==0.2.1
Deprecated==1.2.14
exif==1.6.0
Flask==3.0.3
Flask-Limiter==3.8.0
greenlet==3.1.1
gunicorn==23.0.0
importlib_resources==6.4.5
itsdangerous==2.2.0
Jinja2==3.1.4
limits==3.13.0
markdown-it-py==3.0.0
MarkupSafe==3.0.1
mdurl==0.1.2
numpy==2.1.2
ordered-set==4.1.0
packaging==24.1
piexif==1.1.3
pillow==11.0.0
plum-py==0.8.7
Pygments==2.18.0
pytz==2024.2
rich==13.9.4
six==1.16.0
SQLAlchemy==2.0.36
toml==0.10.2
@ -20,4 +31,4 @@ typing_extensions==4.12.2
tzlocal==5.2
watchdog==6.0.0
Werkzeug==3.0.4
gunicorn
wrapt==1.16.0

View File

@ -1,8 +1,10 @@
from PIL import Image
import numpy as np
from PIL import Image
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):
# Open the image
@ -28,11 +30,12 @@ def embed_message(image_path, message, exifraw):
stego_array = flat_array.reshape(img_array.shape)
# 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
stego_img.save(image_path, exif=exifraw)
def extract_message(image_path, message_length):
# Open the image
img = Image.open(image_path)
@ -43,9 +46,16 @@ def extract_message(image_path, message_length):
flat_array = img_array.flatten()
# 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
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

View File

@ -7,6 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="icon" href="data:;base64,iVBORw0KGgo=">
<style>
body {
margin: 0;
@ -115,6 +116,42 @@
.delete-btn:hover {
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>
</head>
<body>
@ -166,15 +203,65 @@
<td class="editable" data-field="iso">{{ photo.iso }}</td>
<td>{{ photo.width }}x{{ photo.height }}</td>
<td>
<button onclick="saveChanges(this)">Save</button>
<button onclick="deletePhoto(this)" class="delete-btn">Delete</button>
<button id="save-btn">Save</button>
<button class="delete-btn" id="delete-btn">Delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</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>
<script>
<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>
<script nonce="{{ g.csp_nonce }}">
function makeEditable(element) {
const value = element.textContent;
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>
</body>
</html>

View File

@ -7,6 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<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 rel="icon" href="data:;base64,iVBORw0KGgo=">
<style>
body {
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>
<meta charset="UTF-8">
<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.gstatic.com" crossorigin>
<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>
body {
margin: 0;
@ -172,31 +173,119 @@
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>
</head>
<body>
<header>
<div class="sidebar">
<div class="sidebar-nav noto-sans-mono-font">
<div class="nav-toggle"></div>
<nav>
<ul>
<li><a href="/">Home</a></li>
<li><a href="#" id="aboutLink">About</a></li>
<li><hr></li>
<li>Powered by <a href="https://dws.rip">DWS</a></li>
</ul>
</nav>
</div>
<div class="sidebar-title noto-sans-mono-font">
<h1>Tanishq Dubey Photography</h1>
<h1>{{ site_title }}</h1>
</div>
</div>
</header>
<div class="main-content">
<div class="grid-container" id="polaroid-grid"></div>
<div id="loading">Loading more images...</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 loadingIndicator = document.getElementById('loading');
const baseSize = 110; // Size of one grid cell in pixels
@ -345,6 +434,32 @@
}
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>
</body>
</html>