Merge pull request 'Rewrite' (#1) from gridlayout into main
All checks were successful
Docker Build and Publish / build (push) Successful in 12s

Reviewed-on: #1
This commit is contained in:
Tanishq Dubey 2024-11-05 14:47:42 -05:00
commit dcd66c3c76
17 changed files with 1574 additions and 147 deletions

View File

@ -0,0 +1,42 @@
name: Docker Build and Publish
on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
images: git.dws.rip/${{ github.repository }}
tags: |
type=ref,event=branch
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
with:
registry: git.dws.rip
username: ${{ github.actor }}
password: ${{ secrets.TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

28
Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM python:3.12-slim
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN useradd -m -r -s /bin/bash appuser
# Create necessary directories
WORKDIR /app
RUN mkdir -p /app/uploads /app/thumbnails
RUN chown -R appuser:appuser /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn
# Copy application files
COPY --chown=appuser:appuser templates /app/templates
COPY --chown=appuser:appuser app.py config.py models.py steganography.py ./
# Switch to non-root user
USER appuser
# Use gunicorn for production
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "app:app"]

117
README.md Normal file
View File

@ -0,0 +1,117 @@
# Spectra
> A variation on the masonry grid image gallery with the row alighment constraint removed. Oh, it also has an admin interface so you can set it up and forget it.
## Features
- **Color Analysis**: Automatically extracts color palettes from images to create cohesive galleries
- **Smart Thumbnails**: Generates and caches responsive thumbnails in multiple sizes
- **EXIF Preservation**: Maintains all photo metadata through processing
- **Ownership Verification**: Embeds steganographic proofs in images
- **Live Configuration**: Hot-reload config changes without restarts
- **Production Ready**: Fully Dockerized with Traefik integration
## Quick Start
### Local Development
Clone the repository
```bash
git clone https://git.dws.rip/your-username/spectra
cd spectra
```
Set up Python virtual environment
```bash
python -m venv venv
source venv/bin/activate # or venv\Scripts\activate on Windows
```
Install dependencies
```bash
pip install -r requirements.txt
```
Create config from template
```bash
cp config.example.toml config.toml
```
Run development server
```bash
python app.py
```
### Production Deployment
Create required network
```bash
docker network create traefik-public
```
Configure your domain
```bash
sed -i 's/photos.dws.rip/your.domain.here/g' docker-compose.yml
```
Launch
```bash
docker-compose up -d
```
## Configuration
### Essential Settings
```toml
[server]
host = "0.0.0.0"
port = 5000
[security]
max_upload_size_mb = 80
rate_limit = 100 # requests per minute
[admin]
password = "change-this-password" # Required
```
See `config.example.toml` for all available options.
## Directory Structure
```
spectra/
├── app.py # Application entry point
├── config.py # Configuration management
├── models.py # Database models
├── steganography.py # Image verification
├── templates/ # Jinja2 templates
├── uploads/ # Original images
└── thumbnails/ # Generated thumbnails
```
## API Reference
### Endpoints
#### Public Endpoints
- `GET /` - Main gallery view
- `GET /api/images` - Get paginated image list
- `GET /verify/<filename>` - Verify image authenticity
#### Admin Endpoints
- `POST /admin/login` - Admin authentication
- `POST /admin/upload` - Upload new images
- `POST /admin/update_photo/<id>` - Update image metadata
- `POST /admin/delete_photo/<id>` - Delete image
## Environment Variables
- `FLASK_ENV`: Set to 'production' in production
- `WORKERS`: Number of Gunicorn workers (default: 4)
- `PORT`: Override default port (default: 5000)

514
app.py Normal file
View File

@ -0,0 +1,514 @@
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 colorsys
from steganography import embed_message, extract_message
import hashlib
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
import toml
import threading
import time
import atexit
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
import secrets
app = Flask(__name__)
app.secret_key = os.urandom(24)
config = load_or_create_config()
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()
DEFAULT_CONFIG = {
'server': {
'host': '0.0.0.0',
'port': 5000
},
'directories': {
'upload': 'uploads',
'thumbnail': 'thumbnails'
},
'admin': {
'password': 'changeme' # Default password
},
'appearance': {
'accent_color': '#007bff'
}
}
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
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}")
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()
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_highlight_color(image_path):
color_thief = ColorThief(image_path)
palette = color_thief.get_palette(color_count=6, quality=1)
# Convert RGB to HSV and find the color with the highest saturation
highlight_color = max(palette, key=lambda rgb: colorsys.rgb_to_hsv(*rgb)[1])
return '#{:02x}{:02x}{:02x}'.format(*highlight_color)
def generate_thumbnails(filename):
original_path = os.path.join(UPLOAD_FOLDER, filename)
thumb_dir = os.path.join(THUMBNAIL_FOLDER, os.path.splitext(filename)[0])
os.makedirs(thumb_dir, exist_ok=True)
for size in THUMBNAIL_SIZES:
thumb_path = os.path.join(thumb_dir, f"{size}_{filename}")
if not os.path.exists(thumb_path):
with Image.open(original_path) as img:
# Extract EXIF data
exif_data = None
if "exif" in img.info:
exif_data = img.info["exif"]
# Resize image
img.thumbnail((size, size), Image.LANCZOS)
# Save image with EXIF data
if exif_data:
img.save(thumb_path, exif=exif_data, optimize=True, quality=85)
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)
scheduler.add_job(generate_all_thumbnails, 'interval', minutes=5)
scheduler.add_job(generate_all_thumbnails, 'date', run_date=datetime.now()) # Run once at startup
@app.route('/')
def index():
return render_template('index.html', accent_color=config['appearance']['accent_color'])
@app.route('/api/images')
def get_images():
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()
images = []
for photo in photos:
factor = random.randint(2, 3)
if photo.height < 4000 or 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
})
has_more = db_session.query(Photo).count() > page * per_page
db_session.close()
return jsonify({'images': images, 'hasMore': has_more})
@app.route('/admin')
def admin():
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
)
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():
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'])
def update_photo(photo_id):
if 'logged_in' not in session:
return jsonify({'success': False, 'error': 'Not logged in'}), 401
data = request.json
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
try:
for field, value in data.items():
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})
except Exception as e:
db_session.rollback()
db_session.close()
return jsonify({'success': False, 'error': str(e)}), 500
@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
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
try:
# Delete the original file
original_path = os.path.join(UPLOAD_FOLDER, photo.input_filename)
if os.path.exists(original_path):
os.remove(original_path)
# Delete the thumbnail directory
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))
os.rmdir(thumb_dir)
# Delete the database entry
db_session.delete(photo)
db_session.commit()
db_session.close()
return jsonify({'success': True})
except Exception as e:
db_session.rollback()
db_session.close()
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/verify/<filename>', methods=['GET'])
def verify_image(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'})
try:
extracted_key = extract_message(file_path, 16)
db_session = DBSession()
photo = db_session.query(Photo).filter_by(input_filename=filename).first()
db_session.close()
if photo and photo.unique_key == extracted_key:
return jsonify({'verified': True, 'message': 'Image ownership verified'})
else:
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
# Add rate limiting to sensitive endpoints
@app.route('/admin/login', methods=['POST'])
@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'))
else:
flash('Invalid password')
return render_template('admin_login.html', accent_color=config['appearance']['accent_color'])
@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 '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'))
if __name__ == '__main__':
app.run(
debug=True,
port=config['server']['port'],
host=config['server']['host']
)

