Add NextSnap PWA with photo gallery viewer and continuous capture

Offline-first photo capture app for Nextcloud with:
- Camera capture with continuous mode (auto-reopens after each photo)
- File browser with fullscreen image gallery, swipe navigation, and rename
- Upload queue with background sync engine
- Admin panel for Nextcloud user management
- Service worker for offline-first caching (v13)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 04:53:13 -06:00
commit cad4118f72
55 changed files with 9038 additions and 0 deletions

1
app/routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Routes package initialization

131
app/routes/admin.py Normal file
View File

@@ -0,0 +1,131 @@
from flask import Blueprint, request, jsonify, session
from app.services.nextcloud import NextcloudClient
from config import Config
import base64
bp = Blueprint('admin', __name__, url_prefix='/api/admin')
def _get_nc_client():
"""Get authenticated Nextcloud client from session."""
if 'username' not in session or 'password' not in session:
return None
username = session['username']
password = base64.b64decode(session['password'].encode()).decode()
return NextcloudClient(Config.NEXTCLOUD_URL, username, password)
def _check_admin():
"""Check if current user has admin privileges."""
if not session.get('is_admin', False):
return False
return True
@bp.route('/users', methods=['GET'])
def list_users():
"""List all Nextcloud users (admin only)."""
if not _check_admin():
return jsonify({'error': 'Admin privileges required'}), 403
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
result = nc_client.ocs_get_users()
if not result.get('success'):
return jsonify({'error': result.get('error', 'Failed to list users')}), 500
return jsonify(result), 200
@bp.route('/users', methods=['POST'])
def create_user():
"""Create a new Nextcloud user (admin only)."""
if not _check_admin():
return jsonify({'error': 'Admin privileges required'}), 403
data = request.get_json()
if not data or 'username' not in data or 'password' not in data:
return jsonify({'error': 'Username and password required'}), 400
username = data['username'].strip()
password = data['password']
email = data.get('email', '').strip()
displayname = data.get('displayName', '').strip()
groups = data.get('groups', [])
if not username or not password:
return jsonify({'error': 'Username and password cannot be empty'}), 400
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
result = nc_client.ocs_create_user(
username=username,
password=password,
email=email if email else None,
displayname=displayname if displayname else None,
groups=groups if groups else None
)
if not result.get('success'):
return jsonify({'error': result.get('error', 'Failed to create user')}), 500
return jsonify(result), 201
@bp.route('/users/<username>/enable', methods=['PUT'])
def enable_user(username):
"""Enable a user account (admin only)."""
if not _check_admin():
return jsonify({'error': 'Admin privileges required'}), 403
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
result = nc_client.ocs_enable_user(username)
if not result.get('success'):
return jsonify({'error': result.get('error', 'Failed to enable user')}), 500
return jsonify(result), 200
@bp.route('/users/<username>/disable', methods=['PUT'])
def disable_user(username):
"""Disable a user account (admin only)."""
if not _check_admin():
return jsonify({'error': 'Admin privileges required'}), 403
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
result = nc_client.ocs_disable_user(username)
if not result.get('success'):
return jsonify({'error': result.get('error', 'Failed to disable user')}), 500
return jsonify(result), 200
@bp.route('/users/<username>', methods=['DELETE'])
def delete_user(username):
"""Delete a user account (admin only)."""
if not _check_admin():
return jsonify({'error': 'Admin privileges required'}), 403
# Prevent self-deletion
if username == session.get('username'):
return jsonify({'error': 'Cannot delete your own account'}), 400
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
result = nc_client.ocs_delete_user(username)
if not result.get('success'):
return jsonify({'error': result.get('error', 'Failed to delete user')}), 500
return jsonify(result), 200

72
app/routes/auth.py Normal file
View File

