From f90cc7691cb1d9259a808d36c2d644a29b433145 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Tue, 15 Apr 2025 23:07:17 -0400 Subject: [PATCH 1/8] task tracking --- README.md | 3 +++ TASK.md | 12 +++--------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f3b4d11..b6bbb52 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,13 @@ A lightweight Flask-based file upload and download server with a modern UI. Perfect for temporary file sharing and mobile app log uploads. +The Android mobile app for this server can be found at: https://github.com/mattintech/simplefileupload-android + ## Features - Clean, modern UI for file downloads - Secure API authentication for uploads - Mobile-friendly upload endpoint +- Specifically designed for the simplefileupload-android app - Secure 6-digit alphanumeric code system - Automatic file cleanup - Configurable download limits and expiry times diff --git a/TASK.md b/TASK.md index 03247e9..4beaaf7 100644 --- a/TASK.md +++ b/TASK.md @@ -54,23 +54,17 @@ - [ ] Add audit logging ### Features -- [ ] Multi-file upload support -- [ ] Progress bar for uploads -- [ ] Email notifications -- [ ] QR code generation -- [ ] Preview for images/PDFs +- [x] Multi-file upload support ### Infrastructure - [ ] Add unit tests -- [ ] Set up CI/CD pipeline -- [ ] Implement persistent storage +- [x] Set up CI/CD pipeline +- [x] Implement persistent storage - [ ] Add monitoring/metrics - [ ] Create backup system - [ ] Add health check endpoints ### UI/UX - [ ] Dark mode support -- [ ] Drag-and-drop uploads -- [ ] Improved mobile UI - [ ] Accessibility improvements - [ ] Internationalization From d4be29386baf642df4ee7bfee024843b83b106b1 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Wed, 16 Apr 2025 21:40:35 -0400 Subject: [PATCH 2/8] Adding chunk file support --- .gitignore | 1 + LARGE_UPLOAD.md | 326 +++++++++++++++++++++++++++++ README.md | 95 +++++++++ TASK.md | 9 + src/app.py | 17 +- src/controllers/file_controller.py | 263 ++++++++++++++++++++++- src/start.py | 2 +- 7 files changed, 710 insertions(+), 3 deletions(-) create mode 100644 LARGE_UPLOAD.md diff --git a/.gitignore b/.gitignore index 4214b49..e79592d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ tmp/ uploads/ file_metadata.json +chunk_metadata.json .env # Python diff --git a/LARGE_UPLOAD.md b/LARGE_UPLOAD.md new file mode 100644 index 0000000..5cf61f5 --- /dev/null +++ b/LARGE_UPLOAD.md @@ -0,0 +1,326 @@ +# Large File Upload Analysis and Improvements + +## Current Implementation Analysis + +### Client Side (Android) + +The SimpleLogUpload Android application is designed to upload large log files (up to 220MB) to a simple file upload server. The current implementation uses: + +- OkHttp3 for HTTP requests +- A single monolithic file upload in one HTTP request +- Fixed network timeouts: + - Connection timeout: 60 seconds + - Write timeout: 180 seconds (3 minutes) + - Read timeout: 60 seconds +- Basic retry on connection failure + +Behavior by network type: +- **WiFi**: Uploads work successfully +- **5G/Mobile**: Socket timeouts occur during upload (`java.net.SocketException: Socket closed`) + +### Server Side (Python/Flask) + +The server is a simple Flask application with: +- Standard file upload endpoint (`/upload`) +- No explicit chunked upload support +- Uses Gunicorn as WSGI server without specific timeout configurations +- No specific optimizations for large files + +## Problem Root Causes + +1. **Mobile Network Limitations**: + - Mobile carriers may have connection restrictions for large uploads + - 5G networks may have more aggressive connection management + - Network fluctuations are more common in mobile networks + +2. **Timeout Configuration**: + - Current timeout settings may be insufficient for large files over mobile networks + - Server and client timeouts aren't aligned + +3. **Single Upload Approach**: + - The entire 220MB file is uploaded in a single HTTP request + - No resume capability if connection drops + - No progress tracking or partial file recovery + +4. **Missing Network Adaptability**: + - No differentiation between WiFi and mobile network settings + - No adaptive timeout or retry mechanisms + - No connection monitoring during upload + +## Recommended Solutions + +### 1. Implement Chunked Uploading + +Develop a proper chunked upload system with these features: + +```java +// Example chunked upload strategy +public class ChunkedUploadManager { + // Adjust these based on testing + private static final int DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024; // 5MB chunks + private static final int MAX_RETRY_ATTEMPTS = 3; + private static final int RETRY_DELAY_MS = 1000; + + // File metadata + private File file; + private int chunkSize; + private int totalChunks; + private String uploadId; + + // Network state + private boolean isWifiConnection; + private boolean isPaused; + + // Tracking + private int currentChunkIndex; + private Set uploadedChunks = new HashSet<>(); + + public ChunkedUploadManager(File file, boolean isWifiConnection) { + this.file = file; + this.isWifiConnection = isWifiConnection; + + // Adjust chunk size based on network type + this.chunkSize = isWifiConnection ? DEFAULT_CHUNK_SIZE : (DEFAULT_CHUNK_SIZE / 2); + + this.totalChunks = (int) Math.ceil((double) file.length() / chunkSize); + } + + // Methods for initialization, chunk upload, tracking, etc. +} +``` + +**Server Changes Required**: +```python +# New endpoint for chunked uploads +@app.route('/upload/chunk', methods=['POST']) +@require_client_key +def upload_chunk(): + # Get parameters + chunk_index = int(request.form.get('chunk_index')) + total_chunks = int(request.form.get('total_chunks')) + upload_id = request.form.get('upload_id') + filename = request.form.get('filename') + + # Handle chunk file + chunk_file = request.files['chunk'] + + # Process chunk + # ... + + return jsonify({'success': True}) + +# New endpoint to start chunked upload session +@app.route('/upload/start', methods=['POST']) +@require_client_key +def start_chunked_upload(): + # Initialize a new upload session + upload_id = generate_unique_id() + + # Store upload metadata + # ... + + return jsonify({'upload_id': upload_id}) + +# New endpoint to complete chunked upload +@app.route('/upload/complete', methods=['POST']) +@require_client_key +def complete_chunked_upload(): + # Combine chunks into final file + # ... + + return jsonify({'code': result_code}) +``` + +### 2. Adaptive Network Settings + +Implement network-aware settings that adjust based on connection type: + +```java +private void configureNetworkSettings() { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); + boolean isWifi = activeNetwork != null && activeNetwork.getType() == ConnectivityManager.TYPE_WIFI; + + // Adjust settings based on network type + if (isWifi) { + // WiFi settings - can be more aggressive + CONNECTION_TIMEOUT = 60; // seconds + WRITE_TIMEOUT = 180; // seconds + READ_TIMEOUT = 60; // seconds + CHUNK_SIZE = 10_485_760; // 10MB + } else { + // Mobile network settings - more conservative + CONNECTION_TIMEOUT = 30; // seconds + WRITE_TIMEOUT = 90; // seconds + READ_TIMEOUT = 30; // seconds + CHUNK_SIZE = 2_097_152; // 2MB + } +} +``` + +### 3. Network Monitoring and Handling + +Implement network state monitoring to handle: +- Network type changes during upload +- Connection losses +- Bandwidth fluctuations + +```java +private void registerNetworkCallback() { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkRequest.Builder builder = new NetworkRequest.Builder(); + + cm.registerNetworkCallback(builder.build(), new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + // Resume upload if paused + resumeUploadIfPaused(); + } + + @Override + public void onLost(Network network) { + // Pause upload + pauseUpload(); + } + + @Override + public void onCapabilitiesChanged(Network network, NetworkCapabilities capabilities) { + // Adjust strategies based on new network capabilities + boolean isUnmetered = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED); + adjustUploadParameters(isUnmetered); + } + }); +} +``` + +### 4. Progressive Backoff and Retry Strategy + +Implement a more sophisticated retry mechanism: + +```java +private void uploadChunkWithRetry(int chunkIndex) { + int retryCount = 0; + int maxRetries = 5; + long backoffMs = 1000; // Start with 1 second + + while (retryCount < maxRetries) { + try { + // Attempt to upload chunk + boolean success = uploadChunk(chunkIndex); + if (success) { + return; // Success, exit retry loop + } + } catch (IOException e) { + Log.e(TAG, "Chunk upload failed: " + e.getMessage()); + } + + retryCount++; + if (retryCount < maxRetries) { + // Calculate backoff with exponential increase and jitter + long jitter = (long) (Math.random() * 0.3 * backoffMs); + long sleepTime = backoffMs + jitter; + + Log.d(TAG, "Retrying chunk " + chunkIndex + " after " + sleepTime + "ms (attempt " + retryCount + ")"); + + try { + Thread.sleep(sleepTime); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; + } + + // Increase backoff for next iteration + backoffMs = Math.min(backoffMs * 2, 30000); // Cap at 30 seconds + } + } + + // If we get here, all retries failed + Log.e(TAG, "All retry attempts failed for chunk " + chunkIndex); + reportChunkFailed(chunkIndex); +} +``` + +### 5. Enhanced Progress Tracking and Resumability + +Implement more granular progress tracking for better user experience and resumability: + +```java +public class UploadProgress { + private long totalBytes; + private long uploadedBytes; + private int totalChunks; + private Set completedChunks; + private boolean isComplete; + private String sessionId; + + // Persist progress to disk for resumability + public void saveProgress() { + SharedPreferences prefs = context.getSharedPreferences("upload_progress", Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + + // Store progress data + editor.putString("session_" + sessionId + "_chunks", TextUtils.join(",", completedChunks)); + editor.putLong("session_" + sessionId + "_bytes", uploadedBytes); + editor.putBoolean("session_" + sessionId + "_complete", isComplete); + + editor.apply(); + } + + // Load progress for resuming uploads + public static UploadProgress loadProgress(Context context, String sessionId) { + // Implementation details + } +} +``` + +### 6. Server-Side Optimizations + +Modify the server to better handle large file uploads: + +1. Implement explicit chunked upload support +2. Configure Gunicorn timeouts appropriately: + ``` + gunicorn wsgi:app --bind 0.0.0.0:7777 --timeout 300 --workers 4 + ``` +3. Add support for resumable uploads with progress tracking +4. Implement temporary file cleanup for abandoned uploads + +## Implementation Priority + +1. **First Phase**: + - Implement basic chunked uploading + - Add network type detection and adaptive settings + - Configure server for higher timeouts + +2. **Second Phase**: + - Add upload resumability + - Implement network state monitoring + - Enhanced progress tracking + - Progressive retry mechanism + +3. **Final Phase**: + - Server-side optimizations for chunked uploads + - Background upload functionality + - Bandwidth control options + +## Testing Strategy + +1. Test uploading the same 220MB file under various network conditions: + - Stable WiFi + - Congested WiFi + - 5G with strong signal + - 5G with weak signal + - 4G fallback + - Network transitions (WiFi → Mobile) + +2. Measure and compare: + - Success rate of uploads + - Total upload time + - Battery consumption + - Data usage efficiency + +## Conclusion + +The socket timeout issues on 5G networks stem from trying to upload large files in a single HTTP request without adapting to the characteristics of mobile networks. By implementing chunked uploads with resume capability, network-aware configurations, and robust error handling, we can create a more reliable upload solution for all network types. + +The most critical issue to address is the lack of chunking, which leaves the application vulnerable to connection interruptions, particularly on mobile networks where connections may be less stable than WiFi. diff --git a/README.md b/README.md index b6bbb52..89b8d8d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ The Android mobile app for this server can be found at: https://github.com/matti - Clean, modern UI for file downloads - Secure API authentication for uploads - Mobile-friendly upload endpoint +- Chunked upload support for large files - Specifically designed for the simplefileupload-android app - Secure 6-digit alphanumeric code system - Automatic file cleanup @@ -133,6 +134,100 @@ curl -X POST https://localhost:7777/upload \ } ``` +### POST /upload/start +Start a chunked upload session for large files. + +**Authentication:** +- Required: `X-Client-Key` header with your API key + +**Form Fields:** +- `filename`: Name of the file being uploaded (required) +- `total_chunks`: Total number of chunks to expect (required) +- `max_downloads`: Maximum download count (optional, default: 1) +- `expiry_hours`: Hours until expiry (optional, default: 24) + +**Example using curl:** +```bash +# Start a chunked upload session +curl -X POST http://localhost:7777/upload/start \ + -H "X-Client-Key: your-secret-key" \ + -F "filename=largefile.zip" \ + -F "total_chunks=10" \ + -F "max_downloads=5" \ + -F "expiry_hours=48" +``` + +**Response:** +```json +{ + "upload_id": "abC123dEf456gHij" +} +``` + +### POST /upload/chunk +Upload a single chunk of a file. + +**Authentication:** +- Required: `X-Client-Key` header with your API key + +**Form Fields:** +- `upload_id`: Upload session ID from /upload/start (required) +- `chunk_index`: Index of this chunk, starting from 0 (required) +- `chunk`: The file chunk data (required) + +**Example using curl:** +```bash +# Upload chunk 0 +curl -X POST http://localhost:7777/upload/chunk \ + -H "X-Client-Key: your-secret-key" \ + -F "upload_id=abC123dEf456gHij" \ + -F "chunk_index=0" \ + -F "chunk=@chunk_0.bin" +``` + +**Response:** +```json +{ + "success": true, + "received_chunks": 5, + "total_chunks": 10 +} +``` + +### POST /upload/complete +Complete a chunked upload session. + +**Authentication:** +- Required: `X-Client-Key` header with your API key + +**Form Fields:** +- `upload_id`: Upload session ID from /upload/start (required) + +**Example using curl:** +```bash +# Complete the chunked upload +curl -X POST http://localhost:7777/upload/complete \ + -H "X-Client-Key: your-secret-key" \ + -F "upload_id=abC123dEf456gHij" +``` + +**Response:** +```json +{ + "code": "ABC123" +} +``` + +**Error Responses for Chunked Upload Endpoints:** +- 401: Invalid or missing client key +- 400: Missing required parameters or invalid request +- 404: Invalid upload session ID +```json +{ + "error": "Error message here" +} +``` + ### GET /download/{code} Download a file using a 6-character code. diff --git a/TASK.md b/TASK.md index 4beaaf7..4b137b4 100644 --- a/TASK.md +++ b/TASK.md @@ -64,6 +64,15 @@ - [ ] Create backup system - [ ] Add health check endpoints +### Chunked Upload Support +- [ ] Implement new API endpoints for chunked uploads (/upload/chunk, /upload/start, /upload/complete) +- [ ] Add server-side chunk management and reassembly +- [ ] Configure Gunicorn with appropriate timeouts for large uploads +- [ ] Implement temporary storage for in-progress chunked uploads +- [ ] Add automatic cleanup for abandoned uploads +- [ ] Create progress tracking for partially complete uploads +- [ ] Test with large files (>100MB) over various connection types + ### UI/UX - [ ] Dark mode support - [ ] Accessibility improvements diff --git a/src/app.py b/src/app.py index ac75600..8a4aa82 100644 --- a/src/app.py +++ b/src/app.py @@ -3,7 +3,11 @@ from functools import wraps from flask import Flask, request, jsonify, send_from_directory from werkzeug.utils import secure_filename import utils.mLogger as log -from controllers.file_controller import upload_file, download_file, delete_file +from controllers.file_controller import ( + upload_file, download_file, delete_file, + start_chunked_upload, upload_chunk, verify_chunks, complete_chunked_upload, + cleanup_expired_files, cleanup_abandoned_uploads +) from views import views app = Flask(__name__) @@ -13,6 +17,10 @@ app.config['CLIENT_KEY'] = os.environ.get('CLIENT_KEY', 'default-secret-key') if not os.path.exists(app.config['UPLOAD_FOLDER']): os.makedirs(app.config['UPLOAD_FOLDER']) +# Run cleanup on startup +cleanup_expired_files() +cleanup_abandoned_uploads() + def require_client_key(f): @wraps(f) def decorated_function(*args, **kwargs): @@ -33,12 +41,19 @@ def require_client_key(f): # Route for file uploads app.route('/upload', methods=['POST'])(require_client_key(upload_file)) +# Routes for chunked uploads +app.route('/upload/start', methods=['POST'])(require_client_key(start_chunked_upload)) +app.route('/upload/chunk', methods=['POST'])(require_client_key(upload_chunk)) +app.route('/upload/verify', methods=['POST'])(require_client_key(verify_chunks)) +app.route('/upload/complete', methods=['POST'])(require_client_key(complete_chunked_upload)) + # Route for downloading the file app.route('/download/', methods=['GET'])(download_file) # Route for deleting the file app.route('/delete/', methods=['DELETE'])(delete_file) +# Register frontend views app.register_blueprint(views) if __name__ == '__main__': diff --git a/src/controllers/file_controller.py b/src/controllers/file_controller.py index 27416e8..3b203c2 100644 --- a/src/controllers/file_controller.py +++ b/src/controllers/file_controller.py @@ -2,6 +2,7 @@ import os import random import string import json +import shutil from datetime import datetime, timedelta from flask import request, jsonify, send_from_directory, render_template from werkzeug.utils import secure_filename @@ -12,9 +13,17 @@ from models.file_metadata import FileMetadata UPLOAD_FOLDER = os.path.abspath('tmp/uploads') METADATA_FILE = 'file_metadata.json' +# Constants for chunked uploads +CHUNKS_FOLDER = os.path.join(UPLOAD_FOLDER, 'chunks') +CHUNK_METADATA_FILE = 'chunk_metadata.json' + if not os.path.exists(UPLOAD_FOLDER): os.makedirs(UPLOAD_FOLDER) +# Ensure chunks directory exists +if not os.path.exists(CHUNKS_FOLDER): + os.makedirs(CHUNKS_FOLDER) + def load_metadata(): if os.path.exists(METADATA_FILE): with open(METADATA_FILE, 'r') as f: @@ -25,9 +34,22 @@ def save_metadata(metadata): with open(METADATA_FILE, 'w') as f: json.dump(metadata, f) +def load_chunk_metadata(): + if os.path.exists(CHUNK_METADATA_FILE): + with open(CHUNK_METADATA_FILE, 'r') as f: + return json.load(f) + return {} + +def save_chunk_metadata(metadata): + with open(CHUNK_METADATA_FILE, 'w') as f: + json.dump(metadata, f) + def generate_unique_code(): return ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) +def generate_upload_id(): + return ''.join(random.choices(string.ascii_uppercase + string.digits + string.ascii_lowercase, k=16)) + def upload_file(): if 'file' not in request.files: log.e("No File Part") @@ -170,4 +192,243 @@ def cleanup_expired_files(): updated_metadata[code] = file_info save_metadata(updated_metadata) - log.i("Cleanup completed: Expired and overused files removed.") \ No newline at end of file + log.i("Cleanup completed: Expired and overused files removed.") + +def start_chunked_upload(): + # Get metadata from request + filename = request.form.get('filename') + if not filename: + return jsonify({'error': 'Filename is required'}), 400 + + total_chunks = request.form.get('total_chunks') + if not total_chunks or not total_chunks.isdigit(): + return jsonify({'error': 'Invalid total_chunks parameter'}), 400 + + # Generate unique upload ID and create chunk directory + upload_id = generate_upload_id() + chunk_dir = os.path.join(CHUNKS_FOLDER, upload_id) + os.makedirs(chunk_dir) + + # Store upload session metadata + metadata = load_chunk_metadata() + metadata[upload_id] = { + 'filename': secure_filename(filename), + 'total_chunks': int(total_chunks), + 'received_chunks': [], + 'created_at': datetime.now().isoformat(), + 'chunk_dir': chunk_dir, + 'max_downloads': int(request.form.get('max_downloads', 1)), + 'expiry_hours': int(request.form.get('expiry_hours', 24)) + } + save_chunk_metadata(metadata) + + log.i(f"Started chunked upload session: {upload_id}") + return jsonify({'upload_id': upload_id}), 200 + +def upload_chunk(): + # Validate parameters + upload_id = request.form.get('upload_id') + chunk_index = request.form.get('chunk_index') + total_chunks = request.form.get('total_chunks') + + if not upload_id or not chunk_index or not chunk_index.isdigit(): + log.e(f"Invalid chunk upload parameters: upload_id={upload_id}, chunk_index={chunk_index}") + return jsonify({'error': 'Invalid parameters'}), 400 + + chunk_index = int(chunk_index) + + # Get upload session metadata + metadata = load_chunk_metadata() + if upload_id not in metadata: + log.e(f"Invalid upload session ID: {upload_id}") + return jsonify({'error': 'Invalid upload session'}), 404 + + session = metadata[upload_id] + + # Validate chunk index + if chunk_index >= session['total_chunks']: + log.e(f"Invalid chunk index: {chunk_index}, total_chunks: {session['total_chunks']}") + return jsonify({'error': 'Invalid chunk index'}), 400 + + # Handle the chunk file + if 'chunk' not in request.files: + log.e(f"No chunk file provided for chunk {chunk_index} of upload {upload_id}") + return jsonify({'error': 'No chunk file provided'}), 400 + + chunk_file = request.files['chunk'] + if chunk_file.filename == '': + log.e(f"Empty chunk file for chunk {chunk_index} of upload {upload_id}") + return jsonify({'error': 'Empty chunk file'}), 400 + + # Save the chunk + chunk_path = os.path.join(session['chunk_dir'], f'chunk_{chunk_index}') + chunk_file.save(chunk_path) + + # Verify the chunk was saved successfully + if not os.path.exists(chunk_path) or os.path.getsize(chunk_path) == 0: + log.e(f"Failed to save chunk {chunk_index} for upload {upload_id}") + return jsonify({'error': 'Failed to save chunk'}), 500 + + # Update metadata + if chunk_index not in session['received_chunks']: + session['received_chunks'].append(chunk_index) + log.i(f"Added chunk {chunk_index} to received_chunks list for upload {upload_id}") + else: + log.w(f"Chunk {chunk_index} already in received_chunks list for upload {upload_id}, possible duplicate") + + metadata[upload_id] = session + save_chunk_metadata(metadata) + + # If client sent total_chunks, validate it matches what we have + if total_chunks and total_chunks.isdigit() and int(total_chunks) != session['total_chunks']: + log.w(f"Client reported total_chunks ({total_chunks}) doesn't match server ({session['total_chunks']})") + + log.i(f"Received chunk {chunk_index} for upload {upload_id}, now have {len(session['received_chunks'])}/{session['total_chunks']}") + return jsonify({ + 'success': True, + 'received_chunks': len(session['received_chunks']), + 'total_chunks': session['total_chunks'], + 'chunk_index': chunk_index + }), 200 + +def complete_chunked_upload(): + upload_id = request.form.get('upload_id') + if not upload_id: + return jsonify({'error': 'Upload ID is required'}), 400 + + # Get upload session metadata + metadata = load_chunk_metadata() + if upload_id not in metadata: + return jsonify({'error': 'Invalid upload session'}), 404 + + session = metadata[upload_id] + + # Verify all chunks are received by checking if every expected chunk index is in received_chunks + expected_chunks = set(range(session['total_chunks'])) + received_chunks = set(session['received_chunks']) + if expected_chunks != received_chunks: + missing_chunks = list(expected_chunks - received_chunks) + log.e(f"Missing chunks for upload {upload_id}: {missing_chunks}") + return jsonify({ + 'error': 'Not all chunks received', + 'received': len(session['received_chunks']), + 'total': session['total_chunks'], + 'missing_chunks': missing_chunks + }), 400 + + # Combine chunks into final file + final_filename = session['filename'] + final_path = os.path.join(UPLOAD_FOLDER, final_filename) + + # Verify all chunk files actually exist on disk + missing_files = [] + for i in range(session['total_chunks']): + chunk_path = os.path.join(session['chunk_dir'], f'chunk_{i}') + if not os.path.exists(chunk_path): + missing_files.append(i) + + if missing_files: + log.e(f"Missing chunk files for upload {upload_id} despite being in received_chunks: {missing_files}") + return jsonify({ + 'error': 'Chunk files missing on server', + 'missing_files': missing_files + }), 500 + + with open(final_path, 'wb') as outfile: + for i in range(session['total_chunks']): + chunk_path = os.path.join(session['chunk_dir'], f'chunk_{i}') + with open(chunk_path, 'rb') as infile: + outfile.write(infile.read()) + + # Clean up chunks + import shutil + shutil.rmtree(session['chunk_dir']) + del metadata[upload_id] + save_chunk_metadata(metadata) + + # Create regular file metadata + code = generate_unique_code() + expiry_time = datetime.now() + timedelta(hours=session['expiry_hours']) + + file_metadata = load_metadata() + file_metadata[code] = { + 'filename': final_filename, + 'path': final_path, + 'max_downloads': session['max_downloads'], + 'downloads': 0, + 'expiry_time': expiry_time.isoformat() + } + save_metadata(file_metadata) + + log.i(f"Completed chunked upload {upload_id}, assigned code {code}") + return jsonify({'code': code}), 200 + +def cleanup_abandoned_uploads(): + metadata = load_chunk_metadata() + current_time = datetime.now() + expired_uploads = [] + + for upload_id, session in metadata.items(): + created_at = datetime.fromisoformat(session['created_at']) + # Remove uploads older than 24 hours + if (current_time - created_at).total_seconds() > 86400: + if os.path.exists(session['chunk_dir']): + shutil.rmtree(session['chunk_dir']) + expired_uploads.append(upload_id) + + for upload_id in expired_uploads: + del metadata[upload_id] + + if expired_uploads: + save_chunk_metadata(metadata) + log.i(f"Cleaned up {len(expired_uploads)} abandoned uploads") + +def verify_chunks(): + """Verify which chunks have been received for an upload session""" + # Get upload ID from request + upload_id = request.form.get('upload_id') + + if not upload_id: + return jsonify({'error': 'Upload ID is required'}), 400 + + # Get upload session metadata + metadata = load_chunk_metadata() + if upload_id not in metadata: + return jsonify({'error': 'Invalid upload session'}), 404 + + session = metadata[upload_id] + + # Calculate missing chunks + received_chunks = set(session['received_chunks']) + all_chunks = set(range(session['total_chunks'])) + missing_chunks = list(all_chunks - received_chunks) + + # Check for each chunk file on disk to verify it actually exists + disk_missing_chunks = [] + for chunk_index in range(session['total_chunks']): + chunk_path = os.path.join(session['chunk_dir'], f'chunk_{chunk_index}') + if not os.path.exists(chunk_path): + disk_missing_chunks.append(chunk_index) + # If it's in received_chunks but not on disk, there was a problem + if chunk_index in received_chunks: + log.w(f"Chunk {chunk_index} marked as received but file is missing for upload {upload_id}") + # Remove from received_chunks since the file is missing + if chunk_index in session['received_chunks']: + session['received_chunks'].remove(chunk_index) + + # Update metadata if any changes were made + if len(received_chunks) != len(session['received_chunks']): + metadata[upload_id] = session + save_chunk_metadata(metadata) + + # Return missing chunks information + log.i(f"Verified chunks for upload {upload_id}: {len(session['received_chunks'])}/{session['total_chunks']} received") + + # Final verification + missing_chunks = list(all_chunks - set(session['received_chunks'])) + + return jsonify({ + 'total_chunks': session['total_chunks'], + 'received_chunks': len(session['received_chunks']), + 'missing_chunks': missing_chunks + }), 200 \ No newline at end of file diff --git a/src/start.py b/src/start.py index 41ecff8..e61ad6f 100644 --- a/src/start.py +++ b/src/start.py @@ -7,7 +7,7 @@ key = os.getenv("SSL_KEY", "key.pem") use_ssl = os.path.exists(cert) and os.path.exists(key) base_cmd = [ - "gunicorn", "wsgi:app", "--bind", "0.0.0.0:7777" + "gunicorn", "wsgi:app", "--bind", "0.0.0.0:7777", "--workers", "4", "--timeout", "300", ] if use_ssl: From 41ceeea90a180da7aca1697c47ea069e8d13870f Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Wed, 16 Apr 2025 22:28:13 -0400 Subject: [PATCH 3/8] tracking version --- src/templates/index.html | 11 +++++++++++ src/version.txt | 1 + src/views/__init__.py | 11 ++++++++++- 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/version.txt diff --git a/src/templates/index.html b/src/templates/index.html index 207cf23..d140c62 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -22,6 +22,7 @@ align-items: center; min-height: 100vh; color: #2d3748; + position: relative; } .container { @@ -170,6 +171,15 @@ @keyframes spin { to { transform: rotate(360deg); } } + + .version { + position: fixed; + bottom: 1rem; + right: 1rem; + font-size: 0.75rem; + color: #718096; + opacity: 0.8; + } + + +
+
+
+ dashboard +
+