20
config.example.toml Normal file
View File

@ -0,0 +1,20 @@
[server]
host = "0.0.0.0"
port = 5000
[directories]
upload = "/app/uploads"
thumbnail = "/app/thumbnails"
[admin]
# Change this password!
password = "changeme"
[appearance]
accent_color = "#007bff"
[security]
# Add these security settings
max_upload_size_mb = 80
allowed_extensions = ["jpg", "jpeg", "png", "gif"]
rate_limit = 100 # requests per minute

32
config.py Normal file
View File

@ -0,0 +1,32 @@
import toml
import os
import secrets
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
}
}
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:
config = toml.load(f)
return config

34
docker-compose.yml Normal file
View File

@ -0,0 +1,34 @@
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"
volumes:
- ./uploads:/app/uploads
- ./thumbnails:/app/thumbnails
- ./config.toml:/app/config.toml
- ./photos.db:/app/photos.db
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,69 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Tanishq Dubey Photography</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 href="styles/styles.css" rel="stylesheet" />
</head>
<body>
<div class="sidebar">
<h1>Tanishq Dubey Photography</h1>
</div>
<div class="photocollage">
<div class="photo">
<div class="photocontent">
<div class="photoimage">
<img class="photoimagesrc" src="images/1.JPG" alt="A Picture">
</div>
<div class="photodetails noto-sans-mono-font">
<p>2024-10-07</p>
<p>f/1.4 | 24MM | 1/100S | ISO100</p>
</div>
</div>
</div>
<div class="photo">
<div class="photocontent">
<div class = "photoimage">
<img class="photoimagesrc" src="images/2.JPG" alt="A Picture">
</div>
<div class="photodetails noto-sans-mono-font">
<p>2024-10-07</p>
<p>f/1.4 | 24MM | 1/100S | ISO100</p>
</div>
</div>
</div>
<div class="photo">
<div class="photocontent">
<div class = "photoimage">
<img class="photoimagesrc" src="images/3.JPG" alt="A Picture">
</div>
<div class="photodetails noto-sans-mono-font">
<p>2024-10-07</p>
<p>f/1.4 | 24MM | 1/100S | ISO100</p>
</div>
</div>
</div>
<div class="photo">
<div class="photocontent">
<div class = "photoimage">
<img class="photoimagesrc" src="images/4.JPG" alt="A Picture">
</div>
<div class="photodetails noto-sans-mono-font">
<p>2024-10-07</p>
<p>f/1.4 | 24MM | 1/100S | ISO100</p>
</div>
</div>
</div>
</div>
<script src="scripts/script.js"></script>
</body>
</html>

