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:
1
app/routes/__init__.py
Normal file
1
app/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routes package initialization
|
||||
131
app/routes/admin.py
Normal file
131
app/routes/admin.py
Normal 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
72
app/routes/auth.py
Normal 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
35
app/routes/health.py
Normal 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
84
app/routes/views.py
Normal 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
186
app/routes/webdav_proxy.py
Normal 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
|
||||
Reference in New Issue
Block a user