Admin Dashboard

+
Logged in as {{ username }}
+
+
+ + logout + Logout + +
+ +
+ check_circle + Client key copied to clipboard! +
+ +
+
+ vpn_key + Client Key +
+
+
{{ client_key }}
+ +
+
+ +
+ info +
+ What is the Client Key?
+ This key is used by clients to authenticate API requests to the file server. + It should be included in the X-Client-Key header for all upload operations. + Keep this key secure and don't share it publicly. +
+
+ + +
+ + diff --git a/src/templates/admin_login.html b/src/templates/admin_login.html new file mode 100644 index 0000000..5dfff6e --- /dev/null +++ b/src/templates/admin_login.html @@ -0,0 +1,242 @@ + + + + + + Admin Login - Simple File Server + + + + + +
+
+ security +

Admin Login

+

Enter your credentials

+
+ + {% if error %} +
+ error + {{ error }} +
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
Enter the 6-digit code from your authenticator app
+
+ + +
+ + +
+ + diff --git a/src/templates/admin_setup.html b/src/templates/admin_setup.html new file mode 100644 index 0000000..f865eb8 --- /dev/null +++ b/src/templates/admin_setup.html @@ -0,0 +1,198 @@ + + + + + + Admin Setup - Simple File Server + + + + +
+
+ admin_panel_settings +

