10 Commits

Author SHA1 Message Date
c5761e3afc Bump version to 25.12.0 for December 2025 release 2025-12-27 15:09:18 -05:00
5ed0970dab Merge pull request #1 from mattintech/feature/laregeupload
UI improvements: Code clearing fix and custom delete modal
2025-12-27 14:51:58 -05:00
dc05c90cc4 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.
2025-12-27 14:47:37 -05:00
f8943ab363 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.
2025-12-27 14:40:46 -05:00
164e2162a2 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
2025-12-27 14:29:58 -05:00
872ae74668 Migrate storage from JSON to SQLite with enhanced MVC architecture
- Replace JSON file storage with SQLite database for improved concurrency and data integrity
- Implement repository pattern with dedicated model layer (admin_model, file_model, chunk_model)
- Add database.py with automatic migration from existing JSON files
- Enable WAL mode for thread-safe concurrent access across Gunicorn workers
- Store database files in dedicated db/ folder
- Update controllers to use model layer instead of direct JSON access
- Add admin authentication system with TOTP 2FA support
2025-12-27 14:00:39 -05:00
8eac7b90c6 Adding docs 2025-04-16 22:54:53 -04:00
41ceeea90a tracking version 2025-04-16 22:28:13 -04:00
d4be29386b Adding chunk file support 2025-04-16 21:40:35 -04:00
f90cc7691c task tracking 2025-04-15 23:07:17 -04:00
27 changed files with 4759 additions and 84 deletions

4
.gitignore vendored
View File

@@ -2,6 +2,10 @@
tmp/
uploads/
file_metadata.json
chunk_metadata.json
admin_data.json
*.backup
db/
.env
# Python

155
PROJECT.md Normal file
View File