26
models.py Normal file
View File

@ -0,0 +1,26 @@
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Float
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
Base = declarative_base()
class Photo(Base):
__tablename__ = 'photos'
id = Column(Integer, primary_key=True, autoincrement=True)
input_filename = Column(String, nullable=False)
thumbnail_filename = Column(String, nullable=False)
focal_length = Column(String)
aperture = Column(String)
shutter_speed = Column(String)
date_taken = Column(DateTime)
iso = Column(Integer)
width = Column(Integer)
height = Column(Integer)
highlight_color = Column(String)
orientation = Column(Integer)
unique_key = Column(String(16), nullable=False) # Add this line
engine = create_engine('sqlite:///photos.db')
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)

5
pyvenv.cfg Normal file
View File

@ -0,0 +1,5 @@
home = /usr/bin
include-system-site-packages = false
version = 3.12.7
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /home/dubey/projects/photoportfolio/pythonserver

23
requirements.txt Normal file
View File

@ -0,0 +1,23 @@
APScheduler==3.10.4
blinker==1.8.2
click==8.1.7
colorthief==0.2.1
exif==1.6.0
Flask==3.0.3
greenlet==3.1.1
itsdangerous==2.2.0
Jinja2==3.1.4
MarkupSafe==3.0.1
numpy==2.1.2
piexif==1.1.3
pillow==11.0.0
plum-py==0.8.7
pytz==2024.2
six==1.16.0
SQLAlchemy==2.0.36
toml==0.10.2
typing_extensions==4.12.2
tzlocal==5.2
watchdog==6.0.0
Werkzeug==3.0.4
gunicorn

View File

51
steganography.py Normal file
View File

@ -0,0 +1,51 @@
from PIL import Image
import numpy as np
def string_to_binary(message):
return ''.join(format(ord(char), '08b') for char in message)
def embed_message(image_path, message, exifraw):
# Open the image
img = Image.open(image_path)
# Convert image to numpy array
img_array = np.array(img)
# Flatten the array
flat_array = img_array.flatten()
# Convert message to binary
binary_message = string_to_binary(message)
# Check if the message can fit in the image
if len(binary_message) > len(flat_array):
raise ValueError("Message is too long to be embedded in this image")
# Embed the message
for i, bit in enumerate(binary_message):
flat_array[i] = (flat_array[i] & 0xFE) | int(bit)
# Reshape the array back to the original image shape
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)
# 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)
# Convert image to numpy array
img_array = np.array(img)
# Flatten the array
flat_array = img_array.flatten()
# Extract the binary message
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)])
return message

View File