Admin Setup

+

Create your administrator account

+
+ +
+ Step 1 of 2: Create your admin credentials. You'll configure two-factor authentication on the next step. +
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+
+ + +
+ +
+ + +
Minimum 8 characters
+
+ +
+ + +
+ + +
+
+ + diff --git a/src/templates/admin_setup_complete.html b/src/templates/admin_setup_complete.html new file mode 100644 index 0000000..c5b3355 --- /dev/null +++ b/src/templates/admin_setup_complete.html @@ -0,0 +1,199 @@ + + + + + + Setup Complete - Simple File Server + + + + +
+
+ check_circle +

Setup Complete!

+

Your admin account has been created

+
+ +
+
+

+ Welcome, {{ username }}! +

+

+ Your administrator account has been successfully created and your authenticator app is configured. + You can now login to access the admin dashboard. +

+
+
+ +
+ info +
+ Important: Make sure you've saved your authenticator app configuration. + You'll need it every time you log in to the admin panel. +
+
+ + +
+ + diff --git a/src/templates/admin_setup_totp.html b/src/templates/admin_setup_totp.html new file mode 100644 index 0000000..798bd7b --- /dev/null +++ b/src/templates/admin_setup_totp.html @@ -0,0 +1,342 @@ + + + + + + Setup Two-Factor Authentication - Simple File Server + + + + +
+
+ security +