@@ -0,0 +1,72 @@
from flask import Blueprint, request, jsonify, session
from app.services.nextcloud import NextcloudClient
from config import Config
import base64
bp = Blueprint('auth', __name__, url_prefix='/api/auth')
def _encrypt_password(password: str) -> str:
"""Simple base64 encoding for password storage in session."""
return base64.b64encode(password.encode()).decode()
def _decrypt_password(encrypted: str) -> str:
"""Decode base64 encoded password from session."""
return base64.b64decode(encrypted.encode()).decode()
@bp.route('/login', methods=['POST'])
def login():
"""Authenticate user against Nextcloud."""
data = request.get_json()
if not data or 'username' not in data or 'password' not in data:
return jsonify({'error': 'Username and password required'}), 400
username = data['username'].strip()
password = data['password']
if not username or not password:
return jsonify({'error': 'Username and password cannot be empty'}), 400
try:
# Validate credentials by attempting to connect to Nextcloud
nc_client = NextcloudClient(Config.NEXTCLOUD_URL, username, password)
if not nc_client.verify_credentials():
return jsonify({'error': 'Invalid username or password'}), 401
# Check if user is admin
is_admin = nc_client.check_admin()
# Store credentials in session
session['username'] = username
session['password'] = _encrypt_password(password)
session['is_admin'] = is_admin
return jsonify({
'success': True,
'username': username,
'is_admin': is_admin
}), 200
except Exception as e:
return jsonify({'error': f'Authentication failed: {str(e)}'}), 500
@bp.route('/logout', methods=['POST'])
def logout():
"""Clear user session."""
session.clear()
return jsonify({'success': True}), 200
@bp.route('/status', methods=['GET'])
def status():
"""Check current authentication status."""
if 'username' in session:
return jsonify({
'authenticated': True,
'username': session['username'],
'is_admin': session.get('is_admin', False)
}), 200
else:
return jsonify({
'authenticated': False
}), 200

35
app/routes/health.py Normal file
View File