@@ -0,0 +1,155 @@
# Simple File Upload Server Project
## Project Overview
The Simple File Upload Server is a Flask-based application designed for secure, temporary file sharing and mobile app log uploads. It follows a clean MVC architecture and implements modern web standards for both API and user interface interactions.
## Project Rules and Guidelines
### Code Organization
1. **MVC Pattern**
- Models: Place all data models in `src/models/`
- Views: Store templates in `src/templates/`
- Controllers: Keep business logic in `src/controllers/`
2. **File Structure**
- Source code belongs in `src/`
- Configuration files in root directory
- Temporary files in `tmp/uploads/`
- Templates in `src/templates/`
### Development Rules
1. **Code Style**
- Follow PEP 8 guidelines for Python code
- Use type hints where possible
- Document all public functions and classes
- Keep functions focused and single-purpose
2. **Security Requirements**
- All uploads must be authenticated with API key
- File names must be sanitized
- SSL required for production
- Implement proper error handling
- Clean up expired/abandoned files
3. **API Guidelines**
- Maintain backward compatibility
- Document all endpoint changes
- Include proper error responses
- Use consistent response formats
- Validate all input parameters
4. **Feature Implementation**
- Add tests for new features
- Update documentation
- Follow chunked upload protocol for large files
- Maintain mobile app compatibility
- Consider both WiFi and mobile network scenarios
### Testing Requirements
1. **New Features**
- Unit tests required
- Test both success and failure cases
- Include mobile network testing
- Verify chunked upload functionality
2. **Performance Testing**
- Test with files >100MB
- Verify timeout configurations
- Test under various network conditions
- Monitor memory usage
### Deployment Rules
1. **Docker Requirements**
- Use multi-stage builds
- Include only necessary files
- Set proper permissions
- Configure appropriate timeouts
2. **Production Setup**
- SSL certificates required
- Strong CLIENT_KEY required
- Proper file cleanup configuration
- Monitoring setup
## Documentation Structure
### Core Documentation
1. **README.md**
- Basic setup and configuration
- API endpoint documentation
- Docker deployment instructions
- Security best practices
[View README.md for details](README.md)
2. **TASK.md**
- Project roadmap
- Feature implementation status
- Future enhancements
- Current objectives
[View TASK.md for progress](TASK.md)
3. **LARGE_UPLOAD.md**
- Chunked upload implementation
- Network handling strategies
- Mobile optimization
- Technical specifications
[View LARGE_UPLOAD.md for implementation details](LARGE_UPLOAD.md)
## Version Control Rules
1. **Branching Strategy**
- Main branch: production-ready code
- Develop branch: integration branch
- Feature branches: feature/* prefix
- Hotfix branches: hotfix/* prefix
2. **Commit Guidelines**
- Clear, descriptive commit messages
- Reference issue numbers
- Single responsibility per commit
- Include relevant tests
3. **Release Process**
- Tag all releases (v*.*.*)
- Update version.txt
- Generate change logs
- Test deployment process
## CI/CD Pipeline
1. **Automated Processes**
- Build testing
- Unit test execution
- Docker image building
- Version tagging
- DockerHub publishing
2. **Quality Gates**
- All tests must pass
- Code coverage requirements
- No security vulnerabilities
- Proper version tagging
## Monitoring and Maintenance
1. **Server Health**
- Regular cleanup of expired files
- Monitor disk usage
- Track API usage
- Log error rates
2. **Performance Metrics**
- Upload success rates
- Download completion rates
- Response times
- Chunked upload efficiency
## Support and Updates
1. **Issue Management**
- Use GitHub issues for tracking
- Label issues appropriately
- Follow security disclosure process
- Maintain changelog
2. **Documentation Updates**
- Keep API docs current
- Update setup instructions
- Document known issues
- Maintain upgrade guides

View File

@@ -2,10 +2,14 @@
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
- Chunked upload support for large files
- Specifically designed for the simplefileupload-android app
- Secure 6-digit alphanumeric code system
- Automatic file cleanup
- Configurable download limits and expiry times
@@ -130,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.

29
TASK.md
View File

@@ -26,6 +26,11 @@
- [x] Configurable limits
- [x] API response format
- [x] API key authentication
- [x] Chunked upload support
- [x] Upload session management
- [x] Progress tracking
- [x] Resume capability
- [x] Chunk verification
### Download System
- [x] Code validation
@@ -54,23 +59,29 @@
- [ ] 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] Automated Docker builds
- [x] Version tagging
- [x] DockerHub publishing
- [x] Implement persistent storage
- [ ] Add monitoring/metrics
- [ ] Create backup system
- [ ] Add health check endpoints
### Chunked Upload Support
- [x] Implement new API endpoints for chunked uploads (/upload/chunk, /upload/start, /upload/complete)
- [x] Add server-side chunk management and reassembly
- [x] Configure Gunicorn with appropriate timeouts for large uploads
- [x] Implement temporary storage for in-progress chunked uploads
- [x] Add automatic cleanup for abandoned uploads
- [x] Create progress tracking for partially complete uploads
- [ ] Test with large files (>100MB) over various connection types
### UI/UX
- [ ] Dark mode support
- [ ] Drag-and-drop uploads
- [ ] Improved mobile UI
- [ ] Accessibility improvements
- [ ] Internationalization

188
docs/API.md Normal file
View File

@@ -0,0 +1,188 @@
# API Documentation
## Overview
The Simple File Upload Server provides a RESTful API for file uploads and downloads, with special support for chunked uploads of large files. All upload endpoints require API key authentication.
## Authentication
All upload endpoints require the `X-Client-Key` header with a valid API key.
Example:
```http
X-Client-Key: your-secret-key
```
## Endpoints
### File Upload
#### POST /upload
Upload a single file in one request.
**Request:**
- Method: `POST`
- Content-Type: `multipart/form-data`
- Authentication: Required
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| file | File | Yes | The file to upload |
| max_downloads | Integer | No | Maximum number of downloads (default: 1) |
| expiry_hours | Integer | No | Hours until file expires (default: 24) |
**Response:**
```json
{
"code": "ABC123"
}
```
### Chunked Upload
#### POST /upload/start
Initialize a chunked upload session.
**Request:**
- Method: `POST`
- Content-Type: `multipart/form-data`
- Authentication: Required
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| filename | String | Yes | Name of the file being uploaded |
| total_chunks | Integer | Yes | Total number of chunks to expect |
| max_downloads | Integer | No | Maximum number of downloads (default: 1) |
| expiry_hours | Integer | No | Hours until file expires (default: 24) |
**Response:**
```json
{
"upload_id": "abC123dEf456gHij"
}
```
#### POST /upload/chunk
Upload a single chunk of a file.
**Request:**
- Method: `POST`
- Content-Type: `multipart/form-data`
- Authentication: Required
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| upload_id | String | Yes | Upload session ID from /upload/start |
| chunk_index | Integer | Yes | Index of this chunk (0-based) |
| chunk | File | Yes | The chunk data |
**Response:**
```json
{
"success": true,
"received_chunks": 5,
"total_chunks": 10,
"chunk_index": 4
}
```
#### POST /upload/verify
Verify the status of uploaded chunks.
**Request:**
- Method: `POST`
- Content-Type: `multipart/form-data`
- Authentication: Required
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| upload_id | String | Yes | Upload session ID |
**Response:**
```json
{
"total_chunks": 10,
"received_chunks": 5,
"missing_chunks": [5,6,7,8,9]
}
```
#### POST /upload/complete
Complete a chunked upload session.
**Request:**
- Method: `POST`
- Content-Type: `multipart/form-data`
- Authentication: Required
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| upload_id | String | Yes | Upload session ID |
**Response:**
```json
{
"code": "ABC123"
}
```
### File Download
#### GET /download/{code}
Download a file using its 6-character code.
**Request:**
- Method: `GET`
- Authentication: Not required
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| code | String | Yes | 6-character download code |
**Response:**
- Success: File download begins
- Error: HTML error page with user-friendly message
### File Management
#### DELETE /delete/{code}
Delete a file using its code.
**Request:**
- Method: `DELETE`
- Authentication: Not required
**Parameters:**
| Name | Type | Required | Description |
|------|------|----------|-------------|
| code | String | Yes | 6-character download code |
**Response:**
```json
{
"message": "File deleted successfully"
}
```
## Error Responses
### Common Error Format
```json
{
"error": "Error message description"
}
```
### HTTP Status Codes
- 200: Success
- 400: Bad Request (missing parameters, invalid format)
- 401: Unauthorized (invalid or missing API key)
- 404: Not Found (invalid code, expired file)
- 500: Server Error
## Rate Limits
Currently not implemented. See future enhancements in TASK.md.

206
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,206 @@
# System Architecture
## Overview
The Simple File Upload Server is built using a Model-View-Controller (MVC) architecture pattern in Flask, with clear separation of concerns and modular design. The system handles both traditional file uploads and chunked uploads for large files, with a focus on security and reliability.
## System Components
### 1. Core Architecture (MVC)
#### Models (`src/models/`)
- **FileMetadata**
- Handles file metadata management
- Tracks download counts, expiry times
- Manages file paths and access codes
- Implements cleanup for expired files
#### Views (`src/templates/`)
- **index.html**
- Modern, responsive UI for file downloads
- Client-side validation
- Progress tracking
- Error handling
- **error.html**
- User-friendly error pages
- Contextual error messages
- Clean, responsive design
#### Controllers (`src/controllers/`)
- **file_controller.py**
- Upload/download logic
- Chunked upload management
- File cleanup operations
- Access code generation
### 2. Data Flow
```mermaid
graph TD
A[Client] -->|Upload Request| B[Flask App]
B -->|Validate| C[FileController]
C -->|Store| D[FileMetadata]
D -->|Save| E[Filesystem]
A -->|Download Request| B
B -->|Validate Code| C
C -->|Check| D
D -->|Retrieve| E
E -->|Serve| A
```
### 3. File Storage System
#### Directory Structure
```
tmp/
├── uploads/
│ ├── [regular files]
│ └── chunks/
│ └── [upload_session_folders]/
```
#### Metadata Storage
- `file_metadata.json`: Tracks regular file uploads
- `chunk_metadata.json`: Manages chunked upload sessions
### 4. Security Architecture
#### Authentication
- API key authentication for uploads
- 6-digit alphanumeric codes for downloads
- SSL/TLS support for secure transmission
#### File Security
- Secure filename sanitization
- Temporary storage with auto-cleanup
- Download limit enforcement
- File expiry system
### 5. Chunked Upload System
#### Components
1. **Session Management**
- Unique upload session IDs
- Chunk tracking and verification
- Progress monitoring
2. **File Assembly**
```mermaid
graph LR
A[Client] -->|Chunks| B[Temporary Storage]
B -->|Assembly| C[Final File]
C -->|Metadata| D[Database]
```
3. **Cleanup Process**
- Automatic cleanup of abandoned uploads
- Temporary file management
- Session expiry handling
### 6. Logging System (`src/utils/mLogger.py`)
#### Logging Levels
- INFO: Normal operations
- WARNING: Potential issues
- ERROR: Operation failures
- DEBUG: Development information
- VERBOSE: Detailed tracking
#### Log Format
```
timestamp|level|module::message
```
### 7. Error Handling
#### Types of Errors
1. **Client Errors**
- Invalid file types
- Missing parameters
- Authentication failures
- Invalid codes
2. **Server Errors**
- Storage issues
- File assembly failures
- Database errors
- Network timeouts
#### Error Response Format
```json
{
"error": "Error description",
"details": "Additional information"
}
```
### 8. Configuration System
#### Environment Variables
- `CLIENT_KEY`: API authentication
- `UPLOAD_FOLDER`: File storage location
- `SSL_CERT`: SSL certificate path
- `SSL_KEY`: SSL key path
#### Application Constants
- Maximum file size
- Chunk size limits
- Session timeouts
- Cleanup intervals
## Performance Considerations
### 1. Resource Management
- Chunked upload for large files
- Temporary storage cleanup
- Memory-efficient file handling
- Connection timeout management
### 2. Scalability
- Stateless design
- File system abstraction
- Configurable worker processes
- Independent upload sessions
### 3. Network Optimization
- Adaptive chunk sizes
- Progress tracking
- Resume capability
- Connection monitoring
## Deployment Architecture
### Docker Container
```mermaid
graph TD
A[Docker Container] -->|Runs| B[Gunicorn]
B -->|WSGI| C[Flask App]
C -->|Stores| D[Volume Mount]
C -->|Logs| E[Log Volume]
```
### Process Management
- Gunicorn worker processes
- Configurable timeouts
- SSL termination
- Health monitoring
## Future Architecture Considerations
### Planned Improvements
1. **Monitoring System**
- API usage metrics
- Storage utilization
- Error rate tracking
- Performance monitoring
2. **Enhanced Security**
- Rate limiting
- IP whitelisting
- File type restrictions
- Access audit logging
3. **High Availability**
- Load balancing
- Redundant storage
- Session persistence
- Backup management

326
docs/LARGE_UPLOAD.md Normal file
View File

@@ -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<Integer> 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<Integer> 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.

226
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,226 @@
# Security Documentation
## Overview
This document outlines the security measures implemented in the Simple File Upload Server and provides guidelines for secure deployment and operation.
## Security Features
### Authentication
1. **API Key Authentication**
- Required for all upload endpoints
- Configured via CLIENT_KEY environment variable
- Transmitted via X-Client-Key HTTP header
- No default keys allowed in production
2. **Download Security**
- 6-digit alphanumeric codes
- One-time use downloads (configurable)
- Automatic expiration
- No directory listing
### File Security
1. **Upload Security**
- Secure filename sanitization
- No executable permissions
- Temporary storage with cleanup
- Chunked upload verification
2. **Storage Security**
- Files stored outside web root
- Metadata separation from files
- Automatic cleanup of expired files
- Abandoned upload detection
3. **Download Controls**
- Maximum download limits
- Expiry time enforcement
- Force download headers
- Content-Type verification
### Network Security
1. **SSL/TLS**
- Required for production
- Modern cipher configuration
- Perfect forward secrecy
- SSL certificate validation
2. **Headers**
- Secure response headers
- No version disclosure
- XSS protection
- Content security policy
## Secure Deployment
### Production Requirements
1. **SSL Configuration**
```nginx
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
```
2. **Environment Setup**
```bash
# Required settings
export CLIENT_KEY="strong-random-key"
export UPLOAD_FOLDER="/secure/path/uploads"
# SSL settings
export SSL_CERT="/path/to/cert.pem"
export SSL_KEY="/path/to/key.pem"
```
3. **File Permissions**
```bash
# Storage directory
chmod 750 /secure/path/uploads
chown www-data:www-data /secure/path/uploads
# SSL files
chmod 600 /path/to/key.pem
chmod 644 /path/to/cert.pem
```
### Docker Security
1. **Container Configuration**
- Non-root user
- Read-only root filesystem
- Limited capabilities
- Resource limits
2. **Volume Management**
- Separate volumes for uploads
- Proper permissions
- Regular cleanup
- Backup strategy
## Security Monitoring
### Logging
1. **Access Logs**
- Upload attempts
- Download tracking
- Error logging
- Authentication failures
2. **Audit Trail**
- File operations
- Configuration changes
- System events
- Cleanup activities
### Monitoring Requirements
1. **System Monitoring**
- Disk usage
- Memory utilization
- Network traffic
- Error rates
2. **Security Alerts**
- Authentication failures
- Unusual activity
- Storage warnings
- SSL certificate expiry
## Security Best Practices
### Server Configuration
1. **Firewall Rules**
- Allow only required ports
- Rate limit connections
- Block suspicious IPs
- Monitor access patterns
2. **Process Isolation**
- Run as dedicated user
- Minimal permissions
- Resource limits
- Process supervision
### Data Protection
1. **File Handling**
- Validate file types
- Scan for malware
- Enforce size limits
- Secure deletion
2. **Metadata Protection**
- Regular backups
- Access controls
- Data validation
- Secure storage
## Future Security Enhancements
### Planned Features
1. **Rate Limiting**
- Per-IP limits
- Token bucket algorithm
- Configurable thresholds
- Automatic blocking
2. **Advanced Authentication**
- API key rotation
- IP whitelisting
- Two-factor auth option
- Access tokens
3. **Enhanced Monitoring**
- Real-time alerts
- Usage analytics
- Threat detection
- Performance tracking
### Security Roadmap
1. **Short Term**
- Implement rate limiting
- Add file type restrictions
- Enhance logging
- Add health checks
2. **Long Term**
- Access audit system
- Advanced monitoring
- Automated backups
- High availability
## Incident Response
### Response Plan
1. **Detection**
- Monitor logs
- Track metrics
- Alert on anomalies
- User reports
2. **Response**
- Assess impact
- Contain threat
- Notify stakeholders
- Document incident
3. **Recovery**
- Restore service
- Update security
- Review procedures
- Implement fixes
### Security Contacts
For security issues or vulnerabilities:
1. Create a GitHub issue with [SECURITY] prefix
2. For critical issues, contact maintainers directly
3. Follow responsible disclosure guidelines
4. Allow time for patches before public disclosure

View File

@@ -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

View File

@@ -1,18 +1,39 @@
import os
from functools import wraps
from datetime import timedelta
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 controllers.admin_controller import (
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, 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)
if not os.path.exists(app.config['UPLOAD_FOLDER']):
os.makedirs(app.config['UPLOAD_FOLDER'])
# Initialize database
log.i("Initializing database...")
init_db()
migrate_from_json()
migrate_client_key_to_db()
# Run cleanup on startup
cleanup_expired_files()
cleanup_abandoned_uploads()
def require_client_key(f):
@wraps(f)
def decorated_function(*args, **kwargs):
@@ -20,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
@@ -33,12 +61,28 @@ 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/<code>', methods=['GET'])(download_file)
# Route for deleting the file
app.route('/delete/<code>', methods=['DELETE'])(delete_file)
# Admin routes
app.route('/admin/setup', methods=['GET', 'POST'], endpoint='setup_admin')(setup_admin)
app.route('/admin/setup/totp', methods=['GET', 'POST'], endpoint='setup_admin_totp')(setup_admin_totp)
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)
if __name__ == '__main__':

View File

@@ -0,0 +1,343 @@
import os
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,
get_config, update_server_address, update_server_port, regenerate_client_key
)
def require_admin_auth(f):
"""Decorator to require admin authentication"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get('admin_authenticated'):
log.w("Unauthorized admin access attempt")
return redirect(url_for('admin_login'))
return f(*args, **kwargs)
return decorated_function
def admin_exists():
"""Check if an admin user has been created"""
return model_admin_exists()
def setup_admin():
"""Handle initial admin setup - Step 1: Username and Password"""
if admin_exists():
log.w("Attempted to access setup page when admin already exists")
return redirect(url_for('admin_login'))
if request.method == 'GET':
return render_template('admin_setup.html')
# POST request - validate and store credentials in session
username = request.form.get('username')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
if not username or not password or not confirm_password:
return render_template('admin_setup.html', error='All fields are required')
if password != confirm_password:
return render_template('admin_setup.html', error='Passwords do not match')
if len(password) < 8:
return render_template('admin_setup.html', error='Password must be at least 8 characters')
# Store credentials in session temporarily
session['temp_username'] = username
session['temp_password_hash'] = generate_password_hash(password, method='pbkdf2:sha256')
log.i(f"Admin credentials created for: {username}, proceeding to TOTP setup")
return redirect(url_for('setup_admin_totp'))
def setup_admin_totp():
"""Handle TOTP setup - Step 2: Configure Two-Factor Authentication"""
if admin_exists():
log.w("Attempted to access TOTP setup when admin already exists")
return redirect(url_for('admin_login'))
# Check if step 1 was completed
if 'temp_username' not in session:
log.w("Attempted to access TOTP setup without completing step 1")
return redirect(url_for('setup_admin'))
username = session.get('temp_username')
if request.method == 'GET':
# Generate TOTP secret
totp_secret = pyotp.random_base32()
session['temp_totp_secret'] = totp_secret
# Generate QR code for TOTP setup
totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri(
name=username,
issuer_name='Simple File Server'
)
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(totp_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Convert QR code to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
return render_template('admin_setup_totp.html',
qr_code=qr_base64,
totp_secret=totp_secret,
username=username)
# POST request - verify TOTP and complete setup
totp_code = request.form.get('totp_code')
totp_secret = session.get('temp_totp_secret')
password_hash = session.get('temp_password_hash')
if not totp_code:
# Regenerate QR code for error response
totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri(
name=username,
issuer_name='Simple File Server'
)
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(totp_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format='PNG')
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
return render_template('admin_setup_totp.html',
error='TOTP code is required',
qr_code=qr_base64,
totp_secret=totp_secret,
username=username)
# Verify TOTP code
totp = pyotp.TOTP(totp_secret)
if not totp.verify(totp_code, valid_window=1):
log.w(f"Failed admin setup: invalid TOTP code for user '{username}'")
# Regenerate QR code for error response
totp_uri = pyotp.totp.TOTP(totp_secret).provisioning_uri(
name=username,
issuer_name='Simple File Server'
)
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(totp_uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
img.save(buffer, format='PNG')
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
return render_template('admin_setup_totp.html',
error='Invalid TOTP code. Please check your authenticator app and try again.',
qr_code=qr_base64,
totp_secret=totp_secret,
username=username)
# TOTP verified - create admin account
create_admin(username, password_hash, totp_secret)
# Clean up session
session.pop('temp_username', None)
session.pop('temp_password_hash', None)
session.pop('temp_totp_secret', None)
log.i(f"Admin user created: {username}")
return render_template('admin_setup_complete.html', username=username)
def admin_login():
"""Handle admin login"""
if not admin_exists():
return redirect(url_for('setup_admin'))
if session.get('admin_authenticated'):
return redirect(url_for('admin_dashboard'))
if request.method == 'GET':
return render_template('admin_login.html')
# POST request - authenticate
username = request.form.get('username')
password = request.form.get('password')
totp_code = request.form.get('totp_code')
if not username or not password or not totp_code:
return render_template('admin_login.html', error='All fields are required')
admin_data = get_admin()
if not admin_data:
log.e("Admin data not found during login attempt")
return render_template('admin_login.html', error='Invalid credentials')
# Verify username and password
if username != admin_data['username']:
log.w(f"Failed login attempt: invalid username '{username}'")
return render_template('admin_login.html', error='Invalid credentials')
if not check_password_hash(admin_data['password_hash'], password):
log.w(f"Failed login attempt: invalid password for user '{username}'")
return render_template('admin_login.html', error='Invalid credentials')
# Verify TOTP
totp = pyotp.TOTP(admin_data['totp_secret'])
if not totp.verify(totp_code, valid_window=1):
log.w(f"Failed login attempt: invalid TOTP code for user '{username}'")
return render_template('admin_login.html', error='Invalid TOTP code')
# Authentication successful
session['admin_authenticated'] = True
session['admin_username'] = username
session.permanent = True
# Update last login time
update_last_login(username)
log.i(f"Admin user logged in: {username}")
return redirect(url_for('admin_dashboard'))
def admin_dashboard():
"""Display admin dashboard with client key and QR code"""
if not session.get('admin_authenticated'):
return redirect(url_for('admin_login'))
# 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,
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"""
username = session.get('admin_username', 'Unknown')
session.pop('admin_authenticated', None)
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!'))

View File

@@ -1,33 +1,38 @@
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
import utils.mLogger as log
from models.file_metadata import FileMetadata
from models import (
create_file, get_file, increment_download, model_delete_file,
cleanup_expired as model_cleanup_expired,
create_session, get_session, add_chunk, get_received_chunks,
delete_session, cleanup_abandoned as model_cleanup_abandoned,
update_received_chunks
)
# Make UPLOAD_FOLDER absolute
UPLOAD_FOLDER = os.path.abspath('tmp/uploads')
METADATA_FILE = 'file_metadata.json'
# Constants for chunked uploads
CHUNKS_FOLDER = os.path.join(UPLOAD_FOLDER, 'chunks')
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
def load_metadata():
if os.path.exists(METADATA_FILE):
with open(METADATA_FILE, 'r') as f:
return json.load(f)
return {}
def save_metadata(metadata):
with open(METADATA_FILE, 'w') as f:
json.dump(metadata, f)
# Ensure chunks directory exists
if not os.path.exists(CHUNKS_FOLDER):
os.makedirs(CHUNKS_FOLDER)
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")
@@ -49,38 +54,27 @@ def upload_file():
# Get metadata from request
max_downloads = int(request.form.get('max_downloads', 1))
expiry_hours = int(request.form.get('expiry_hours', 24))
expiry_time = datetime.now() + timedelta(hours=expiry_hours)
# Save metadata
metadata = load_metadata()
metadata[code] = {
'filename': filename,
'path': file_path,
'max_downloads': max_downloads,
'downloads': 0,
'expiry_time': expiry_time.isoformat()
}
save_metadata(metadata)
# Save metadata to database
create_file(code, filename, file_path, max_downloads, expiry_hours)
return jsonify({'code': code}), 200
def download_file(code):
metadata = load_metadata()
file_info = get_file(code)
if code not in metadata:
if not file_info:
return render_template('error.html',
error_title='Invalid Code',
error_message='The code you entered is invalid or incorrect. Please check your code and try again.'
), 404
file_info = metadata[code]
file_path = file_info['path']
expiry_time = datetime.fromisoformat(file_info['expiry_time'])
# Check expiry
if datetime.now() > expiry_time:
del metadata[code]
save_metadata(metadata)
model_delete_file(code)
return render_template('error.html',
error_title='File Expired',
error_message='This file has expired and is no longer available for download.'
@@ -88,31 +82,26 @@ def download_file(code):
# Check if file exists
if not os.path.exists(file_path):
del metadata[code]
save_metadata(metadata)
model_delete_file(code)
return render_template('error.html',
error_title='File Not Found',
error_message='The requested file could not be found on the server.'
), 404
# Increment download counter before checking limit
file_info['downloads'] += 1
# Increment download counter and get new count
new_downloads = increment_download(code)
# Check if this download puts us over the limit
if file_info['downloads'] > file_info['max_downloads']:
if new_downloads > file_info['max_downloads']:
# Clean up the file and metadata
if os.path.exists(file_path):
os.remove(file_path)
del metadata[code]
save_metadata(metadata)
model_delete_file(code)
return render_template('error.html',
error_title='Download Limit Reached',
error_message='This file has reached its maximum number of downloads and is no longer available.'
), 404
# Save the updated download count
save_metadata(metadata)
# Serve the file
directory = os.path.dirname(file_path)
filename = os.path.basename(file_path)
@@ -124,50 +113,234 @@ def download_file(code):
)
def delete_file(code):
metadata = load_metadata()
file_info = get_file(code)
if code not in metadata:
if not file_info:
return jsonify({'error': 'Invalid code'}), 404
file_info = metadata[code]
file_path = file_info['path']
expiry_time = datetime.fromisoformat(file_info['expiry_time'])
# Check expiry
if datetime.now() > expiry_time:
del metadata[code]
save_metadata(metadata)
model_delete_file(code)
return jsonify({'error': 'File already expired'}), 404
# Check if still downloadable
if file_info['downloads'] > file_info['max_downloads']:
del metadata[code]
save_metadata(metadata)
model_delete_file(code)
return jsonify({'error': 'File no longer available'}), 404
# Delete the file
if os.path.exists(file_path):
os.remove(file_path)
# Remove from metadata
del metadata[code]
save_metadata(metadata)
# Remove from database
model_delete_file(code)
log.i(f"File deleted: {code}")
return jsonify({'message': 'File deleted successfully'}), 200
def cleanup_expired_files():
metadata = load_metadata()
updated_metadata = {}
model_cleanup_expired()
log.i("Cleanup completed: Expired and overused files removed.")
for code, file_info in metadata.items():
expiry_time = datetime.fromisoformat(file_info['expiry_time'])
if datetime.now() > expiry_time or file_info['downloads'] >= file_info['max_downloads']:
# Remove the file if it exists
if os.path.exists(file_info['path']):
os.remove(file_info['path'])
else:
updated_metadata[code] = file_info
def start_chunked_upload():
# Get metadata from request
filename = request.form.get('filename')
if not filename:
return jsonify({'error': 'Filename is required'}), 400
save_metadata(updated_metadata)
log.i("Cleanup completed: Expired and overused files removed.")
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 in database
max_downloads = int(request.form.get('max_downloads', 1))
expiry_hours = int(request.form.get('expiry_hours', 24))
create_session(
upload_id,
secure_filename(filename),
int(total_chunks),
chunk_dir,
max_downloads,
expiry_hours
)
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
session = get_session(upload_id)
if not session:
log.e(f"Invalid upload session ID: {upload_id}")
return jsonify({'error': 'Invalid upload session'}), 404
# 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 - add chunk to received list
add_chunk(upload_id, chunk_index)
# Get updated session
session = get_session(upload_id)
# 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
session = get_session(upload_id)
if not session:
return jsonify({'error': 'Invalid upload session'}), 404
# 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
shutil.rmtree(session['chunk_dir'])
delete_session(upload_id)
# Create regular file metadata
code = generate_unique_code()
create_file(code, final_filename, final_path, session['max_downloads'], session['expiry_hours'])
log.i(f"Completed chunked upload {upload_id}, assigned code {code}")
return jsonify({'code': code}), 200
def cleanup_abandoned_uploads():
model_cleanup_abandoned()
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
session = get_session(upload_id)
if not session:
return jsonify({'error': 'Invalid upload session'}), 404
# 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']):
update_received_chunks(upload_id, session['received_chunks'])
# 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

View File

@@ -0,0 +1,45 @@
from .file_metadata import FileMetadata
# Database initialization
from .database import init_db, get_db_connection, migrate_from_json, migrate_client_key_to_db
# Admin model functions
from .admin_model import (
get_admin,
create_admin,
admin_exists as model_admin_exists,
update_last_login
)
# File model functions
from .file_model import (
create_file,
get_file,
increment_download,
delete_file as model_delete_file,
get_expired_files,
cleanup_expired,
get_all_files
)
# Chunk model functions
from .chunk_model import (
create_session,
get_session,
add_chunk,
get_received_chunks,
delete_session,
get_abandoned_sessions,
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
)

91
src/models/admin_model.py Normal file
View File

@@ -0,0 +1,91 @@
import sqlite3
from models.database import get_db
import utils.mLogger as log
def get_admin():
"""Get admin user data
Returns:
dict: Admin data with keys: username, password_hash, totp_secret, created_at, last_login
None: If no admin exists
"""
try:
with get_db() as conn:
cursor = conn.cursor()
row = cursor.execute("""
SELECT username, password_hash, totp_secret, created_at, last_login
FROM admin
LIMIT 1
""").fetchone()
if row:
return dict(row)
return None
except sqlite3.Error as e:
log.e(f"Error getting admin data: {e}")
return None
def create_admin(username, password_hash, totp_secret):
"""Create admin user
Args:
username: Admin username
password_hash: Hashed password
totp_secret: TOTP secret for 2FA
Returns:
bool: True if successful, False otherwise
"""
try:
with get_db() as conn:
from datetime import datetime
conn.execute("""
INSERT INTO admin (username, password_hash, totp_secret, created_at)
VALUES (?, ?, ?, ?)
""", (username, password_hash, totp_secret, datetime.now().isoformat()))
log.i(f"Admin user created: {username}")
return True
except sqlite3.Error as e:
log.e(f"Error creating admin: {e}")
return False
def admin_exists():
"""Check if an admin user has been created
Returns:
bool: True if admin exists, False otherwise
"""
try:
with get_db() as conn:
cursor = conn.cursor()
count = cursor.execute("SELECT COUNT(*) FROM admin").fetchone()[0]
return count > 0
except sqlite3.Error as e:
log.e(f"Error checking if admin exists: {e}")
return False
def update_last_login(username):
"""Update admin's last login time
Args:
username: Admin username
Returns:
bool: True if successful, False otherwise
"""
try:
with get_db() as conn:
from datetime import datetime
conn.execute("""
UPDATE admin
SET last_login = ?
WHERE username = ?
""", (datetime.now().isoformat(), username))
return True
except sqlite3.Error as e:
log.e(f"Error updating last login: {e}")
return False

262
src/models/chunk_model.py Normal file
View File

@@ -0,0 +1,262 @@
import sqlite3
import json
import os
import shutil
from datetime import datetime, timedelta
from models.database import get_db
import utils.mLogger as log
def create_session(upload_id, filename, total_chunks, chunk_dir, max_downloads, expiry_hours):
"""Create a new chunked upload session
Args:
upload_id: 16-character unique upload ID
filename: Original filename
total_chunks: Total number of chunks expected
chunk_dir: Directory where chunks are stored
max_downloads: Maximum downloads for final file
expiry_hours: Expiry hours for final file
Returns:
bool: True if successful, False otherwise
"""
try:
with get_db() as conn:
conn.execute("""
INSERT INTO chunk_sessions
(upload_id, filename, total_chunks, received_chunks, created_at,
chunk_dir, max_downloads, expiry_hours)
VALUES (?, ?, ?, '[]', ?, ?, ?, ?)
""", (upload_id, filename, total_chunks, datetime.now().isoformat(),
chunk_dir, max_downloads, expiry_hours))
log.i(f"Created chunk session: {upload_id}")
return True
except sqlite3.Error as e:
log.e(f"Error creating chunk session: {e}")
return False
def get_session(upload_id):
"""Get chunk upload session data
Args:
upload_id: 16-character upload ID
Returns:
dict: Session data with keys: upload_id, filename, total_chunks, received_chunks (list),
created_at, chunk_dir, max_downloads, expiry_hours
None: If session not found
"""
try:
with get_db() as conn:
cursor = conn.cursor()
row = cursor.execute("""
SELECT upload_id, filename, total_chunks, received_chunks, created_at,
chunk_dir, max_downloads, expiry_hours
FROM chunk_sessions
WHERE upload_id = ?
""", (upload_id,)).fetchone()
if row:
data = dict(row)
# Parse received_chunks from JSON string to list
data['received_chunks'] = json.loads(data['received_chunks'])
return data
return None
except sqlite3.Error as e:
log.e(f"Error getting chunk session {upload_id}: {e}")
return None
def add_chunk(upload_id, chunk_index):
"""Add a chunk index to the received chunks list
Args:
upload_id: 16-character upload ID
chunk_index: Index of the chunk received
Returns:
bool: True if successful, False otherwise
"""
try:
with get_db() as conn:
cursor = conn.cursor()
# Get current received_chunks
row = cursor.execute("""
SELECT received_chunks FROM chunk_sessions WHERE upload_id = ?
""", (upload_id,)).fetchone()
if not row:
log.e(f"Chunk session not found: {upload_id}")
return False
received = json.loads(row['received_chunks'])
# Add chunk index if not already present
if chunk_index not in received:
received.append(chunk_index)
# Update database
cursor.execute("""
UPDATE chunk_sessions
SET received_chunks = ?
WHERE upload_id = ?
""", (json.dumps(received), upload_id))
log.i(f"Added chunk {chunk_index} to session {upload_id}")
return True
else:
log.w(f"Chunk {chunk_index} already in session {upload_id}")
return True
except sqlite3.Error as e:
log.e(f"Error adding chunk to session {upload_id}: {e}")
return False
def get_received_chunks(upload_id):
"""Get list of received chunk indices
Args:
upload_id: 16-character upload ID
Returns:
list: List of chunk indices, or None if session not found
"""
try:
with get_db() as conn:
cursor = conn.cursor()
row = cursor.execute("""
SELECT received_chunks FROM chunk_sessions WHERE upload_id = ?
""", (upload_id,)).fetchone()
if row:
return json.loads(row['received_chunks'])
return None
except sqlite3.Error as e:
log.e(f"Error getting received chunks for {upload_id}: {e}")
return None
def delete_session(upload_id):
"""Delete chunk upload session
Args:
upload_id: 16-character upload ID
Returns:
bool: True if deleted, False if not found or error
"""
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM chunk_sessions WHERE upload_id = ?", (upload_id,))
if cursor.rowcount > 0:
log.i(f"Deleted chunk session: {upload_id}")
return True
return False
except sqlite3.Error as e:
log.e(f"Error deleting chunk session {upload_id}: {e}")
return False
def get_abandoned_sessions(hours=24):
"""Get list of abandoned upload sessions older than specified hours
Args:
hours: Age threshold in hours (default: 24)
Returns:
list: List of dicts with session metadata
"""
try:
with get_db() as conn:
cursor = conn.cursor()
cutoff_time = (datetime.now() - timedelta(hours=hours)).isoformat()
rows = cursor.execute("""
SELECT upload_id, filename, total_chunks, received_chunks, created_at,
chunk_dir, max_downloads, expiry_hours
FROM chunk_sessions
WHERE created_at < ?
""", (cutoff_time,)).fetchall()
sessions = []
for row in rows:
data = dict(row)
data['received_chunks'] = json.loads(data['received_chunks'])
sessions.append(data)
return sessions
except sqlite3.Error as e:
log.e(f"Error getting abandoned sessions: {e}")
return []
def cleanup_abandoned(hours=24):
"""Delete abandoned upload sessions and their chunk directories
Args:
hours: Age threshold in hours (default: 24)
Returns:
int: Number of sessions cleaned up
"""
try:
# Get abandoned sessions first so we can clean up directories
abandoned = get_abandoned_sessions(hours)
if not abandoned:
return 0
with get_db() as conn:
cursor = conn.cursor()
cutoff_time = (datetime.now() - timedelta(hours=hours)).isoformat()
# Delete from database
cursor.execute("""
DELETE FROM chunk_sessions
WHERE created_at < ?
""", (cutoff_time,))
count = cursor.rowcount
# Delete chunk directories
for session in abandoned:
if os.path.exists(session['chunk_dir']):
try:
shutil.rmtree(session['chunk_dir'])
log.i(f"Deleted chunk directory: {session['chunk_dir']}")
except Exception as e:
log.w(f"Could not delete chunk directory {session['chunk_dir']}: {e}")
if count > 0:
log.i(f"Cleaned up {count} abandoned upload session(s)")
return count
except sqlite3.Error as e:
log.e(f"Error cleaning up abandoned sessions: {e}")
return 0
def update_received_chunks(upload_id, received_chunks):
"""Update the entire received_chunks list (used for verification corrections)
Args:
upload_id: 16-character upload ID
received_chunks: List of chunk indices
Returns:
bool: True if successful, False otherwise
"""
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE chunk_sessions
SET received_chunks = ?
WHERE upload_id = ?
""", (json.dumps(received_chunks), upload_id))
if cursor.rowcount > 0:
log.i(f"Updated received_chunks for session {upload_id}")
return True
return False
except sqlite3.Error as e:
log.e(f"Error updating received_chunks for {upload_id}: {e}")
return False

157
src/models/config_model.py Normal file
View File

@@ -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

263
src/models/database.py Normal file
View File

@@ -0,0 +1,263 @@
import sqlite3
import json
import os
from contextlib import contextmanager
import utils.mLogger as log
# Database file location (relative to server directory)
DB_DIR = 'db'
DB_PATH = os.path.join(DB_DIR, 'file_server.db')
# Ensure database directory exists
if not os.path.exists(DB_DIR):
os.makedirs(DB_DIR)
def get_db_connection():
"""Get a thread-safe database connection with row factory"""
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row # Access columns by name
# Enable WAL mode for better concurrency
conn.execute("PRAGMA journal_mode=WAL")
# Set busy timeout to 5 seconds to handle concurrent access
conn.execute("PRAGMA busy_timeout=5000")
return conn
@contextmanager
def get_db():
"""Context manager for database connections"""
conn = get_db_connection()
try:
yield conn
conn.commit()
except Exception as e:
conn.rollback()
log.e(f"Database error: {e}")
raise
finally:
conn.close()
def init_db():
"""Initialize database with schema"""
log.i("Initializing database...")
with get_db() as conn:
# Admin table
conn.execute("""
CREATE TABLE IF NOT EXISTS admin (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
totp_secret TEXT NOT NULL,
created_at TEXT NOT NULL,
last_login TEXT
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_admin_username ON admin(username)
""")
# Files table
conn.execute("""
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
path TEXT NOT NULL,
max_downloads INTEGER NOT NULL DEFAULT 1,
downloads INTEGER NOT NULL DEFAULT 0,
expiry_time TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
CONSTRAINT code_length CHECK(length(code) = 6),
CONSTRAINT max_downloads_positive CHECK(max_downloads > 0),
CONSTRAINT downloads_non_negative CHECK(downloads >= 0)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_files_code ON files(code)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_files_expiry ON files(expiry_time)
""")
# Chunk sessions table
conn.execute("""
CREATE TABLE IF NOT EXISTS chunk_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
upload_id TEXT NOT NULL UNIQUE,
filename TEXT NOT NULL,
total_chunks INTEGER NOT NULL,
received_chunks TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
chunk_dir TEXT NOT NULL,
max_downloads INTEGER NOT NULL DEFAULT 1,
expiry_hours INTEGER NOT NULL DEFAULT 24,
CONSTRAINT upload_id_length CHECK(length(upload_id) = 16),
CONSTRAINT total_chunks_positive CHECK(total_chunks > 0)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_chunk_sessions_upload_id ON chunk_sessions(upload_id)
""")
conn.execute("""
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():
"""One-time migration from JSON files to SQLite"""
# Check if we need to migrate
admin_json = 'admin_data.json'
file_json = 'file_metadata.json'
chunk_json = 'chunk_metadata.json'
# Only migrate if at least one JSON file exists
if not any(os.path.exists(f) for f in [admin_json, file_json, chunk_json]):
log.i("No JSON files found, skipping migration")
return
log.i("Starting migration from JSON to SQLite...")
migrated = False
with get_db() as conn:
# Migrate admin_data.json
if os.path.exists(admin_json):
try:
with open(admin_json, 'r') as f:
admin = json.load(f)
# Check if admin already exists in database
existing = conn.execute("SELECT COUNT(*) FROM admin").fetchone()[0]
if existing == 0:
conn.execute("""
INSERT INTO admin (username, password_hash, totp_secret, created_at)
VALUES (?, ?, ?, ?)
""", (admin['username'], admin['password_hash'],
admin['totp_secret'], admin['created_at']))
log.i(f"Migrated admin user: {admin['username']}")
migrated = True
else:
log.i("Admin already exists in database, skipping admin migration")
except Exception as e:
log.e(f"Error migrating admin_data.json: {e}")
# Migrate file_metadata.json
if os.path.exists(file_json):
try:
with open(file_json, 'r') as f:
files = json.load(f)
file_count = 0
for code, file_info in files.items():
# Check if file already exists
existing = conn.execute("SELECT COUNT(*) FROM files WHERE code = ?", (code,)).fetchone()[0]
if existing == 0:
conn.execute("""
INSERT INTO files (code, filename, path, max_downloads, downloads, expiry_time)
VALUES (?, ?, ?, ?, ?, ?)
""", (code, file_info['filename'], file_info['path'],
file_info['max_downloads'], file_info['downloads'],
file_info['expiry_time']))
file_count += 1
if file_count > 0:
log.i(f"Migrated {file_count} file(s)")
migrated = True
else:
log.i("No new files to migrate")
except Exception as e:
log.e(f"Error migrating file_metadata.json: {e}")
# Migrate chunk_metadata.json
if os.path.exists(chunk_json):
try:
with open(chunk_json, 'r') as f:
chunks = json.load(f)
chunk_count = 0
for upload_id, session in chunks.items():
# Check if session already exists
existing = conn.execute("SELECT COUNT(*) FROM chunk_sessions WHERE upload_id = ?", (upload_id,)).fetchone()[0]
if existing == 0:
conn.execute("""
INSERT INTO chunk_sessions
(upload_id, filename, total_chunks, received_chunks, created_at,
chunk_dir, max_downloads, expiry_hours)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (upload_id, session['filename'], session['total_chunks'],
json.dumps(session['received_chunks']), session['created_at'],
session['chunk_dir'], session['max_downloads'],
session['expiry_hours']))
chunk_count += 1
if chunk_count > 0:
log.i(f"Migrated {chunk_count} chunk session(s)")
migrated = True
else:
log.i("No new chunk sessions to migrate")
except Exception as e:
log.e(f"Error migrating chunk_metadata.json: {e}")
# Backup JSON files after successful migration
if migrated:
for filename in [admin_json, file_json, chunk_json]:
if os.path.exists(filename):
backup_name = f'{filename}.backup'
try:
os.rename(filename, backup_name)
log.i(f"Backed up {filename} to {backup_name}")
except Exception as e:
log.w(f"Could not backup {filename}: {e}")
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()

199
src/models/file_model.py Normal file
View File

@@ -0,0 +1,199 @@
import sqlite3
import os
from datetime import datetime, timedelta
from models.database import get_db
import utils.mLogger as log
def create_file(code, filename, path, max_downloads, expiry_hours):
"""Create a new file metadata record
Args:
code: 6-character unique code
filename: Original filename
path: Absolute path to file
max_downloads: Maximum number of downloads allowed
expiry_hours: Hours until file expires
Returns:
bool: True if successful, False otherwise
"""
try:
with get_db() as conn:
expiry_time = datetime.now() + timedelta(hours=expiry_hours)
conn.execute("""
INSERT INTO files (code, filename, path, max_downloads, downloads, expiry_time)
VALUES (?, ?, ?, ?, 0, ?)
""", (code, filename, path, max_downloads, expiry_time.isoformat()))
log.i(f"Created file record: {code} - {filename}")
return True
except sqlite3.Error as e:
log.e(f"Error creating file record: {e}")
return False
def get_file(code):
"""Get file metadata by code
Args:
code: 6-character file code
Returns:
dict: File metadata with keys: code, filename, path, max_downloads, downloads, expiry_time
None: If file not found
"""
try:
with get_db() as conn:
cursor = conn.cursor()
row = cursor.execute("""
SELECT code, filename, path, max_downloads, downloads, expiry_time
FROM files
WHERE code = ?
""", (code,)).fetchone()
if row:
return dict(row)
return None
except sqlite3.Error as e:
log.e(f"Error getting file {code}: {e}")
return None
def increment_download(code):
"""Atomically increment download counter for a file
Args:
code: 6-character file code
Returns:
int: New download count, or None if file not found
"""
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE files
SET downloads = downloads + 1
WHERE code = ?
""", (code,))
if cursor.rowcount > 0:
# Get the new download count
row = cursor.execute("""
SELECT downloads FROM files WHERE code = ?
""", (code,)).fetchone()
if row:
new_count = row['downloads']
log.i(f"Incremented download counter for {code}: {new_count}")
return new_count
return None
except sqlite3.Error as e:
log.e(f"Error incrementing download for {code}: {e}")
return None
def delete_file(code):
"""Delete file metadata record
Args:
code: 6-character file code
Returns:
bool: True if deleted, False if not found or error
"""
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM files WHERE code = ?", (code,))
if cursor.rowcount > 0:
log.i(f"Deleted file record: {code}")
return True
return False
except sqlite3.Error as e:
log.e(f"Error deleting file {code}: {e}")
return False
def get_expired_files():
"""Get list of expired files or files that exceeded download limit
Returns:
list: List of dicts with file metadata
"""
try:
with get_db() as conn:
cursor = conn.cursor()
now = datetime.now().isoformat()
rows = cursor.execute("""
SELECT code, filename, path, max_downloads, downloads, expiry_time
FROM files
WHERE expiry_time < ? OR downloads >= max_downloads
""", (now,)).fetchall()
return [dict(row) for row in rows]
except sqlite3.Error as e:
log.e(f"Error getting expired files: {e}")
return []
def cleanup_expired():
"""Delete expired file records and files that reached download limit
Returns:
int: Number of files cleaned up
"""
try:
# Get expired files first so we can delete physical files
expired_files = get_expired_files()
if not expired_files:
return 0
with get_db() as conn:
cursor = conn.cursor()
now = datetime.now().isoformat()
# Delete from database
cursor.execute("""
DELETE FROM files
WHERE expiry_time < ? OR downloads >= max_downloads
""", (now,))
count = cursor.rowcount
# Delete physical files
for file_info in expired_files:
if os.path.exists(file_info['path']):
try:
os.remove(file_info['path'])
log.i(f"Deleted expired file: {file_info['path']}")
except Exception as e:
log.w(f"Could not delete file {file_info['path']}: {e}")
if count > 0:
log.i(f"Cleaned up {count} expired file(s)")
return count
except sqlite3.Error as e:
log.e(f"Error cleaning up expired files: {e}")
return 0
def get_all_files():
"""Get all file metadata records
Returns:
list: List of dicts with file metadata
"""
try:
with get_db() as conn:
cursor = conn.cursor()
rows = cursor.execute("""
SELECT code, filename, path, max_downloads, downloads, expiry_time, created_at
FROM files
ORDER BY created_at DESC
""").fetchall()
return [dict(row) for row in rows]
except sqlite3.Error as e:
log.e(f"Error getting all files: {e}")
return []

View File

@@ -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:

View File

@@ -0,0 +1,734 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Dashboard - Simple File Server</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #2d3748;
padding: 2rem 1rem;
}
.container {
background: rgba(255, 255, 255, 0.95);
padding: 2rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
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;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 2px solid #e2e8f0;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.icon {
color: #667eea;
font-size: 2.5rem;
}
.header-text h1 {
color: #2d3748;
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.header-text .username {
color: #718096;
font-size: 0.9rem;
}
.logout-btn {
background: transparent;
color: #718096;
padding: 0.5rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.logout-btn:hover {
border-color: #e53e3e;
color: #e53e3e;
background: #fff5f5;
}
.card {
background: #f7fafc;
padding: 1.25rem;
border-radius: 0.75rem;
border-left: 4px solid #667eea;
}
.card-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #2d3748;
margin-bottom: 1rem;
font-size: 1.1rem;
}
.card-title .material-icons {
color: #667eea;
font-size: 1.5rem;
}
.key-container {
background: white;
padding: 1rem;
border-radius: 0.5rem;
border: 2px solid #e2e8f0;
position: relative;
}
.key-display {
font-family: 'Courier New', monospace;
font-size: 0.95rem;
word-break: break-all;
color: #2d3748;
padding-right: 3rem;
line-height: 1.6;
}
.copy-btn {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: #667eea;
color: white;
border: none;
border-radius: 0.375rem;
padding: 0.5rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.copy-btn:hover {
background: #5a67d8;
transform: scale(1.05);
}
.copy-btn:active {
transform: scale(0.95);
}
.copy-btn .material-icons {
font-size: 1.25rem;
}
.info-box {
background: #ebf8ff;
color: #2c5282;
padding: 0.75rem;
border-radius: 0.5rem;
border-left: 3px solid #4299e1;
font-size: 0.8rem;
line-height: 1.5;
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.info-box .material-icons {
color: #4299e1;
font-size: 1.5rem;
flex-shrink: 0;
}
.success-message {
background: #f0fff4;
color: #2f855a;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
display: none;
align-items: center;
gap: 0.5rem;
animation: slideIn 0.3s ease;
}
.success-message .material-icons {
color: #48bb78;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.btn {
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;
text-decoration: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.btn-secondary {
background: white;
color: #667eea;
border: 2px solid #667eea;
}
.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;
}
</style>
<script>
function copyToClipboard() {
const keyText = document.querySelector('.key-display').textContent;
navigator.clipboard.writeText(keyText).then(() => {
const successMsg = document.getElementById('copy-success');
successMsg.style.display = 'flex';
setTimeout(() => {
successMsg.style.display = 'none';
}, 3000);
});
}
function validateConfig() {
const address = document.getElementById('server_address').value.trim();
const port = parseInt(document.getElementById('server_port').value);
if (!address) {
alert('Server address is required');
return false;
}
if (isNaN(port) || port < 1 || port > 65535) {
alert('Port must be between 1 and 65535');
return false;
}
return true;
}
function confirmRegenerate() {
const confirmed = confirm(
'WARNING: Regenerating the client key will immediately invalidate the current key.\n\n' +
'All Android apps and clients using the old key will be unable to upload files until they are updated with the new key.\n\n' +
'Are you sure you want to continue?'
);
if (confirmed) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/admin/regenerate-key';
document.body.appendChild(form);
form.submit();
}
}
function switchTab(tabName) {
// Hide all tab contents
const tabContents = document.querySelectorAll('.tab-content');
tabContents.forEach(content => content.classList.remove('active'));
// Remove active class from all tabs
const tabs = document.querySelectorAll('.tab');
tabs.forEach(tab => tab.classList.remove('active'));
// Show selected tab content
const selectedContent = document.getElementById(tabName + '-tab');
if (selectedContent) {
selectedContent.classList.add('active');
}
// Add active class to clicked tab
event.target.closest('.tab').classList.add('active');
}
function downloadQRCode() {
const qrImage = document.querySelector('.qr-image');
if (!qrImage) return;
// Convert base64 to blob and download
const base64Data = qrImage.src.split(',')[1];
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'image/png' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'server-config-qr.png';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
// Show success message
showCopySuccess('QR code downloaded!');
}
async function copyQRConfig() {
const qrImage = document.querySelector('.qr-image');
if (!qrImage) return;
try {
// Convert base64 to blob
const base64Data = qrImage.src.split(',')[1];
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: 'image/png' });
// Copy to clipboard
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob
})
]);
showCopySuccess('QR code copied to clipboard!');
} catch (err) {
console.error('Failed to copy QR code:', err);
alert('Failed to copy QR code to clipboard. Your browser may not support this feature.');
}
}
function showCopySuccess(message) {
const successMsg = document.getElementById('copy-success');
const messageSpan = successMsg.querySelector('span:last-child');
if (messageSpan) {
const originalMessage = messageSpan.textContent;
messageSpan.textContent = message;
successMsg.style.display = 'flex';
setTimeout(() => {
successMsg.style.display = 'none';
messageSpan.textContent = originalMessage;
}, 3000);
}
}
</script>
</head>
<body>
<div class="container">
<div class="header">
<div class="header-left">
<span class="material-icons icon">dashboard</span>
<div class="header-text">
<h1>Admin Dashboard</h1>
<div class="username">Logged in as {{ username }}</div>
</div>
</div>
<a href="/admin/logout" class="logout-btn">
<span class="material-icons" style="font-size: 1.25rem;">logout</span>
Logout
</a>
</div>
<!-- Error Message -->
{% if error %}
<div class="error-message">
<span class="material-icons">error</span>
{{ error }}
</div>
{% endif %}
<!-- Success Message -->
{% if success %}
<div class="success-message" style="display: flex;">
<span class="material-icons">check_circle</span>
{{ success }}
</div>
{% endif %}
<div class="success-message" id="copy-success">
<span class="material-icons">check_circle</span>
<span>Client key copied to clipboard!</span>
</div>
<!-- Tabs Navigation -->
<div class="tabs">
<button class="tab active" onclick="switchTab('config')">
<span class="material-icons" style="font-size: 1.2rem; vertical-align: middle;">settings</span>
Configuration
</button>
<button class="tab" onclick="switchTab('qrcode')" {% if not qr_code %}disabled style="opacity: 0.5; cursor: not-allowed;"{% endif %}>
<span class="material-icons" style="font-size: 1.2rem; vertical-align: middle;">qr_code_2</span>
QR Code
</button>
</div>
<!-- Configuration Tab -->
<div id="config-tab" class="tab-content active">
<div class="cards-grid">
<!-- Server Configuration Card -->
<div class="card">
<div class="card-title">
<span class="material-icons">settings</span>
Server Configuration
</div>
<form method="POST" action="/admin/config" onsubmit="return validateConfig()">
<div class="form-group">
<label for="server_address">Server Address</label>
<input type="text"
id="server_address"
name="server_address"
class="form-input"
value="{{ server_address }}"
placeholder="e.g., 192.168.1.100 or myserver.com"
required>
<small class="form-hint">IP address or hostname where the server is accessible</small>
</div>
<div class="form-group">
<label for="server_port">Port</label>
<input type="number"
id="server_port"
name="server_port"
class="form-input"
value="{{ server_port }}"
min="1"
max="65535"
required>
<small class="form-hint">Port number (default: 7777)</small>
</div>
<button type="submit" class="btn btn-primary">
<span class="material-icons">save</span>
Save Configuration
</button>
</form>
</div>
<!-- Client Key Card -->
<div class="card">
<div class="card-title">
<span class="material-icons">vpn_key</span>
Client Key
</div>
<div class="key-container">
<div class="key-display">{{ client_key }}</div>
<button class="copy-btn" onclick="copyToClipboard()" title="Copy to clipboard">
<span class="material-icons">content_copy</span>
</button>
</div>
<div class="info-box">
<span class="material-icons">info</span>
<div>
<strong>What is the Client Key?</strong><br>
This key is used by clients to authenticate API requests to the file server.
It should be included in the <code>X-Client-Key</code> header for all upload operations.
Keep this key secure and don't share it publicly.
</div>
</div>
<div class="key-actions">
<button type="button" class="btn btn-danger" onclick="confirmRegenerate()">
<span class="material-icons">refresh</span>
Regenerate Key
</button>
</div>
</div>
</div><!-- end cards-grid -->
</div><!-- end config-tab -->
<!-- QR Code Tab -->
<div id="qrcode-tab" class="tab-content">
{% if qr_code %}
<div class="card">
<div class="card-title">
<span class="material-icons">qr_code_2</span>
QR Code for Android App
</div>
<div class="qr-container">
<img src="data:image/png;base64,{{ qr_code }}" alt="Configuration QR Code" class="qr-image">
</div>
<div class="info-box">
<span class="material-icons">info</span>
<div>
Scan this QR code with the Simple File Upload Android app to automatically configure:
<ul style="margin: 0.5rem 0 0 1.5rem;">
<li>Server address: <strong>{{ server_address }}</strong></li>
<li>Port: <strong>{{ server_port }}</strong></li>
<li>Client key: <strong>(hidden)</strong></li>
</ul>
</div>
</div>
<div class="qr-actions">
<button class="btn-icon btn-download" onclick="downloadQRCode()">
<span class="material-icons">download</span>
Download PNG
</button>
<button class="btn-icon btn-copy-config" onclick="copyQRConfig()">
<span class="material-icons">content_copy</span>
Copy QR Code
</button>
</div>
</div>
{% else %}
<div class="info-box" style="background: #fef5e7; border-left-color: #f39c12; color: #7d6608;">
<span class="material-icons" style="color: #f39c12;">warning</span>
<div>
<strong>Configure server settings to generate QR code</strong><br>
Enter your server address and port above to generate a QR code for the Android app.
</div>
</div>
{% endif %}
</div><!-- end qrcode-tab -->
<div class="actions">
<a href="/" class="btn btn-secondary">
<span class="material-icons">home</span>
Home
</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Simple File Server</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #2d3748;
}
.container {
background: rgba(255, 255, 255, 0.95);
padding: 2.5rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 450px;
backdrop-filter: blur(10px);
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.icon {
color: #667eea;
font-size: 3rem;
margin-bottom: 1rem;
}
h1 {
color: #2d3748;
font-size: 1.75rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.subtitle {
color: #718096;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
color: #4a5568;
margin-bottom: 0.5rem;
}
label .material-icons {
font-size: 1.2rem;
color: #667eea;
}
input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1rem;
transition: all 0.2s;
background: white;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.totp-input {
text-align: center;
font-size: 1.5rem;
letter-spacing: 0.5rem;
font-family: 'Courier New', monospace;
}
button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.875rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
button:active {
transform: translateY(0);
}
.error {
background-color: #fff5f5;
color: #c53030;
padding: 0.75rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #feb2b2;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.error .material-icons {
color: #c53030;
}
.totp-hint {
font-size: 0.8rem;
color: #718096;
margin-top: 0.25rem;
text-align: center;
}
.back-link {
text-align: center;
margin-top: 1rem;
}
.back-link a {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.back-link a:hover {
text-decoration: underline;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
const totpInput = document.querySelector('.totp-input');
// Auto-submit on 6 digits
totpInput.addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/\D/g, '');
if (e.target.value.length === 6) {
// Optional: auto-submit
// document.querySelector('form').submit();
}
});
});
</script>
</head>
<body>
<div class="container">
<div class="header">
<span class="material-icons icon">security</span>
<h1>Admin Login</h1>
<p class="subtitle">Enter your credentials</p>
</div>
{% if error %}
<div class="error">
<span class="material-icons">error</span>
<span>{{ error }}</span>
</div>
{% endif %}
<form method="POST">
<div class="form-group">
<label>
<span class="material-icons">person</span>
Username
</label>
<input type="text" name="username" required autocomplete="username" autofocus>
</div>
<div class="form-group">
<label>
<span class="material-icons">lock</span>
Password
</label>
<input type="password" name="password" required autocomplete="current-password">
</div>
<div class="form-group">
<label>
<span class="material-icons">pin</span>
Two-Factor Code
</label>
<input type="text" name="totp_code" class="totp-input" required
maxlength="6" pattern="[0-9]{6}" inputmode="numeric"
autocomplete="one-time-code" placeholder="000000">
<div class="totp-hint">Enter the 6-digit code from your authenticator app</div>
</div>
<button type="submit">
<span class="material-icons">login</span>
Login
</button>
</form>
<div class="back-link">
<a href="/">
<span class="material-icons" style="font-size: 1rem;">home</span>
Back to Home
</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Setup - Simple File Server</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #2d3748;
}
.container {
background: rgba(255, 255, 255, 0.95);
padding: 2.5rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 450px;
backdrop-filter: blur(10px);
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.icon {
color: #667eea;
font-size: 3rem;
margin-bottom: 1rem;
}
h1 {
color: #2d3748;
font-size: 1.75rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.subtitle {
color: #718096;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
color: #4a5568;
margin-bottom: 0.5rem;
}
label .material-icons {
font-size: 1.2rem;
color: #667eea;
}
input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1rem;
transition: all 0.2s;
background: white;
}
input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.875rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
button:active {
transform: translateY(0);
}
.error {
background-color: #fff5f5;
color: #c53030;
padding: 0.75rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #feb2b2;
font-size: 0.9rem;
}
.info-box {
background-color: #ebf4ff;
color: #2c5282;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border-left: 4px solid #4299e1;
font-size: 0.85rem;
line-height: 1.5;
}
.password-hint {
font-size: 0.8rem;
color: #718096;
margin-top: 0.25rem;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<span class="material-icons icon">admin_panel_settings</span>
<h1>Admin Setup</h1>
<p class="subtitle">Create your administrator account</p>
</div>
<div class="info-box">
Step 1 of 2: Create your admin credentials. You'll configure two-factor authentication on the next step.
</div>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="POST">
<div class="form-group">
<label>
<span class="material-icons">person</span>
Username
</label>
<input type="text" name="username" required autocomplete="username" autofocus>
</div>
<div class="form-group">
<label>
<span class="material-icons">lock</span>
Password
</label>
<input type="password" name="password" required autocomplete="new-password" minlength="8">
<div class="password-hint">Minimum 8 characters</div>
</div>
<div class="form-group">
<label>
<span class="material-icons">lock</span>
Confirm Password
</label>
<input type="password" name="confirm_password" required autocomplete="new-password" minlength="8">
</div>
<button type="submit">
<span class="material-icons">arrow_forward</span>
Continue to Two-Factor Setup
</button>
</form>
</div>
</body>
</html>

View File

@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setup Complete - Simple File Server</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #2d3748;
}
.container {
background: rgba(255, 255, 255, 0.95);
padding: 2.5rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 500px;
backdrop-filter: blur(10px);
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.icon {
color: #48bb78;
font-size: 3.5rem;
margin-bottom: 1rem;
}
h1 {
color: #2d3748;
font-size: 1.75rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.subtitle {
color: #718096;
font-size: 0.9rem;
}
.step {
background: #f7fafc;
padding: 1.5rem;
border-radius: 0.75rem;
margin-bottom: 1.5rem;
border-left: 4px solid #667eea;
}
.step-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #2d3748;
margin-bottom: 1rem;
font-size: 1.1rem;
}
.step-title .material-icons {
color: #667eea;
font-size: 1.5rem;
}
.qr-container {
text-align: center;
margin: 1rem 0;
background: white;
padding: 1rem;
border-radius: 0.5rem;
}
.qr-code {
max-width: 200px;
height: auto;
}
.secret-code {
background: white;
padding: 1rem;
border-radius: 0.5rem;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
word-break: break-all;
border: 2px dashed #cbd5e0;
margin: 0.5rem 0;
text-align: center;
font-weight: bold;
color: #2d3748;
}
.warning {
background-color: #fffaf0;
color: #744210;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border-left: 4px solid #ed8936;
font-size: 0.85rem;
display: flex;
align-items: start;
gap: 0.75rem;
}
.warning .material-icons {
color: #ed8936;
font-size: 1.5rem;
}
button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.875rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.step-content {
color: #4a5568;
line-height: 1.6;
}
ol {
margin-left: 1.25rem;
margin-top: 0.5rem;
}
li {
margin-bottom: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<span class="material-icons icon">check_circle</span>
<h1>Setup Complete!</h1>
<p class="subtitle">Your admin account has been created</p>
</div>
<div class="step">
<div class="step-content" style="text-align: center;">
<p style="font-size: 1.1rem; color: #2d3748; margin-bottom: 1rem;">
Welcome, <strong>{{ username }}</strong>!
</p>
<p style="color: #4a5568; line-height: 1.6;">
Your administrator account has been successfully created and your authenticator app is configured.
You can now login to access the admin dashboard.
</p>
</div>
</div>
<div class="warning">
<span class="material-icons">info</span>
<div>
<strong>Important:</strong> Make sure you've saved your authenticator app configuration.
You'll need it every time you log in to the admin panel.
</div>
</div>
<button onclick="window.location.href='/admin/login'">
<span class="material-icons">login</span>
Continue to Login
</button>
</div>
</body>
</html>

View File

@@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Setup Two-Factor Authentication - Simple File Server</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
color: #2d3748;
padding: 2rem 1rem;
}
.container {
background: rgba(255, 255, 255, 0.95);
padding: 2.5rem;
border-radius: 1rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 550px;
backdrop-filter: blur(10px);
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.icon {
color: #667eea;
font-size: 3rem;
margin-bottom: 1rem;
}
h1 {
color: #2d3748;
font-size: 1.75rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.subtitle {
color: #718096;
font-size: 0.9rem;
}
.step-indicator {
background: #ebf4ff;
color: #2c5282;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border-left: 4px solid #4299e1;
font-size: 0.85rem;
font-weight: 500;
}
.info-box {
background-color: #fffaf0;
color: #744210;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border-left: 4px solid #ed8936;
font-size: 0.85rem;
line-height: 1.6;
display: flex;
align-items: start;
gap: 0.75rem;
}
.info-box .material-icons {
color: #ed8936;
font-size: 1.5rem;
flex-shrink: 0;
}
.error {
background-color: #fff5f5;
color: #c53030;
padding: 0.75rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #feb2b2;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.error .material-icons {
color: #c53030;
}
.totp-setup-section {
background: #f7fafc;
padding: 1.5rem;
border-radius: 0.75rem;
margin-bottom: 1.5rem;
border: 2px solid #e2e8f0;
}
.section-title {
font-size: 1.1rem;
margin-bottom: 1rem;
color: #2d3748;
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.section-title .material-icons {
color: #667eea;
}
.qr-container {
text-align: center;
margin: 1rem 0;
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
}
.qr-code {
max-width: 220px;
height: auto;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
padding: 0.5rem;
}
.secret-display {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 2px dashed #e2e8f0;
}
.secret-label {
font-size: 0.85rem;
color: #4a5568;
margin-bottom: 0.5rem;
font-weight: 500;
}
.secret-code {
background: white;
padding: 0.875rem;
border-radius: 0.5rem;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
word-break: break-all;
border: 2px dashed #cbd5e0;
text-align: center;
font-weight: bold;
color: #2d3748;
margin-bottom: 0.5rem;
}
.account-info {
font-size: 0.8rem;
color: #718096;
text-align: center;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
color: #4a5568;
margin-bottom: 0.5rem;
}
label .material-icons {
font-size: 1.2rem;
color: #667eea;
}
.totp-input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 1.5rem;
transition: all 0.2s;
background: white;
text-align: center;
letter-spacing: 0.5rem;
font-family: 'Courier New', monospace;
}
.totp-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.totp-hint {
font-size: 0.8rem;
color: #718096;
margin-top: 0.25rem;
text-align: center;
}
button {
width: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 0.875rem;
border: none;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
button:active {
transform: translateY(0);
}
.steps-list {
list-style: none;
padding: 0;
margin: 0;
}
.steps-list li {
padding: 0.5rem 0;
color: #4a5568;
font-size: 0.9rem;
line-height: 1.5;
}
.steps-list li:before {
content: "→";
color: #667eea;
font-weight: bold;
margin-right: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<span class="material-icons icon">security</span>
<h1>Two-Factor Authentication</h1>
<p class="subtitle">Secure your admin account</p>
</div>
<div class="step-indicator">
Step 2 of 2: Configure your authenticator app
</div>
<div class="info-box">
<span class="material-icons">info</span>
<div>
<strong>Required:</strong> You need an authenticator app to continue.
Download Google Authenticator, Authy, or any TOTP-compatible app before proceeding.
</div>
</div>
{% if error %}
<div class="error">
<span class="material-icons">error</span>
<span>{{ error }}</span>
</div>
{% endif %}
<div class="totp-setup-section">
<div class="section-title">
<span class="material-icons">qr_code_scanner</span>
Scan QR Code
</div>
<div class="qr-container">
<img class="qr-code" src="data:image/png;base64,{{ qr_code }}" alt="TOTP QR Code">
</div>
<div class="secret-display">
<div class="section-title" style="font-size: 1rem; margin-bottom: 0.75rem;">
<span class="material-icons" style="font-size: 1.25rem;">vpn_key</span>
Or Enter Manually
</div>
<div class="secret-label">Secret Key:</div>
<div class="secret-code">{{ totp_secret }}</div>
<div class="account-info">
Account: {{ username }}<br>
Issuer: Simple File Server
</div>
</div>
</div>
<form method="POST">
<div class="form-group">
<label>
<span class="material-icons">verified_user</span>
Verify Authentication Code
</label>
<input type="text" name="totp_code" class="totp-input" required
maxlength="6" pattern="[0-9]{6}" inputmode="numeric"
autocomplete="off" placeholder="000000" autofocus>
<div class="totp-hint">Enter the 6-digit code from your authenticator app</div>
</div>
<button type="submit">
<span class="material-icons">check_circle</span>
Complete Setup
</button>
</form>
</div>
</body>
</html>

View File

@@ -22,6 +22,7 @@
align-items: center;
min-height: 100vh;
color: #2d3748;
position: relative;
}
.container {
@@ -170,6 +171,96 @@
@keyframes spin {
to { transform: rotate(360deg); }
}
.version {
position: fixed;
bottom: 1rem;
right: 1rem;
font-size: 0.75rem;
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;
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
@@ -179,6 +270,9 @@
const deleteButton = document.querySelector('.delete-button');
const spinner = document.querySelector('.spinner');
const message = document.querySelector('.message');
const deleteModal = document.getElementById('deleteModal');
const modalCancel = document.getElementById('modalCancel');
const modalConfirm = document.getElementById('modalConfirm');
const showMessage = (text, type) => {
message.textContent = text;
@@ -189,6 +283,38 @@
}, 5000);
};
const showDeleteModal = () => {
return new Promise((resolve) => {
deleteModal.classList.add('active');
const handleConfirm = () => {
deleteModal.classList.remove('active');
modalConfirm.removeEventListener('click', handleConfirm);
modalCancel.removeEventListener('click', handleCancel);
deleteModal.removeEventListener('click', handleOverlayClick);
resolve(true);
};
const handleCancel = () => {
deleteModal.classList.remove('active');
modalConfirm.removeEventListener('click', handleConfirm);
modalCancel.removeEventListener('click', handleCancel);
deleteModal.removeEventListener('click', handleOverlayClick);
resolve(false);
};
const handleOverlayClick = (e) => {
if (e.target === deleteModal) {
handleCancel();
}
};
modalConfirm.addEventListener('click', handleConfirm);
modalCancel.addEventListener('click', handleCancel);
deleteModal.addEventListener('click', handleOverlayClick);
});
};
inputs.forEach((input, index) => {
input.addEventListener('input', (e) => {
const value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
@@ -227,13 +353,14 @@
deleteButton.addEventListener('click', 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;
}
if (!confirm('Are you sure you want to delete this file? This action cannot be undone.')) {
const confirmed = await showDeleteModal();
if (!confirmed) {
return;
}
@@ -270,7 +397,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;
@@ -282,8 +409,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 {
@@ -322,5 +467,17 @@
<div class="message"></div>
</form>
</div>
<div class="version">v{{ version }}</div>
<div class="modal-overlay" id="deleteModal">
<div class="modal">
<h2>Delete File</h2>
<p>Are you sure you want to delete this file? This action cannot be undone.</p>
<div class="modal-buttons">
<button type="button" class="modal-cancel" id="modalCancel">Cancel</button>
<button type="button" class="modal-confirm" id="modalConfirm">Delete</button>
</div>
</div>
</div>
</body>
</html>

1
src/version.txt Normal file
View File

@@ -0,0 +1 @@
25.12.0

View File

@@ -1,7 +1,16 @@
import os
from flask import Blueprint, render_template
views = Blueprint('views', __name__)
def get_version():
version_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'version.txt')
try:
with open(version_path) as f:
return f.read().strip()
except:
return '0.0.0'
@views.route('/')
def home():
return render_template('index.html')
return render_template('index.html', version=get_version())