Two-Factor Authentication

+

Secure your admin account

+
+ +
+ Step 2 of 2: Configure your authenticator app +
+ +
+ info +
+ Required: You need an authenticator app to continue. + Download Google Authenticator, Authy, or any TOTP-compatible app before proceeding. +
+
+ + {% if error %} +
+ error + {{ error }} +
+ {% endif %} + +
+
+ qr_code_scanner + Scan QR Code +
+ +
+ TOTP QR Code +
+ +
+
+ vpn_key + Or Enter Manually +
+
Secret Key:
+
{{ totp_secret }}
+ +
+
+ +
+
+ + +
Enter the 6-digit code from your authenticator app
+
+ + +
+
+ + From 164e2162a28fce91ee5b9ff541189603526468bb Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Sat, 27 Dec 2025 14:29:58 -0500 Subject: [PATCH 6/8] Move client key to database with admin UI for key management and QR code generation - Store client key in SQLite database instead of environment variable - Add database migration from CLIENT_KEY environment variable to preserve existing keys - Add admin UI with tabbed interface (Configuration and QR Code tabs) - Implement QR code generation containing server config (address, port, key) for Android app - Add functionality to regenerate client key with warning dialog - Add buttons to download QR code as PNG and copy QR image to clipboard - Add manual input fields for server address and port configuration - Update requirements.txt with pyotp and qrcode dependencies --- requirements.txt | 2 + src/app.py | 22 +- src/controllers/admin_controller.py | 124 +++++++- src/models/__init__.py | 12 +- src/models/config_model.py | 157 ++++++++++ src/models/database.py | 37 +++ src/templates/admin_dashboard.html | 464 ++++++++++++++++++++++++++-- 7 files changed, 786 insertions(+), 32 deletions(-) create mode 100644 src/models/config_model.py diff --git a/requirements.txt b/requirements.txt index 1d85079..1e3e363 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,8 @@ itsdangerous==2.2.0 Jinja2==3.1.4 MarkupSafe==3.0.0 packaging==24.1 +pyotp==2.9.0 +qrcode==7.4.2 requests==2.32.3 urllib3==2.2.3 Werkzeug==3.0.4 diff --git a/src/app.py b/src/app.py index 9d81b91..ab77985 100644 --- a/src/app.py +++ b/src/app.py @@ -10,14 +10,14 @@ from controllers.file_controller import ( cleanup_expired_files, cleanup_abandoned_uploads ) from controllers.admin_controller import ( - setup_admin, setup_admin_totp, admin_login, admin_dashboard, admin_logout + setup_admin, setup_admin_totp, admin_login, admin_dashboard, admin_logout, + update_config, regenerate_key ) from views import views -from models import init_db, migrate_from_json +from models import init_db, migrate_from_json, migrate_client_key_to_db app = Flask(__name__) app.config['UPLOAD_FOLDER'] = 'tmp/uploads' -app.config['CLIENT_KEY'] = os.environ.get('CLIENT_KEY', 'default-secret-key') app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24).hex()) app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=24) @@ -28,6 +28,7 @@ if not os.path.exists(app.config['UPLOAD_FOLDER']): log.i("Initializing database...") init_db() migrate_from_json() +migrate_client_key_to_db() # Run cleanup on startup cleanup_expired_files() @@ -40,12 +41,19 @@ def require_client_key(f): if not client_key: log.e("No client key provided in request headers") return jsonify({'error': 'Missing client key'}), 401 - - expected_key = app.config['CLIENT_KEY'] + + # Get expected key from database + from models import get_client_key + expected_key = get_client_key() + + if not expected_key: + log.e("Server configuration error: no client key in database") + return jsonify({'error': 'Server configuration error'}), 500 + if client_key != expected_key: log.e(f"Invalid client key provided. Expected length: {len(expected_key)}, Got length: {len(client_key)}") return jsonify({'error': 'Invalid client key'}), 401 - + log.i("Client key authentication successful") return f(*args, **kwargs) return decorated_function @@ -71,6 +79,8 @@ app.route('/admin/setup/totp', methods=['GET', 'POST'], endpoint='setup_admin_to app.route('/admin/login', methods=['GET', 'POST'], endpoint='admin_login')(admin_login) app.route('/admin', methods=['GET'], endpoint='admin_dashboard')(admin_dashboard) app.route('/admin/logout', methods=['GET'], endpoint='admin_logout')(admin_logout) +app.route('/admin/config', methods=['POST'], endpoint='update_config')(update_config) +app.route('/admin/regenerate-key', methods=['POST'], endpoint='regenerate_key')(regenerate_key) # Register frontend views app.register_blueprint(views) diff --git a/src/controllers/admin_controller.py b/src/controllers/admin_controller.py index e5f0a12..1d073c1 100644 --- a/src/controllers/admin_controller.py +++ b/src/controllers/admin_controller.py @@ -3,12 +3,16 @@ import pyotp import qrcode import io import base64 +import json from functools import wraps from datetime import datetime, timedelta from flask import request, jsonify, render_template, session, redirect, url_for, current_app from werkzeug.security import generate_password_hash, check_password_hash import utils.mLogger as log -from models import get_admin, create_admin, model_admin_exists, update_last_login +from models import ( + get_admin, create_admin, model_admin_exists, update_last_login, + get_config, update_server_address, update_server_port, regenerate_client_key +) def require_admin_auth(f): """Decorator to require admin authentication""" @@ -207,16 +211,37 @@ def admin_login(): return redirect(url_for('admin_dashboard')) def admin_dashboard(): - """Display admin dashboard with client key""" + """Display admin dashboard with client key and QR code""" if not session.get('admin_authenticated'): return redirect(url_for('admin_login')) - client_key = current_app.config.get('CLIENT_KEY', 'Not configured') + # Get server configuration from database + config = get_config() + + if not config: + log.e("Server configuration not found in database") + return render_template('admin_dashboard.html', + error='Server configuration error', + username=session.get('admin_username', 'Admin')) + + client_key = config['client_key'] + server_address = config.get('server_address') or '' + server_port = config.get('server_port') or 7777 username = session.get('admin_username', 'Admin') + # Generate QR code if server address and port are configured + qr_code_base64 = None + if server_address and server_port: + qr_code_base64 = generate_config_qr(server_address, server_port, client_key) + return render_template('admin_dashboard.html', client_key=client_key, - username=username) + server_address=server_address, + server_port=server_port, + qr_code=qr_code_base64, + username=username, + success=request.args.get('success'), + error=request.args.get('error')) def admin_logout(): """Handle admin logout""" @@ -225,3 +250,94 @@ def admin_logout(): session.pop('admin_username', None) log.i(f"Admin user logged out: {username}") return redirect(url_for('admin_login')) + + +def generate_config_qr(server_address, server_port, client_key): + """Generate QR code containing server configuration + + Args: + server_address: Server IP or hostname + server_port: Server port number + client_key: Client authentication key + + Returns: + str: Base64-encoded PNG image of QR code + None: If error occurred + """ + try: + # Create JSON configuration + config_data = { + "server": server_address, + "port": server_port, + "key": client_key + } + config_json = json.dumps(config_data) + + # Generate QR code + qr = qrcode.QRCode( + version=None, # Auto-size + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=4 + ) + qr.add_data(config_json) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + # Convert to base64 + buffer = io.BytesIO() + img.save(buffer, format='PNG') + qr_base64 = base64.b64encode(buffer.getvalue()).decode() + + return qr_base64 + except Exception as e: + log.e(f"Error generating QR code: {e}") + return None + + +@require_admin_auth +def update_config(): + """Handle server configuration updates from admin dashboard""" + if request.method != 'POST': + return redirect(url_for('admin_dashboard')) + + server_address = request.form.get('server_address', '').strip() + server_port = request.form.get('server_port', '').strip() + + # Validate inputs + if not server_address: + return redirect(url_for('admin_dashboard', error='Server address is required')) + + try: + port = int(server_port) + if port < 1 or port > 65535: + raise ValueError("Port out of range") + except (ValueError, TypeError): + return redirect(url_for('admin_dashboard', error='Invalid port number')) + + # Update database + if not update_server_address(server_address): + return redirect(url_for('admin_dashboard', error='Failed to update server address')) + + if not update_server_port(port): + return redirect(url_for('admin_dashboard', error='Failed to update server port')) + + log.i(f"Server configuration updated: {server_address}:{port}") + return redirect(url_for('admin_dashboard', success='Configuration updated successfully')) + + +@require_admin_auth +def regenerate_key(): + """Handle client key regeneration""" + if request.method != 'POST': + return redirect(url_for('admin_dashboard')) + + # Regenerate the key + new_key = regenerate_client_key() + + if not new_key: + return redirect(url_for('admin_dashboard', error='Failed to regenerate client key')) + + log.w(f"Client key regenerated by admin: {session.get('admin_username')}") + return redirect(url_for('admin_dashboard', success='Client key regenerated successfully. All clients must update!')) diff --git a/src/models/__init__.py b/src/models/__init__.py index 22eceab..08757d3 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -1,7 +1,7 @@ from .file_metadata import FileMetadata # Database initialization -from .database import init_db, get_db_connection, migrate_from_json +from .database import init_db, get_db_connection, migrate_from_json, migrate_client_key_to_db # Admin model functions from .admin_model import ( @@ -33,3 +33,13 @@ from .chunk_model import ( cleanup_abandoned, update_received_chunks ) + +# Config model functions +from .config_model import ( + get_config, + get_client_key, + init_config, + update_server_address, + update_server_port, + regenerate_client_key +) diff --git a/src/models/config_model.py b/src/models/config_model.py new file mode 100644 index 0000000..aa26f52 --- /dev/null +++ b/src/models/config_model.py @@ -0,0 +1,157 @@ +import sqlite3 +import secrets +from datetime import datetime +from models.database import get_db +import utils.mLogger as log + + +def generate_client_key(): + """Generate a random 64-character hex string (256 bits)""" + return secrets.token_hex(32) # 32 bytes = 64 hex chars + + +def get_config(): + """Get server configuration (singleton) + + Returns: + dict: Configuration with client_key, server_address, server_port, timestamps + None: If config doesn't exist or error occurred + """ + try: + with get_db() as conn: + cursor = conn.cursor() + row = cursor.execute(""" + SELECT client_key, server_address, server_port, created_at, updated_at + FROM server_config + WHERE id = 1 + """).fetchone() + + if row: + return dict(row) + return None + except sqlite3.Error as e: + log.e(f"Error getting config: {e}") + return None + + +def get_client_key(): + """Get only the client key for authentication + + Returns: + str: The client key + None: If config doesn't exist or error occurred + """ + config = get_config() + return config['client_key'] if config else None + + +def init_config(client_key=None): + """Initialize server config with provided or generated key + + Args: + client_key (str, optional): Client key to use. If None, generates new key + + Returns: + bool: True if successful, False otherwise + """ + try: + with get_db() as conn: + # Check if config already exists + existing = conn.execute("SELECT COUNT(*) FROM server_config WHERE id = 1").fetchone()[0] + + if existing > 0: + log.i("Server config already exists, skipping initialization") + return True + + # Generate key if not provided + if not client_key: + client_key = generate_client_key() + log.i("Generated new client key") + else: + log.i("Using provided client key from environment") + + # Insert initial config + now = datetime.now().isoformat() + + conn.execute(""" + INSERT INTO server_config (id, client_key, created_at, updated_at) + VALUES (1, ?, ?, ?) + """, (client_key, now, now)) + + log.i("Server config initialized successfully") + return True + except sqlite3.Error as e: + log.e(f"Error initializing config: {e}") + return False + + +def update_server_address(address): + """Update server address + + Args: + address (str): Server address (IP or hostname) + + Returns: + bool: True if successful, False otherwise + """ + try: + with get_db() as conn: + conn.execute(""" + UPDATE server_config + SET server_address = ?, updated_at = ? + WHERE id = 1 + """, (address, datetime.now().isoformat())) + + log.i(f"Updated server address: {address}") + return True + except sqlite3.Error as e: + log.e(f"Error updating server address: {e}") + return False + + +def update_server_port(port): + """Update server port + + Args: + port (int): Server port number + + Returns: + bool: True if successful, False otherwise + """ + try: + with get_db() as conn: + conn.execute(""" + UPDATE server_config + SET server_port = ?, updated_at = ? + WHERE id = 1 + """, (port, datetime.now().isoformat())) + + log.i(f"Updated server port: {port}") + return True + except sqlite3.Error as e: + log.e(f"Error updating server port: {e}") + return False + + +def regenerate_client_key(): + """Generate and set a new client key + + Returns: + str: The new client key + None: If error occurred + """ + try: + new_key = generate_client_key() + + with get_db() as conn: + conn.execute(""" + UPDATE server_config + SET client_key = ?, updated_at = ? + WHERE id = 1 + """, (new_key, datetime.now().isoformat())) + + log.w("Client key regenerated - all clients must update!") + return new_key + except sqlite3.Error as e: + log.e(f"Error regenerating client key: {e}") + return None diff --git a/src/models/database.py b/src/models/database.py index 8b4d779..80e4e3b 100644 --- a/src/models/database.py +++ b/src/models/database.py @@ -110,6 +110,19 @@ def init_db(): CREATE INDEX IF NOT EXISTS idx_chunk_sessions_created_at ON chunk_sessions(created_at) """) + # Server configuration table (singleton) + conn.execute(""" + CREATE TABLE IF NOT EXISTS server_config ( + id INTEGER PRIMARY KEY CHECK(id = 1), + client_key TEXT NOT NULL, + server_address TEXT, + server_port INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + CONSTRAINT client_key_length CHECK(length(client_key) = 64) + ) + """) + log.i("Database initialized successfully") def migrate_from_json(): @@ -224,3 +237,27 @@ def migrate_from_json(): log.i("Migration completed successfully") else: log.i("No migration performed") + + +def migrate_client_key_to_db(): + """Migrate CLIENT_KEY from environment to database (one-time)""" + from models.config_model import init_config + + # Only run if config doesn't exist + with get_db() as conn: + existing = conn.execute("SELECT COUNT(*) FROM server_config WHERE id = 1").fetchone()[0] + + if existing > 0: + log.i("Server config already exists in database, skipping migration") + return + + # Get CLIENT_KEY from environment if set + env_key = os.environ.get('CLIENT_KEY') + + # Initialize config (will generate new key if env_key is None or default) + if env_key and env_key != 'default-secret-key': + log.i("Migrating CLIENT_KEY from environment to database") + init_config(client_key=env_key) + else: + log.i("No valid CLIENT_KEY in environment, generating new key") + init_config() diff --git a/src/templates/admin_dashboard.html b/src/templates/admin_dashboard.html index ef357c2..5b36454 100644 --- a/src/templates/admin_dashboard.html +++ b/src/templates/admin_dashboard.html @@ -27,14 +27,63 @@ .container { background: rgba(255, 255, 255, 0.95); - padding: 2.5rem; + padding: 2rem; border-radius: 1rem; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); width: 90%; - max-width: 600px; + max-width: 900px; backdrop-filter: blur(10px); } + .tabs { + display: flex; + gap: 0.5rem; + border-bottom: 2px solid #e2e8f0; + margin-bottom: 1.5rem; + } + + .tab { + padding: 0.75rem 1.5rem; + cursor: pointer; + border: none; + background: transparent; + color: #718096; + font-weight: 500; + font-size: 0.95rem; + transition: all 0.2s; + border-bottom: 3px solid transparent; + margin-bottom: -2px; + } + + .tab:hover { + color: #667eea; + } + + .tab.active { + color: #667eea; + border-bottom-color: #667eea; + } + + .tab-content { + display: none; + } + + .tab-content.active { + display: block; + } + + .cards-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + } + + @media (min-width: 768px) { + .cards-grid { + grid-template-columns: 1fr 1fr; + } + } + .header { display: flex; justify-content: space-between; @@ -90,9 +139,8 @@ .card { background: #f7fafc; - padding: 1.5rem; + padding: 1.25rem; border-radius: 0.75rem; - margin-bottom: 1.5rem; border-left: 4px solid #667eea; } @@ -160,13 +208,14 @@ .info-box { background: #ebf8ff; color: #2c5282; - padding: 1rem; + padding: 0.75rem; border-radius: 0.5rem; - border-left: 4px solid #4299e1; - font-size: 0.85rem; - line-height: 1.6; + border-left: 3px solid #4299e1; + font-size: 0.8rem; + line-height: 1.5; display: flex; - gap: 0.75rem; + gap: 0.5rem; + margin-top: 1rem; } .info-box .material-icons { @@ -205,7 +254,7 @@ .actions { display: flex; gap: 1rem; - margin-top: 1.5rem; + margin-top: 2rem; } .btn { @@ -243,18 +292,263 @@ .btn-secondary:hover { background: #f7fafc; } + + .form-group { + margin-bottom: 1rem; + } + + .form-group:last-of-type { + margin-bottom: 1.25rem; + } + + .form-group label { + display: block; + font-weight: 500; + margin-bottom: 0.5rem; + color: #2d3748; + } + + .form-input { + width: 100%; + padding: 0.75rem; + border: 2px solid #e2e8f0; + border-radius: 0.5rem; + font-size: 0.95rem; + transition: border-color 0.2s; + } + + .form-input:focus { + outline: none; + border-color: #667eea; + } + + .form-hint { + display: block; + color: #718096; + font-size: 0.85rem; + margin-top: 0.25rem; + } + + .key-actions { + margin-top: 1rem; + } + + .btn-danger { + background: #fff5f5; + color: #e53e3e; + border: 2px solid #e53e3e; + } + + .btn-danger:hover { + background: #e53e3e; + color: white; + } + + .qr-container { + display: flex; + justify-content: center; + padding: 1rem; + background: white; + border-radius: 0.5rem; + border: 2px solid #e2e8f0; + margin-bottom: 0.75rem; + } + + .qr-image { + max-width: 250px; + width: 100%; + height: auto; + } + + .error-message { + background: #fff5f5; + color: #c53030; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + animation: slideIn 0.3s ease; + } + + .error-message .material-icons { + color: #e53e3e; + } + + .qr-actions { + display: flex; + gap: 0.75rem; + margin-top: 1rem; + } + + .btn-icon { + flex: 1; + padding: 0.75rem; + border: none; + border-radius: 0.5rem; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + } + + .btn-download { + background: #667eea; + color: white; + } + + .btn-download:hover { + background: #5a67d8; + transform: translateY(-2px); + } + + .btn-copy-config { + background: white; + color: #667eea; + border: 2px solid #667eea; + } + + .btn-copy-config:hover { + background: #f7fafc; + } @@ -273,11 +567,83 @@ -
+ + {% if error %} +
+ error + {{ error }} +
+ {% endif %} + + + {% if success %} +
check_circle - Client key copied to clipboard! + {{ success }} +
+ {% endif %} + +
+ check_circle + Client key copied to clipboard!
+ +
+ + +
+ + +
+
+ +
+
+ settings + Server Configuration +
+ +
+
+ + + IP address or hostname where the server is accessible +
+ +
+ + + Port number (default: 7777) +
+ + +
+
+ +
vpn_key @@ -289,17 +655,73 @@ content_copy
-
-
- info -
- What is the Client Key?
- This key is used by clients to authenticate API requests to the file server. - It should be included in the X-Client-Key header for all upload operations. - Keep this key secure and don't share it publicly. +
+ info +
+ What is the Client Key?
+ This key is used by clients to authenticate API requests to the file server. + It should be included in the X-Client-Key header for all upload operations. + Keep this key secure and don't share it publicly. +
+
+ +
+
+
+
+ + +
+ {% if qr_code %} +
+
+ qr_code_2 + QR Code for Android App +
+ +
+ Configuration QR Code +
+ +
+ info +
+ Scan this QR code with the Simple File Upload Android app to automatically configure: +
    +
  • Server address: {{ server_address }}
  • +
  • Port: {{ server_port }}
  • +
  • Client key: (hidden)
  • +