@ -1,78 +0,0 @@
html {
margin: 0;
height: 100vh;
}
body {
min-height: 100vh;
margin: 0;
}
.photocollage {
display: flex;
justify-content: flex-start;
align-items: flex-start;
/* flex-flow: row wrap; */
flex-direction: row;
flex-wrap: wrap;
align-content: flex-start;
height: 100%;
padding: 15px;
gap: 5px;
}
.noto-sans-mono-font {
font-family: "Noto Sans Mono", monospace;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
font-variation-settings:
"wdth" 100;
}
.photo {
background-color: #eeeeee;
filter: drop-shadow(0px 3px 3px #888888);
display: inline-block;
padding: 1vh;
margin: 1vh;
}
.photocontent {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: normal;
align-content: normal;
}
.photoimage {
display: block;
flex-grow: 1;
flex-shrink: 1;
flex-basis: auto;
align-self: center;
order: 0;
}
.photoimagesrc {
max-width:40vw;
max-height:40vw;
width: auto;
height: auto;
}
.photodetails {
display: block;
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
align-self: flex-end;
order: 0;
text-align: right;
line-height: 70%;
font-size: 0.75rem;
}

255
templates/admin.html Normal file
View File

@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Interface - Tanishq Dubey Photography</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">
<style>
body {
margin: 0;
padding: 0;
font-family: 'Noto Sans Mono', monospace;
background-color: #f0f0f0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
h1 {
color: {{ accent_color }};
margin-bottom: 1.5rem;
}
.upload-form {
background-color: #ffffff;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
input[type="file"] {
margin-right: 1rem;
}
input[type="submit"] {
background-color: {{ accent_color }};
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: bold;
}
input[type="submit"]:hover {
background-color: {{ accent_color }}e0; /* Slightly darker version of the accent color */
}
table {
width: 100%;
border-collapse: collapse;
background-color: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
th {
background-color: #f5f5f5;
font-weight: bold;
color: #333;
}
tr:hover {
background-color: #f9f9f9;
}
.thumbnail {
max-width: 100px;
max-height: 100px;
object-fit: cover;
}
.flash-messages {
margin-bottom: 1rem;
}
.flash-messages p {
background-color: #4CAF50;
color: white;
padding: 0.5rem;
border-radius: 4px;
}
.logout {
text-align: right;
margin-bottom: 1rem;
}
.logout a {
color: {{ accent_color }};
text-decoration: none;
}
.logout a:hover {
text-decoration: underline;
}
.editable {
cursor: pointer;
}
.editable:hover {
background-color: #f0f0f0;
}
.editing {
background-color: #fff;
border: 1px solid #ccc;
padding: 2px;
}
.delete-btn {
background-color: #ff4136;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: bold;
margin-left: 0.5rem;
}
.delete-btn:hover {
background-color: #ff1a1a;
}
</style>
</head>
<body>
<div class="container">
<div class="logout">
<a href="{{ url_for('admin_logout') }}">Logout</a>
</div>
<h1>Admin Interface</h1>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="flash-messages">
{% for message in messages %}
<p>{{ message }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="upload-form">
<h2>Upload New Image</h2>
<form action="{{ url_for('admin_upload') }}" method="POST" enctype="multipart/form-data">
<input type="file" name="file" accept=".jpg,.jpeg,.png,.gif" required>
<input type="submit" value="Upload">
</form>
</div>
<h2>Uploaded Images</h2>
<table>
<thead>
<tr>
<th>Thumbnail</th>
<th>Filename</th>
<th>Date Taken</th>
<th>Focal Length</th>
<th>Aperture</th>
<th>Shutter Speed</th>
<th>ISO</th>
<th>Dimensions</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for photo in photos %}
<tr data-id="{{ photo.id }}">
<td><img src="{{ url_for('serve_thumbnail', filename=photo.thumbnail_filename) }}" alt="Thumbnail" class="thumbnail"></td>
<td class="editable" data-field="input_filename">{{ photo.input_filename }}</td>
<td class="editable" data-field="date_taken">{{ photo.date_taken.strftime('%Y-%m-%d %H:%M:%S') }}</td>
<td class="editable" data-field="focal_length">{{ photo.focal_length }}</td>
<td class="editable" data-field="aperture">{{ photo.aperture }}</td>
<td class="editable" data-field="shutter_speed">{{ photo.shutter_speed }}</td>
<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>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
function makeEditable(element) {
const value = element.textContent;
const input = document.createElement('input');
input.value = value;
input.classList.add('editing');
element.textContent = '';
element.appendChild(input);
input.focus();
input.addEventListener('blur', function() {
element.textContent = this.value;
element.classList.remove('editing');
});
}
document.querySelectorAll('.editable').forEach(el => {
el.addEventListener('click', function() {
if (!this.classList.contains('editing')) {
makeEditable(this);
}
});
});
function saveChanges(button) {
const row = button.closest('tr');
const photoId = row.dataset.id;
const updatedData = {};
row.querySelectorAll('.editable').forEach(el => {
updatedData[el.dataset.field] = el.textContent;
});
fetch('/admin/update_photo/' + photoId, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedData),
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Changes saved successfully!');
} else {
alert('Error saving changes: ' + data.error);
}
})
.catch((error) => {
console.error('Error:', error);
alert('An error occurred while saving changes.');
});
}
function deletePhoto(button) {
if (confirm('Are you sure you want to delete this photo?')) {
const row = button.closest('tr');
const photoId = row.dataset.id;
fetch('/admin/delete_photo/' + photoId, {
method: 'POST',
})
.then(response => response.json())
.then(data => {
if (data.success) {
row.remove();
alert('Photo deleted successfully!');
} else {
alert('Error deleting photo: ' + data.error);
}
})
.catch((error) => {
console.error('Error:', error);
alert('An error occurred while deleting the photo.');
});
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Tanishq Dubey Photography</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">
<style>
body {
margin: 0;
padding: 0;
font-family: 'Noto Sans Mono', monospace;
background-color: #f0f0f0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}
.login-container {
background-color: #ffffff;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
text-align: center;
}
h1 {
color: {{ accent_color }};
margin-bottom: 1.5rem;
}
input[type="password"] {
width: 100%;
padding: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
font-family: inherit;
}
input[type="submit"] {
background-color: {{ accent_color }};
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-weight: bold;
}
input[type="submit"]:hover {
background-color: #e55c00;
}
.flash-messages {
margin-bottom: 1rem;
color: #ff0000;
}
</style>
</head>
<body>
<div class="login-container">
<h1>Admin Login</h1>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="flash-messages">
{% for message in messages %}
<p>{{ message }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="POST">
<input type="password" name="password" required placeholder="Enter password">
<input type="submit" value="Login">
</form>
</div>
</body>
</html>

350
templates/index.html Normal file
View File

@ -0,0 +1,350 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tanishq Dubey Photography</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">
<style>
body {
margin: 0;
padding: 0;
font-family: Arial, sans-serif;
background-color: #f0f0f0;
display: flex;
flex-direction: row;
min-height: 100vh;
}
.main-content {
flex-grow: 1;
padding: 20px;
overflow-y: auto;
}
.grid-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10px, 1fr));
grid-auto-rows: 10px;
grid-auto-flow: dense;
gap: 15px;
max-width: 100%;
box-sizing: border-box;
}
.polaroid {
position: relative;
background-color: #e8e8ea;
box-shadow: 0 0 2px 0 #f4f4f6 inset,
1px 5px 5px 0px #99999955;
border-radius: 2px;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
transition: background-color 0.3s ease;
max-width: 100%;
box-sizing: border-box;
}
.polaroid img {
max-width: 100%;
height: auto;
display: block;
flex-grow: 1;
object-fit: contain;
}
.date-overlay {
position: absolute;
bottom: 4rem;
font-size: 1rem;
right: 1rem;
text-align: right;
color: #ec5a11;
font-family: "Noto Sans Mono", monospace;
font-size: 0.7rem;
opacity: 0;
text-shadow: 0 0 2px #ec5a11;
transition: opacity 0.3s ease;
}
.polaroid:hover .date-overlay {
opacity: 0.8;
}
.polaroid .caption {
margin-top: 10px;
display: block;
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
align-self: flex-end;
order: 0;
text-align: right;
line-height: 70%;
font-size: 0.75rem;
}
.noto-sans-mono-font {
font-family: "Noto Sans Mono", monospace;
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
font-variation-settings:
"wdth" 100;
}
.sidebar {
width: 20rem;
background-color: #f0f0f0;
padding: 20px;
box-sizing: border-box;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.sidebar-title {
font-size: 1rem;
text-align: right;
}
.sidebar-nav {
font-size: 1rem;
flex-grow: 1;
justify-content: center;
text-align: right;
}
.sidebar-nav ul {
list-style-type: none;
padding: 0;
}
.sidebar-nav a {
text-decoration: none;
color: {{ accent_color }};
}
.nav-toggle {
display: none;
cursor: pointer;
font-size: 1.5rem;
text-align: right;
}
@media (max-width: 768px) {
.main-content {
padding: 10px;
}
.grid-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.polaroid {
width: 100%;
max-width: 100vw;
height: auto;
}
}
@media (max-width: 1024px) {
body {
flex-direction: column;
}
.sidebar {
width: 100%;
height: auto;
position: static;
padding: 10px;
overflow-y: visible;
}
.sidebar-nav ul {
display: none;
}
.nav-toggle {
display: block;
}
.sidebar-nav.active ul {
display: block;
}
}
</style>
</head>
<body>
<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><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>
</div>
</div>
<div class="main-content">
<div class="grid-container" id="polaroid-grid"></div>
<div id="loading">Loading more images...</div>
</div>
<script>
const gridContainer = document.getElementById('polaroid-grid');
const loadingIndicator = document.getElementById('loading');
const baseSize = 110; // Size of one grid cell in pixels
let page = 1;
let isLoading = false;
let hasMore = true;
function createPolaroid(polaroid) {
const polaroidElement = document.createElement('div');
polaroidElement.className = 'polaroid';
if (polaroid.height > polaroid.width) {
polaroidElement.classList.add('vertical');
}
polaroidElement.style.backgroundColor = `${polaroid.highlightColor}33`;
const img = document.createElement('img');
img.alt = polaroid.caption;
img.setAttribute('data-original-width', polaroid.width);
img.setAttribute('data-original-height', polaroid.height);
img.setAttribute('data-base-src', polaroid.imgSrc);
img.src = polaroid.imgSrc;
//img.onload = () => loadOptimalThumbnail(img); // Load optimal thumbnail after initial load
const dateOverlay = document.createElement('div');
dateOverlay.className = 'date-overlay';
dateOverlay.textContent = polaroid.date;
const caption = document.createElement('div');
caption.className = 'caption noto-sans-mono-font';
caption.textContent = polaroid.technicalInfo;
polaroidElement.appendChild(img);
polaroidElement.appendChild(dateOverlay);
polaroidElement.appendChild(caption);
return polaroidElement;
}
function calculateGridSpan(dimension) {
return Math.ceil(dimension / baseSize);
}
function positionPolaroid(polaroidElement, polaroid) {
const width = calculateGridSpan(polaroid.width + 20); // Add 20px for padding
const height = calculateGridSpan(polaroid.height + 40) + 1; // Add 40px for padding and caption
polaroidElement.style.gridColumnEnd = `span ${width}`;
polaroidElement.style.gridRowEnd = `span ${height}`;
}
function getOptimalThumbnailSize(width, height) {
const sizes = [256, 512, 768, 1024, 1536, 2048];
const maxDimension = Math.max(width, height);
return sizes.find(size => size >= maxDimension) || sizes[sizes.length - 1];
}
function loadOptimalThumbnail(img) {
// Use a small delay to ensure the image has rendered
setTimeout(() => {
const containerWidth = img.offsetWidth;
const containerHeight = img.offsetHeight;
const devicePixelRatio = window.devicePixelRatio || 1;
// If dimensions are still 0, use the original image dimensions
const width = containerWidth || parseInt(img.getAttribute('data-original-width'));
const height = containerHeight || parseInt(img.getAttribute('data-original-height'));
const optimalSize = getOptimalThumbnailSize(
width * devicePixelRatio,
height * devicePixelRatio
);
const newSrc = img.getAttribute('data-base-src').replace('1536_', `${optimalSize}_`);
if (newSrc !== img.src) {
const tempImg = new Image();
tempImg.onload = function() {
img.src = newSrc;
img.style.opacity = 1;
};
tempImg.src = newSrc;
img.style.opacity = 0.5;
}
}, 100); // 100ms delay
}
function handleResize() {
const polaroids = document.querySelectorAll('.polaroid');
polaroids.forEach(polaroid => {
const img = polaroid.querySelector('img');
const width = parseInt(img.getAttribute('data-original-width'));
const height = parseInt(img.getAttribute('data-original-height'));
positionPolaroid(polaroid, { width, height });
//loadOptimalThumbnail(img);
});
}
async function loadImages() {
if (isLoading || !hasMore) return;
isLoading = true;
loadingIndicator.style.display = 'block';
try {
const response = await fetch(`/api/images?page=${page}`);
const data = await response.json();
data.images.forEach(polaroid => {
const polaroidElement = createPolaroid(polaroid);
positionPolaroid(polaroidElement, polaroid);
gridContainer.appendChild(polaroidElement);
// loadOptimalThumbnail is now called in the img.onload event
});
hasMore = data.hasMore;
page++;
} catch (error) {
console.error('Error loading images:', error);
} finally {
isLoading = false;
loadingIndicator.style.display = 'none';
}
}
function handleScroll() {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
loadImages();
}
}
window.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleResize);
loadImages(); // Initial load
function setupNavToggle() {
const navToggle = document.querySelector('.nav-toggle');
const sidebarNav = document.querySelector('.sidebar-nav');
navToggle.addEventListener('click', () => {
sidebarNav.classList.toggle('active');
});
}
setupNavToggle();
</script>
</body>
</html>