@@ -0,0 +1,35 @@
from flask import Blueprint, jsonify
import requests
from config import Config
bp = Blueprint('health', __name__, url_prefix='/api')
@bp.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint for monitoring."""
return jsonify({
'status': 'healthy',
'service': 'nextsnap',
'version': '1.0.0'
}), 200
@bp.route('/health/nextcloud', methods=['GET'])
def nextcloud_health():
"""Check connectivity to Nextcloud instance."""
try:
nc_url = Config.NEXTCLOUD_URL
response = requests.get(f"{nc_url}/status.php", timeout=5)
response.raise_for_status()
return jsonify({
'status': 'healthy',
'nextcloud_url': nc_url,
'nextcloud_reachable': True
}), 200
except Exception as e:
return jsonify({
'status': 'degraded',
'nextcloud_url': Config.NEXTCLOUD_URL,
'nextcloud_reachable': False,
'error': str(e)
}), 503

84
app/routes/views.py Normal file
View File

@@ -0,0 +1,84 @@
from flask import Blueprint, render_template, redirect, url_for, session, jsonify
bp = Blueprint('views', __name__)
@bp.route('/')
def index():
"""Root route - redirect to capture if logged in, otherwise show login."""
if 'username' in session:
return redirect(url_for('views.capture'))
return render_template('login.html')
@bp.route('/login')
def login_page():
"""Render login page."""
if 'username' in session:
return redirect(url_for('views.capture'))
return render_template('login.html')
@bp.route('/capture')
def capture():
"""Render capture page (requires authentication)."""
if 'username' not in session:
return redirect(url_for('views.login_page'))
return render_template(
'capture.html',
username=session['username'],
show_nav=True,
is_admin=session.get('is_admin', False)
)
@bp.route('/queue')
def queue():
"""Render queue page (requires authentication)."""
if 'username' not in session:
return redirect(url_for('views.login_page'))
return render_template(
'queue.html',
username=session['username'],
show_nav=True,
is_admin=session.get('is_admin', False)
)
@bp.route('/browser')
def browser():
"""Render file browser page (requires authentication)."""
if 'username' not in session:
return redirect(url_for('views.login_page'))
return render_template(
'browser.html',
username=session['username'],
show_nav=True,
is_admin=session.get('is_admin', False)
)
@bp.route('/reviewer')
def reviewer():
"""Render photo reviewer page (requires authentication)."""
if 'username' not in session:
return redirect(url_for('views.login_page'))
return render_template(
'reviewer.html',
username=session['username'],
show_nav=False,
is_admin=session.get('is_admin', False)
)
@bp.route('/admin')
def admin():
"""Render admin page (requires admin privileges)."""
if 'username' not in session:
return redirect(url_for('views.login_page'))
if not session.get('is_admin', False):
return "Access denied: Admin privileges required", 403
return render_template(
'admin.html',
username=session['username'],
show_nav=True,
is_admin=True
)
@bp.route('/test')
def test():
"""Test page for camera functionality."""
return render_template('test.html')

186
app/routes/webdav_proxy.py Normal file
View File

@@ -0,0 +1,186 @@
from flask import Blueprint, request, jsonify, send_file
from app.services.auth import require_auth, get_current_user
from app.services.nextcloud import NextcloudClient
from config import Config
from io import BytesIO
from PIL import Image
bp = Blueprint('webdav', __name__, url_prefix='/api/files')
def _get_nc_client():
"""Get Nextcloud client for current user."""
user = get_current_user()
if not user:
return None
return NextcloudClient(Config.NEXTCLOUD_URL, user['username'], user['password'])
@bp.route('/list', methods=['GET'])
@require_auth
def list_files():
"""List files and directories at the given path."""
path = request.args.get('path', '/')
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
try:
result = nc_client.propfind(path, depth=1)
return jsonify(result), 200
except Exception as e:
return jsonify({'error': f'Failed to list files: {str(e)}'}), 500
@bp.route('/upload', methods=['PUT', 'POST'])
@require_auth
def upload_file():
"""Upload a file to Nextcloud."""
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
# Get target path from query parameter or form data
target_path = request.args.get('path') or request.form.get('path')
if not target_path:
return jsonify({'error': 'Target path is required'}), 400
# Get file data
if 'file' in request.files:
# Multipart upload
file = request.files['file']
file_data = file.read()
filename = file.filename
else:
# Raw binary upload
file_data = request.get_data()
filename = request.args.get('filename', 'upload.jpg')
if not file_data:
return jsonify({'error': 'No file data provided'}), 400
# Construct full path
full_path = f"{target_path.rstrip('/')}/{filename}"
try:
result = nc_client.put_file(full_path, file_data)
return jsonify({
'success': True,
'path': full_path,
'url': result.get('url', '')
}), 200
except Exception as e:
return jsonify({'error': f'Upload failed: {str(e)}'}), 500
@bp.route('/verify', methods=['HEAD', 'GET'])
@require_auth
def verify_file():
"""Verify that a file exists on Nextcloud."""
path = request.args.get('path')
if not path:
return jsonify({'error': 'Path is required'}), 400
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
try:
exists = nc_client.head(path)
if exists:
return jsonify({'exists': True}), 200
else:
return jsonify({'exists': False}), 404
except Exception as e:
return jsonify({'error': f'Verification failed: {str(e)}'}), 500
@bp.route('/mkdir', methods=['POST'])
@require_auth
def make_directory():
"""Create a new directory on Nextcloud."""
data = request.get_json()
if not data or 'path' not in data:
return jsonify({'error': 'Path is required'}), 400
path = data['path']
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
try:
nc_client.mkcol(path)
return jsonify({'success': True, 'path': path}), 201
except Exception as e:
return jsonify({'error': f'Failed to create directory: {str(e)}'}), 500
@bp.route('/rename', methods=['POST'])
@require_auth
def rename_file():
"""Rename or move a file on Nextcloud."""
data = request.get_json()
if not data or 'source' not in data or 'destination' not in data:
return jsonify({'error': 'Source and destination paths are required'}), 400
source = data['source']
destination = data['destination']
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
try:
nc_client.move(source, destination)
return jsonify({
'success': True,
'source': source,
'destination': destination
}), 200
except Exception as e:
return jsonify({'error': f'Failed to rename file: {str(e)}'}), 500
@bp.route('/thumbnail', methods=['GET'])
@require_auth
def get_thumbnail():
"""Generate and return a thumbnail of an image file."""
path = request.args.get('path')
size = request.args.get('size', '256')
if not path:
return jsonify({'error': 'Path is required'}), 400
try:
size = int(size)
if size < 32 or size > 1024:
size = 256
except ValueError:
size = 256
nc_client = _get_nc_client()
if not nc_client:
return jsonify({'error': 'Not authenticated'}), 401
try:
# Download the file from Nextcloud
file_data = nc_client.get_file(path)
# Open image and create thumbnail
image = Image.open(BytesIO(file_data))
# Convert to RGB if necessary (for PNG with transparency, etc.)
if image.mode not in ('RGB', 'L'):
image = image.convert('RGB')
# Create thumbnail maintaining aspect ratio
image.thumbnail((size, size), Image.Resampling.LANCZOS)
# Save to BytesIO
output = BytesIO()
image.save(output, format='JPEG', quality=85)
output.seek(0)
return send_file(
output,
mimetype='image/jpeg',
as_attachment=False,
download_name=f'thumbnail_{size}.jpg'
)
except Exception as e:
return jsonify({'error': f'Failed to generate thumbnail: {str(e)}'}), 500