+
+
+ +
+ + +
+
+ {% else %} +
+ warning +
+ Configure server settings to generate QR code
+ Enter your server address and port above to generate a QR code for the Android app. +
+
+ {% endif %} +
From f8943ab36360cedb5fd274ec6f04c9b23034f48a Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Sat, 27 Dec 2025 14:40:46 -0500 Subject: [PATCH 7/8] Fix code input clearing behavior on download Validate download code before starting download to ensure code input is only cleared on successful downloads. Invalid or expired codes now show an error message while preserving the entered code for correction. --- src/templates/index.html | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/templates/index.html b/src/templates/index.html index d140c62..ff3472b 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -280,7 +280,7 @@ form.addEventListener('submit', async (e) => { e.preventDefault(); const code = Array.from(inputs).map(input => input.value).join(''); - + if (code.length !== 6) { showMessage('Please enter a valid 6-character code.', 'error'); return; @@ -292,8 +292,26 @@ downloadButton.textContent = 'Downloading...'; try { - window.location.assign(`/download/${code}`); - showMessage('Download started!', 'success'); + // First check if the code is valid by making a HEAD request + const checkResponse = await fetch(`/download/${code}`, { + method: 'HEAD' + }); + + if (checkResponse.ok) { + // Code is valid, start download and clear the input + window.location.assign(`/download/${code}`); + showMessage('Download started!', 'success'); + + // Clear the code inputs after successful download + setTimeout(() => { + form.reset(); + downloadButton.disabled = true; + deleteButton.disabled = true; + }, 500); + } else { + // Code is invalid, show error and don't clear + showMessage('Invalid or expired code. Please check and try again.', 'error'); + } } catch (error) { showMessage('Error starting download. Please try again.', 'error'); } finally { From dc05c90cc409450242a3d148448d9fafdb76b89b Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Sat, 27 Dec 2025 14:47:37 -0500 Subject: [PATCH 8/8] Replace browser confirm dialog with custom modal Add custom confirmation modal for file deletion with smooth animations and backdrop blur. Improves UI consistency and provides better user experience than default browser dialogs. --- src/templates/index.html | 132 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 2 deletions(-) diff --git a/src/templates/index.html b/src/templates/index.html index ff3472b..dc22059 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -180,6 +180,87 @@ color: #718096; opacity: 0.8; } + + .modal-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + z-index: 1000; + align-items: center; + justify-content: center; + } + + .modal-overlay.active { + display: flex; + } + + .modal { + background: white; + border-radius: 1rem; + padding: 2rem; + max-width: 400px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: modalSlideIn 0.3s ease-out; + } + + @keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .modal h2 { + color: #2d3748; + font-size: 1.5rem; + margin-bottom: 1rem; + font-weight: 600; + } + + .modal p { + color: #4a5568; + font-size: 1rem; + margin-bottom: 1.5rem; + line-height: 1.5; + } + + .modal-buttons { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + } + + .modal-buttons button { + padding: 0.625rem 1.5rem; + font-size: 0.95rem; + } + + .modal-cancel { + background-color: #e2e8f0; + color: #2d3748; + } + + .modal-cancel:hover { + background-color: #cbd5e0; + } + + .modal-confirm { + background-color: #e53e3e; + } + + .modal-confirm:hover { + background-color: #c53030; + }