From 738fb9f1790f37fab06c80663eb1cecad76afbf7 Mon Sep 17 00:00:00 2001 From: Anry Das Date: Sun, 19 Oct 2025 09:22:23 +0300 Subject: [PATCH] re-Made server totally - version 1.0 now come. --- .env.exampl | 9 + README.md | 367 ++++++++++++ app_config.py | 25 +- configs/server_config.yaml | 9 +- css/main.css | 64 --- docker-compose.yaml | 38 ++ dockerfile | 24 + html/main.html | 51 -- js/main.js | 171 ------ main.py | 1086 +++++++++++++++++++++++------------- requirements.txt | 15 +- setup.sh | 72 +++ templates/admin.html | 266 +++++++++ templates/base.html | 334 +++++++++++ templates/login.html | 139 +++++ templates/settings.html | 134 +++++ templates/user_files.html | 228 ++++++++ 17 files changed, 2308 insertions(+), 724 deletions(-) create mode 100644 .env.exampl create mode 100644 README.md delete mode 100644 css/main.css create mode 100644 docker-compose.yaml create mode 100644 dockerfile delete mode 100644 html/main.html delete mode 100644 js/main.js create mode 100644 setup.sh create mode 100644 templates/admin.html create mode 100644 templates/base.html create mode 100644 templates/login.html create mode 100644 templates/settings.html create mode 100644 templates/user_files.html diff --git a/.env.exampl b/.env.exampl new file mode 100644 index 0000000..3d37e9d --- /dev/null +++ b/.env.exampl @@ -0,0 +1,9 @@ +# Database Configuration +DATABASE_URL=postgresql://username:password@localhost:5432/filestore + +# Upload Directory +UPLOAD_DIR=./uploads + +# Server Configuration +HOST=0.0.0.0 +PORT=8000 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc3ecbd --- /dev/null +++ b/README.md @@ -0,0 +1,367 @@ +# Das File Storage Web Application + +A production-ready file storage service built with FastAPI, PostgreSQL, and session-based authentication. + +## Features + +- **Web Interface**: Modern, responsive GUI for users and admins +- **User Authentication**: Session-based auth for web, HTTP Basic Auth and API Key for REST API +- **Role-Based Access**: Admin and regular user roles +- **File Management**: Upload, download, delete files via web UI or API +- **Secure Sharing**: Generate secure share links without exposing usernames or filenames +- **Admin Panel**: Full user management interface (create, delete, block users) +- **Settings Page**: Change password, manage API keys +- **Password Security**: PBKDF2-SHA256 password hashing +- **Database**: PostgreSQL for all metadata storage + +## Quick Start + +### Using Docker Compose (Recommended) + +1. Clone the repository +2. Update database credentials in `docker-compose.yml` +3. Run: +```bash +docker-compose up -d +``` + +The application will be available at `http://localhost:8000` by default + +### Project Structure + +``` +project/ +├── main.py # Main application file +├── requirements.txt # Python dependencies +├── docker-compose.yml # Docker configuration +├── Dockerfile # Container image +├── .env # Environment variables +├── templates/ # HTML templates +│ ├── base.html # Base template +│ ├── login.html # Login page +│ ├── user_files.html # User dashboard +│ ├── settings.html # Settings page +│ └── admin.html # Admin panel +└── uploads/ # File storage directory +``` + +### Manual Installation + +1. Install PostgreSQL and create a database: +```sql +CREATE DATABASE filestore; +``` + +2. Install Python dependencies: +```bash +pip install -r requirements.txt +``` + +3. Create `.env` file: +```bash +cp .env.example .env +# Edit .env with your database credentials +``` + +4. Update `configs/server_config.yaml` file with your own server's configuration (including `secrets.yaml` if needed) + +5. Create templates directory and add HTML files: +```bash +mkdir templates +# Copy all template files (base.html, login.html, user_files.html, settings.html, admin.html) +``` + +6. Run the application: +```bash +python main.py +``` + +Or with uvicorn: +```bash +uvicorn main:app --host 0.0.0.0 --port 8000 --reload +``` + +## Default Admin Account + +On first startup, a default admin account is created: +- **Username**: `admin` +- **Password**: `admin123` +- **⚠️ Change this password immediately in production!** + +The admin API key will be printed in the console on first startup. + +## Web Interface + +### User Interface + +1. **Login Page** (`/login`) + - Clean, modern login interface + - Username and password authentication + +2. **Dashboard** (`/dashboard`) + - View all your uploaded files + - Upload new files with drag-and-drop + - Download files + - Generate and copy share links + - Delete files + - File size and upload date information + +3. **Settings** (`/settings`) + - Change your password + - View and copy your API key + - Regenerate API key + - View account information + +### Admin Interface + +4. **Admin Panel** (`/admin`) + - Dashboard statistics (total users, active/blocked, total files) + - User management table + - Create new users + - Block/unblock users + - Delete users + - View file counts per user + +### Navigation + +All pages have a navigation bar with: + - User badge showing username and role + - Quick access to dashboard, settings, and admin panel (if admin) + - Logout button + +## API Documentation + +Once running, visit: +- Interactive API docs: `http://localhost:8000/docs` +- Alternative docs: `http://localhost:8000/redoc` + +## API Endpoints + +### Authentication + +**HTTP Basic Auth**: Use username and password in the Authorization header +```bash +curl -u username:password http://localhost:8000/api/files +``` + +**API Key Auth**: Use X-API-Key header +```bash +curl -H "X-API-Key: your-api-key" http://localhost:8000/api/files +``` + +### User Management (Admin Only) + +#### Create User +```bash +POST /api/users +Authorization: Basic admin:admin123 +Content-Type: application/json + +{ + "username": "newuser", + "password": "securepass123", + "is_admin": false +} +``` + +#### List Users +```bash +GET /api/users +Authorization: Basic admin:admin123 +``` + +#### Delete User +```bash +DELETE /api/users/{user_id} +Authorization: Basic admin:admin123 +``` + +#### Block/Unblock User +```bash +PATCH /api/users/{user_id}/block?block=true +Authorization: Basic admin:admin123 +``` + +#### Get Your API Key +```bash +GET /api/users/me/api-key +Authorization: Basic username:password +``` + +#### Regenerate API Key +```bash +POST /api/users/me/api-key/regenerate +Authorization: Basic username:password +``` + +### File Management + +#### Upload File +```bash +POST /api/files/upload +Authorization: Basic username:password +Content-Type: multipart/form-data + +file=@/path/to/file.pdf +``` + +With API Key: +```bash +curl -X POST \ + -H "X-API-Key: your-api-key" \ + -F "file=@document.pdf" \ + http://localhost:8000/api/files/upload +``` + +#### List Your Files +```bash +GET /api/files +Authorization: Basic username:password +``` + +#### Download File +```bash +GET /api/files/{file_id}/download +Authorization: Basic username:password +``` + +#### Delete File +```bash +DELETE /api/files/{file_id} +Authorization: Basic username:password +``` + +### Public File Sharing + +#### Download Shared File (No Auth Required) +```bash +GET /share/{share_token} +``` + +Example: `http://localhost:8000/share/abc123xyz...` + +## Database Schema + +### Users Table +- `id`: Primary key +- `username`: Unique username +- `password_hash`: PBKDF2-SHA256 hashed password +- `is_admin`: Admin role flag +- `is_blocked`: Block status +- `api_key`: Unique API key for REST authentication +- `created_at`: Account creation timestamp + +### Files Table +- `id`: Primary key +- `filename`: Original filename +- `stored_filename`: Unique stored filename +- `file_size`: File size in bytes +- `content_type`: MIME type +- `share_token`: Secure token for public sharing +- `user_id`: Foreign key to users +- `uploaded_at`: Upload timestamp + +## Security Features + +1. **Password Hashing**: PBKDF2-SHA256 with 100,000 iterations +2. **Secure Tokens**: Cryptographically secure random tokens for API keys and share links +3. **User Isolation**: Users can only access their own files +4. **Admin Controls**: Block/unblock users, manage accounts +5. **Share Links**: No exposure of usernames or original filenames + +## Production Deployment + +### Environment Variables + +```bash +DATABASE_URL=postgresql://user:pass@host:port/dbname +UPLOAD_DIR=/var/app/uploads +``` + +### Security Checklist + +- [ ] Change default admin password +- [ ] Use strong database credentials +- [ ] Enable HTTPS/TLS +- [ ] Set up firewall rules +- [ ] Configure backup strategy +- [ ] Set up monitoring and logging +- [ ] Limit file upload sizes +- [ ] Implement rate limiting +- [ ] Regular security updates + +### Nginx Configuration Example + +```nginx +server { + listen 80; + server_name yourdomain.com; + + client_max_body_size 100M; + + location / { + proxy_pass http://localhost:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### PostgreSQL Optimization + +```sql +-- Create indexes for better performance +CREATE INDEX idx_files_user_id ON files(user_id); +CREATE INDEX idx_files_share_token ON files(share_token); +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_api_key ON users(api_key); +``` + +## Testing + +### Create a Test User +```bash +curl -X POST http://localhost:8000/api/users \ + -u admin:admin123 \ + -H "Content-Type: application/json" \ + -d '{"username": "testuser", "password": "testpass123"}' +``` + +### Upload a Test File +```bash +echo "Hello World" > test.txt +curl -X POST http://localhost:8000/api/files/upload \ + -u testuser:testpass123 \ + -F "file=@test.txt" +``` + +### List Files +```bash +curl http://localhost:8000/api/files \ + -u testuser:testpass123 +``` + +## Troubleshooting + +### Database Connection Issues +- Verify PostgreSQL is running +- Check DATABASE_URL is correct +- Ensure database exists and user has permissions + +### File Upload Issues +- Check UPLOAD_DIR exists and has write permissions +- Verify disk space is available +- Check file size limits + +### Authentication Issues +- Verify username/password are correct +- Check if user is blocked +- Ensure API key is valid and not regenerated + +## Support + +For issues and questions, please open an issue on the repository. + +###### _Made by -=:dAs:=-_ \ No newline at end of file diff --git a/app_config.py b/app_config.py index c5e32c5..c676091 100644 --- a/app_config.py +++ b/app_config.py @@ -1,30 +1,17 @@ import os from config_file import read_config as read_cfg -APP_VERSION="1.0" +APP_VERSION="1.0.0" SCRIPT_PATH = os.path.dirname(__file__) CONFIGS_DIR = SCRIPT_PATH + "/configs" CONFIG_FILE_NAME = CONFIGS_DIR + "/server_config.yaml" API_KEY = '' -UPLOAD_DIR = 'uploads' +UPLOAD_DIR = '.uploads' DB_URL = '' APP_PORT = 25100 IS_DEBUG = False IS_DOCS_AVAILABLE = False -HTML_FILE = 'html/main.html' -CSS_FILE = 'css/main.css' -JS_FILE = 'js/main.js' -ALIAS_NUM_LETTERS = 3 -ALIAS_NUM_DIGITS = 4 -""" -For 3 + 4 defaults there are 1 406 080 000 possible values: -Кількість варіантів для кожної літери (великої або малої) дорівнює 52 (26 великих + 26 малих). -Кількість комбінацій для трьох літер: 52 * 52 * 52 = 140 608. -Кількість варіантів для кожної цифри: 10 (0-9). -Кількість комбінацій для чотирьох цифр: 10 * 10 * 10 * 10 = 10 000. -Загальна кількість комбінацій: 140 608 * 10 000 = 1 406 080 000. -""" def read_app_config(): j, _ = read_cfg(CONFIG_FILE_NAME) @@ -40,18 +27,12 @@ def get_config_value(cfg, section, key, default): return cfg[section][key] if section in cfg and key in cfg[section] else default def parse_config(cfg): - global UPLOAD_DIR, APP_PORT, IS_DEBUG, DB_URL, ALIAS_NUM_LETTERS, ALIAS_NUM_DIGITS\ - , IS_DOCS_AVAILABLE, HTML_FILE, CSS_FILE, JS_FILE + global UPLOAD_DIR, APP_PORT, IS_DEBUG, DB_URL, IS_DOCS_AVAILABLE UPLOAD_DIR = get_config_value(cfg, 'server', 'directory', UPLOAD_DIR) APP_PORT = get_config_value(cfg, 'server', 'port', APP_PORT) DB_URL = get_config_value(cfg, 'server', 'db_url', DB_URL) IS_DEBUG = get_config_value(cfg, 'server', 'debug', IS_DEBUG) - ALIAS_NUM_LETTERS = get_config_value(cfg, 'server', 'alias_letters', ALIAS_NUM_LETTERS) - ALIAS_NUM_DIGITS = get_config_value(cfg, 'server', 'alias_digits', ALIAS_NUM_DIGITS) IS_DOCS_AVAILABLE = get_config_value(cfg, 'server', 'docs_available', IS_DOCS_AVAILABLE) - HTML_FILE = get_config_value(cfg, 'interface', 'html', HTML_FILE) - CSS_FILE = get_config_value(cfg, 'interface', 'css', CSS_FILE) - JS_FILE = get_config_value(cfg, 'interface', 'js', JS_FILE) def init_config(): config = read_app_config() diff --git a/configs/server_config.yaml b/configs/server_config.yaml index dadfa93..9ecb40b 100644 --- a/configs/server_config.yaml +++ b/configs/server_config.yaml @@ -1,13 +1,8 @@ server: port: !secret port - directory: 'uploads' + directory: '.uploads' db_url: !secret db_url alias_letters: 3 alias_digits: 4 debug: false - docs_available: false - -interface: - html: 'html/main.html' - css: 'css/main.css' - script: 'js/main.js' + docs_available: true diff --git a/css/main.css b/css/main.css deleted file mode 100644 index 17c6f7b..0000000 --- a/css/main.css +++ /dev/null @@ -1,64 +0,0 @@ -body { - font-family: Arial, sans-serif; - max-width: 800px; - margin: 50px auto; - padding: 20px; -} -h1 { color: #333; } -.section { - background: #f5f5f5; - padding: 20px; - margin: 20px 0; - border-radius: 8px; -} -input[type="file"], input[type="text"] { - margin: 10px 0; - padding: 8px; - width: 100%; - max-width: 400px; -} -button { - background: #007bff; - color: white; - padding: 10px 20px; - border: none; - border-radius: 4px; - cursor: pointer; - margin: 5px; -} -button:hover { - background: #0056b3; -} -.file-list { - list-style: none; - padding: 0; -} -.file-item { - background: white; - padding: 10px; - margin: 5px 0; - border-radius: 4px; - display: flex; - justify-content: space-between; - align-items: center; -} -.message { - padding: 10px; - margin: 10px 0; - border-radius: 4px; -} -.success { background: #d4edda; color: #155724; } -.error { background: #f8d7da; color: #721c24; } -.info { background: #d1ecf1; color: #0c5460; } -.hidden { display: none; } -.api-key-display { - background: white; - padding: 10px; - border-radius: 4px; - font-family: monospace; - word-break: break-all; -} -.file-date { - font-size: 0.85em; - color: #676; -} \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..336feff --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,38 @@ +version: '3.8' + +services: + postgres: + image: postgres:15-alpine + container_name: filestore_db + environment: + POSTGRES_USER: filestore_user + POSTGRES_PASSWORD: secure_password_here + POSTGRES_DB: filestore + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U filestore_user"] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: . + container_name: filestore_app + environment: + DATABASE_URL: postgresql://filestore_user:secure_password_here@postgres:5432/filestore + UPLOAD_DIR: /app/uploads + volumes: + - ./uploads:/app/uploads + - ./app:/app + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + +volumes: + postgres_data: \ No newline at end of file diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..8aea394 --- /dev/null +++ b/dockerfile @@ -0,0 +1,24 @@ +FROM python:3.13-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY main.py . + +# Create uploads directory +RUN mkdir -p /app/uploads + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/html/main.html b/html/main.html deleted file mode 100644 index 930b96a..0000000 --- a/html/main.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - Das File Server - - - - -

🔐 Secure File Server by -=:dAs:=-

- -
-

API Key Management

-
- - - -
- -
-
- - - - - - - - \ No newline at end of file diff --git a/js/main.js b/js/main.js deleted file mode 100644 index 5bb6918..0000000 --- a/js/main.js +++ /dev/null @@ -1,171 +0,0 @@ -let apiKey = localStorage.getItem('apiKey'); - -function updateUI() { - const hasKey = !!apiKey; - document.getElementById('noKeySection').classList.toggle('hidden', hasKey); - document.getElementById('hasKeySection').classList.toggle('hidden', !hasKey); - document.getElementById('uploadSection').classList.toggle('hidden', !hasKey); - document.getElementById('filesSection').classList.toggle('hidden', !hasKey); - - if (hasKey) { - document.getElementById('apiKeyDisplay').textContent = apiKey; - loadFiles(); - } -} - -async function generateKey() { - const messageDiv = document.getElementById('authMessage'); - try { - const response = await fetch('/add-user', { - method: 'POST' - }); - const result = await response.json(); - - if (response.ok) { - apiKey = result.api_key; - localStorage.setItem('apiKey', apiKey); - messageDiv.innerHTML = '
API key generated successfully!
'; - updateUI(); - } else { - messageDiv.innerHTML = `
${result.detail}
`; - } - } catch (error) { - messageDiv.innerHTML = '
Failed to generate key
'; - } -} - -function useExistingKey() { - const key = document.getElementById('existingKey').value.trim(); - const messageDiv = document.getElementById('authMessage'); - - if (!key) { - messageDiv.innerHTML = '
Please enter an API key
'; - return; - } - - apiKey = key; - localStorage.setItem('apiKey', apiKey); - messageDiv.innerHTML = '
API key set!
'; - document.getElementById('existingKey').value = ''; - updateUI(); -} - -function clearKey() { - apiKey = null; - localStorage.removeItem('apiKey'); - document.getElementById('authMessage').innerHTML = ''; - updateUI(); -} - -async function uploadFiles() { - const input = document.getElementById('fileInput'); - const files = input.files; - const messageDiv = document.getElementById('uploadMessage'); - - if (files.length === 0) { - messageDiv.innerHTML = '
Please select files
'; - return; - } - - const formData = new FormData(); - for (let file of files) { - formData.append('files', file); - } - - try { - const response = await fetch('/upload', { - method: 'POST', - headers: { - 'X-API-Key': apiKey - }, - body: formData - }); - const result = await response.json(); - - if (response.ok) { - messageDiv.innerHTML = '
Files uploaded successfully!
'; - input.value = ''; - loadFiles(); - } else { - messageDiv.innerHTML = `
${result.detail}
`; - } - } catch (error) { - messageDiv.innerHTML = '
Upload failed
'; - } -} - -async function loadFiles() { - if (!apiKey) return; - - try { - const response = await fetch('/files', { - headers: { - 'X-API-Key': apiKey - } - }); - const files = await response.json(); - const fileList = document.getElementById('fileList'); - - if (response.status === 401) { - document.getElementById('authMessage').innerHTML = '
Invalid API key
'; - clearKey(); - return; - } - - if (files.length === 0) { - fileList.innerHTML = '
  • No files available
  • '; - return; - } - - fileList.innerHTML = files.map(file => ` -
  • -
    -
    ${file.original_name} (${formatBytes(file.size)}) 
    -
    Uploaded: ${new Date(file.uploaded_at).toLocaleString()}
    -
    -
    - - -
    -
  • - `).join(''); - } catch (error) { - console.error('Failed to load files:', error); - } -} - -async function downloadFile(fileId, filename) { - window.location.href = `/download/${fileId}?api_key=${apiKey}`; -} - -async function deleteFile(fileId) { - if (!confirm('Are you sure you want to delete this file?')) return; - - try { - const response = await fetch(`/files/${fileId}`, { - method: 'DELETE', - headers: { - 'X-API-Key': apiKey - } - }); - - if (response.ok) { - loadFiles(); - } else { - alert('Failed to delete file'); - } - } catch (error) { - alert('Failed to delete file'); - } -} - -function formatBytes(bytes) { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; -} - -// Initialize UI -updateUI(); \ No newline at end of file diff --git a/main.py b/main.py index a022b3e..226bc20 100644 --- a/main.py +++ b/main.py @@ -1,472 +1,752 @@ -''' -🗄️ PostgreSQL Database Integration - -Database Tables: -users - Stores user_id, api_key, and creation timestamp -files - Stores file metadata with foreign key to users -Automatic cascading delete when user is removed -Indexed columns for fast lookups - -Stored Information: -API keys and user IDs -File metadata (name, size, path, upload date) -User-file relationships - -📁 File Organization -Directory Structure: -uploads/ -├── user-id-1/ -│ ├── file-id-1_document.pdf -│ └── file-id-2_image.jpg -└── user-id-2/ - └── file-id-3_data.csv -Each user gets their own subdirectory for better organization and isolation. - -⚙️ Setup Instructions - -Install dependencies: -bashpip install fastapi uvicorn python-multipart asyncpg - -Set up PostgreSQL database: -bash# Create database -createdb fileserver - -# Set DATABASE_URL environment variable -export DATABASE_URL="postgresql://username:password@localhost:5432/fileserver" - -Run the server: -bashpython script_name.py - -The database tables will be created automatically on startup! -🔑 Key Features - -✅ All data persists in PostgreSQL -✅ Files organized in user-specific subdirectories -✅ Automatic database initialization -✅ Connection pooling for better performance -✅ Proper foreign key relationships with cascade delete -✅ Timestamps for all uploads - -The DATABASE_URL can be configured via environment variable for different environments (development, production, etc.). -''' -import random -import string -import sys -from fastapi import FastAPI, File, UploadFile, HTTPException, Header, Depends -from fastapi.responses import FileResponse, HTMLResponse -from fastapi.security import APIKeyHeader -from pathlib import Path -import shutil import os -from typing import List, Optional -import uuid import secrets -from datetime import datetime -import asyncpg -from contextlib import asynccontextmanager +import hashlib +import sys +from datetime import datetime, timedelta +from typing import Optional, List +from pathlib import Path + +from fastapi import FastAPI, Depends, HTTPException, status, UploadFile, File, Form, Request, Cookie +from fastapi.security import HTTPBasic, HTTPBasicCredentials, APIKeyHeader +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from fastapi.staticfiles import StaticFiles +from sqlalchemy import create_engine, Column, Integer, String, DateTime, Boolean, ForeignKey, BigInteger, func +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, Session, relationship +from pydantic import BaseModel, Field +import uvicorn + import app_config ERROR_STATUS_NO_DB_URL = 10 -STYLE_MACRO = "#$STYLE$#" -SCRIPT_MACRO = "#$SCRIPT$#" -# Database configuration app_config.init_config() + +# Database Configuration DATABASE_URL = app_config.DB_URL - -# Global database pool -db_pool: asyncpg.Pool | None = None - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Checking DB URL""" - global DATABASE_URL +if not DATABASE_URL: + DATABASE_URL = os.getenv("DATABASE_URL", "") if not DATABASE_URL: - DATABASE_URL = os.getenv("DATABASE_URL", "") - if not DATABASE_URL: - print('DB URL does not set. DB URL is a mandatory variable.\nPlease provide it in config file or as a environment variable and restart the server') + print('DB URL does not set. DB URL is a mandatory variable.\nPlease provide it in config file or as an environment variable and start the server again') sys.exit(ERROR_STATUS_NO_DB_URL) - """Manage application lifespan - startup and shutdown""" - global db_pool - - # Startup: Create database pool and initialize tables - db_pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10) - await init_database() - print("✅ Database connected and initialized") - - yield - - # Shutdown: Close database pool - await db_pool.close() - print("👋 Database connection closed") - - -app = FastAPI( - title="Das File Server", - description="Upload, save, and download files with API key authentication", - lifespan=lifespan, - openapi_url="/api/v1/openapi.json" if app_config.IS_DOCS_AVAILABLE else None -) - -# Configure upload directory UPLOAD_DIR = Path(app_config.UPLOAD_DIR) UPLOAD_DIR.mkdir(exist_ok=True) -# API Key header +# Templates +templates = Jinja2Templates(directory="templates") + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + +# Security +security = HTTPBasic() api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) - -async def init_database(): - """Initialize database tables""" - async with db_pool.acquire() as conn: - # Create users table - await conn.execute(""" - CREATE TABLE IF NOT EXISTS users ( - user_id UUID PRIMARY KEY, - api_key VARCHAR(255) UNIQUE NOT NULL, - alias VARCHAR(32) UNIQUE NOT NULL, - created_at TIMESTAMP DEFAULT NOW() - ) - """) - - # Create files table - await conn.execute(""" - CREATE TABLE IF NOT EXISTS files ( - file_id UUID PRIMARY KEY, - user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, - original_name VARCHAR(500) NOT NULL, - stored_name VARCHAR(500) NOT NULL, - file_path TEXT NOT NULL, - size BIGINT NOT NULL, - uploaded_at TIMESTAMP DEFAULT NOW(), - CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(user_id) - ) - """) - - # Create index for faster lookups - await conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_files_user_id ON files(user_id) - """) - await conn.execute(""" - CREATE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key) - """) +# Session storage (in-memory for demo, use Redis in production) +sessions = {} -async def get_user_id(api_key: Optional[str] = Depends(api_key_header)) -> str: - """Validate API key and return user ID""" - if not api_key: - raise HTTPException(status_code=401, detail="Missing API key") +# Create tables +Base.metadata.create_all(bind=engine) - async with db_pool.acquire() as conn: - user = await conn.fetchrow( - "SELECT user_id FROM users WHERE api_key = $1", - api_key + +# Database Models +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, index=True, nullable=False) + password_hash = Column(String(256), nullable=False) + is_admin = Column(Boolean, default=False) + is_blocked = Column(Boolean, default=False) + api_key = Column(String(64), unique=True, index=True) + created_at = Column(DateTime, default=datetime.utcnow) + + files = relationship("FileMetadata", back_populates="owner", cascade="all, delete-orphan") + + +class FileMetadata(Base): + __tablename__ = "files" + + id = Column(Integer, primary_key=True, index=True) + filename = Column(String(255), nullable=False) + stored_filename = Column(String(255), unique=True, nullable=False) + file_size = Column(BigInteger, nullable=False) + content_type = Column(String(100)) + share_token = Column(String(64), unique=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + uploaded_at = Column(DateTime, default=datetime.utcnow) + + owner = relationship("User", back_populates="files") + + +# Pydantic Models +class UserCreate(BaseModel): + username: str = Field(..., min_length=3, max_length=50) + password: str = Field(..., min_length=8) + is_admin: bool = False + + +class UserResponse(BaseModel): + id: int + username: str + is_admin: bool + is_blocked: bool + created_at: datetime + + class Config: + from_attributes = True + + +class FileInfo(BaseModel): + id: int + filename: str + file_size: int + content_type: Optional[str] + uploaded_at: datetime + share_link: Optional[str] + + class Config: + from_attributes = True + +# Password Hashing with PBKDF2-SHA256 +def hash_password(password: str) -> str: + """Hash password using PBKDF2-SHA256""" + salt = os.urandom(32) + pwdhash = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000) + return salt.hex() + ':' + pwdhash.hex() + + +def verify_password(stored_password: str, provided_password: str) -> bool: + """Verify password against hash""" + try: + salt, pwdhash = stored_password.split(':') + return hashlib.pbkdf2_hmac( + 'sha256', + provided_password.encode('utf-8'), + bytes.fromhex(salt), + 100000 + ).hex() == pwdhash + except Exception: + return False + + +def generate_api_key() -> str: + """Generate a secure API key""" + return secrets.token_urlsafe(48) + + +def generate_share_token() -> str: + """Generate a secure share token""" + return secrets.token_urlsafe(32) + + +def generate_session_token() -> str: + """Generate a secure session token""" + return secrets.token_urlsafe(32) + + +def format_size(size_bytes: int) -> str: + """Format file size in human-readable format""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} TB" + + +# Add filter to Jinja2 +templates.env.filters['format_size'] = format_size + + +# Database Dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +# Session Management +def get_session_user(session_token: Optional[str] = Cookie(None), db: Session = Depends(get_db)) -> Optional[User]: + """Get user from session token""" + if not session_token or session_token not in sessions: + return None + + user_id = sessions[session_token] + user = db.query(User).filter(User.id == user_id).first() + + if not user or user.is_blocked: + return None + + return user + + +# Authentication +def get_current_user_http( + credentials: HTTPBasicCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + """Authenticate user via HTTP Basic Auth""" + user = db.query(User).filter(User.username == credentials.username).first() + + if not user or not verify_password(user.password_hash, credentials.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid credentials", + headers={"WWW-Authenticate": "Basic"}, ) - if not user: - raise HTTPException(status_code=401, detail="Invalid API key") + if user.is_blocked: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is blocked" + ) - return str(user['user_id']) + return user -def get_user_directory(user_id: str) -> Path: - """Get or create user's subdirectory""" - user_dir = UPLOAD_DIR / user_id - user_dir.mkdir(exist_ok=True) - return user_dir +def get_current_user_api( + api_key: Optional[str] = Depends(api_key_header), + db: Session = Depends(get_db) +) -> User: + """Authenticate user via API Key""" + if not api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="API Key required" + ) + + user = db.query(User).filter(User.api_key == api_key).first() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API Key" + ) + + if user.is_blocked: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is blocked" + ) + + return user -def get_file_content(file_name): +def get_current_user( + session_user: Optional[User] = Depends(get_session_user), + api_key: Optional[str] = Depends(api_key_header), + db: Session = Depends(get_db) +) -> User: + """Get current user from session or API authentication""" + if session_user: + return session_user + + if api_key: + return get_current_user_api(api_key, db) + + # Try HTTP Basic Auth try: - with open(file_name, 'r') as file: - file_content = file.read() - return file_content - except FileNotFoundError: - print(f"Error: The file '{file_name}' was not found.") - except Exception as e: - print(f"An error occurred: {e}") + from fastapi.security import HTTPBasicCredentials + # This is for API endpoints + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required" + ) + except: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required" + ) +def require_admin(user: User = Depends(get_current_user)) -> User: + """Require admin role""" + if not user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required" + ) + return user + + +# FastAPI Application +app = FastAPI( + title="File Storage Service by -=:dAs:=-", + description="Upload, save, download, share and remove files with for authenticated users included administrative dashboard and REST API", + openapi_url="/api/v1/openapi.json" if app_config.IS_DOCS_AVAILABLE else None, + version=app_config.APP_VERSION +) + + +# Initialize default admin user +@app.on_event("startup") +async def startup_event(): + db = SessionLocal() + try: + admin = db.query(User).filter(User.username == "admin").first() + if not admin: + admin = User( + username="admin", + password_hash=hash_password("admin123"), + is_admin=True, + api_key=generate_api_key() + ) + db.add(admin) + db.commit() + print(f"Default admin created. Username: admin, Password: admin123") + print(f"Admin API Key: {admin.api_key}") + finally: + db.close() + + +# Web Routes + @app.get("/", response_class=HTMLResponse) -async def home(): - """Serve the web interface""" - css = get_file_content(app_config.CSS_FILE) - js = get_file_content(app_config.JS_FILE) - return (get_file_content(app_config.HTML_FILE) - .replace(STYLE_MACRO, css) - .replace(SCRIPT_MACRO, js)) +async def root(session_user: Optional[User] = Depends(get_session_user)): + """Root page - redirect to login or dashboard""" + if session_user: + return RedirectResponse(url="/dashboard", status_code=302) + return RedirectResponse(url="/login", status_code=302) -@app.post("/new-key") -@app.get("/new-key") -async def generate_key(): - """Generate a new API key for a user""" - api_key = secrets.token_urlsafe(32) - try: - return { - "api_key": api_key, - "message": "New API key generated successfully." +@app.get("/login", response_class=HTMLResponse) +async def login_page(request: Request, error: Optional[str] = None): + """Login page""" + return templates.TemplateResponse("login.html", { + "request": request, + "error": error + }) + + +@app.post("/login") +async def login( + request: Request, + username: str = Form(...), + password: str = Form(...), + db: Session = Depends(get_db) +): + """Process login""" + user = db.query(User).filter(User.username == username).first() + + if not user or not verify_password(user.password_hash, password): + return templates.TemplateResponse("login.html", { + "request": request, + "error": "Invalid username or password" + }) + + if user.is_blocked: + return templates.TemplateResponse("login.html", { + "request": request, + "error": "Your account has been blocked" + }) + + # Create session + session_token = generate_session_token() + sessions[session_token] = user.id + + response = RedirectResponse(url="/dashboard", status_code=302) + response.set_cookie(key="session_token", value=session_token, httponly=True) + return response + + +@app.get("/logout") +async def logout(session_token: Optional[str] = Cookie(None)): + """Logout""" + if session_token and session_token in sessions: + del sessions[session_token] + + response = RedirectResponse(url="/login", status_code=302) + response.delete_cookie("session_token") + return response + + +@app.get("/dashboard", response_class=HTMLResponse) +async def dashboard( + request: Request, + user: User = Depends(get_session_user), + db: Session = Depends(get_db) +): + """User dashboard""" + if not user: + return RedirectResponse(url="/login", status_code=302) + + files = db.query(FileMetadata).filter(FileMetadata.user_id == user.id).order_by( + FileMetadata.uploaded_at.desc()).all() + + return templates.TemplateResponse("user_files.html", { + "request": request, + "username": user.username, + "is_admin": user.is_admin, + "files": files, + "format_size": format_size + }) + + +@app.get("/settings", response_class=HTMLResponse) +async def settings_page( + request: Request, + user: User = Depends(get_session_user), + db: Session = Depends(get_db) +): + """Settings page""" + if not user: + return RedirectResponse(url="/login", status_code=302) + + file_count = db.query(FileMetadata).filter(FileMetadata.user_id == user.id).count() + + return templates.TemplateResponse("settings.html", { + "request": request, + "username": user.username, + "is_admin": user.is_admin, + "api_key": user.api_key, + "file_count": file_count + }) + + +@app.post("/settings/change-password") +async def change_password( + request: Request, + current_password: str = Form(...), + new_password: str = Form(...), + confirm_password: str = Form(...), + user: User = Depends(get_session_user), + db: Session = Depends(get_db) +): + """Change password""" + if not user: + return RedirectResponse(url="/login", status_code=302) + + if not verify_password(user.password_hash, current_password): + file_count = db.query(FileMetadata).filter(FileMetadata.user_id == user.id).count() + return templates.TemplateResponse("settings.html", { + "request": request, + "username": user.username, + "is_admin": user.is_admin, + "api_key": user.api_key, + "file_count": file_count, + "error": "Current password is incorrect" + }) + + if new_password != confirm_password: + file_count = db.query(FileMetadata).filter(FileMetadata.user_id == user.id).count() + return templates.TemplateResponse("settings.html", { + "request": request, + "username": user.username, + "is_admin": user.is_admin, + "api_key": user.api_key, + "file_count": file_count, + "error": "New passwords do not match" + }) + + user.password_hash = hash_password(new_password) + db.commit() + + file_count = db.query(FileMetadata).filter(FileMetadata.user_id == user.id).count() + return templates.TemplateResponse("settings.html", { + "request": request, + "username": user.username, + "is_admin": user.is_admin, + "api_key": user.api_key, + "file_count": file_count, + "message": "Password changed successfully" + }) + + +@app.get("/admin", response_class=HTMLResponse) +async def admin_panel( + request: Request, + user: User = Depends(get_session_user), + db: Session = Depends(get_db) +): + """Admin panel""" + if not user: + return RedirectResponse(url="/login", status_code=302) + + if not user.is_admin: + return RedirectResponse(url="/dashboard", status_code=302) + + # Get all users with file counts + users = db.query( + User, + func.count(FileMetadata.id).label('file_count') + ).outerjoin(FileMetadata).group_by(User.id).order_by(User.created_at.desc()).all() + + users_list = [] + for u, file_count in users: + user_dict = { + 'id': u.id, + 'username': u.username, + 'is_admin': u.is_admin, + 'is_blocked': u.is_blocked, + 'created_at': u.created_at, + 'file_count': file_count } - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to generate API key: {str(e)}") + users_list.append(type('User', (), user_dict)()) - -@app.post("/new-alias") -@app.get("/new-alias") -async def generate_alias(): - """Generate a new alias""" - return { - "alias": await get_alias(), - "message": "New Alias generated successfully." + # Statistics + stats = { + 'total_users': db.query(User).count(), + 'active_users': db.query(User).filter(User.is_blocked == False).count(), + 'blocked_users': db.query(User).filter(User.is_blocked == True).count(), + 'total_files': db.query(FileMetadata).count() } -async def get_all_aliases(): - async with db_pool.acquire() as conn: - aliases = await conn.fetch(""" - SELECT alias FROM users - """) - return aliases - -async def get_alias(): - """Getting the alias""" - existing_aliases = await get_all_aliases() - alias = existing_aliases[0] if existing_aliases else "" - while alias in existing_aliases or alias == "": - num_letters = app_config.ALIAS_NUM_LETTERS - num_digits = app_config.ALIAS_NUM_DIGITS - letters = [random.choice(string.ascii_letters) for _ in range(num_letters)] - digits = [random.choice(string.digits) for _ in range(num_digits)] - res_list = letters + digits - #random.shuffle(res_list) - alias = ''.join(res_list) - return alias + return templates.TemplateResponse("admin.html", { + "request": request, + "username": user.username, + "is_admin": user.is_admin, + "users": users_list, + "stats": stats + }) -@app.post("/add-user") -async def add_new_user(): - """Add new user with new API key""" - # Generate secure random API key - api_key = secrets.token_urlsafe(32) - user_id = uuid.uuid4() - user_alias = get_alias() +# API Endpoints - async with db_pool.acquire() as conn: - try: - # Store API key in database - await conn.execute( - "INSERT INTO users (user_id, api_key) VALUES ($1, $2, $3)", - user_id, api_key, user_alias - ) - - # Create user's directory - get_user_directory(str(user_id)) - - return { - "api_key": api_key, - "user_id": str(user_id), - "message": "API key generated successfully. Keep it secure!" - } - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to generate API key: {str(e)}") - - -@app.post("/upload") -async def upload_files( - files: List[UploadFile] = File(...), - user_id: str = Depends(get_user_id) +# User Management (Admin Only) +@app.post("/api/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def create_user( + user_data: UserCreate, + db: Session = Depends(get_db), + admin: User = Depends(require_admin) ): - """Upload one or multiple files (requires API key)""" - uploaded_files = [] - user_dir = get_user_directory(user_id) + """Create a new user (Admin only)""" + existing = db.query(User).filter(User.username == user_data.username).first() + if existing: + raise HTTPException(status_code=400, detail="Username already exists") - async with db_pool.acquire() as conn: - for file in files: - # Generate unique ID for file - file_id = uuid.uuid4() - stored_name = f"{file_id}_{file.filename}" - file_path = user_dir / stored_name + new_user = User( + username=user_data.username, + password_hash=hash_password(user_data.password), + is_admin=user_data.is_admin, + api_key=generate_api_key() + ) - # Save file - try: - with open(file_path, "wb") as buffer: - shutil.copyfileobj(file.file, buffer) + db.add(new_user) + db.commit() + db.refresh(new_user) - file_size = os.path.getsize(file_path) - - # Store metadata in database - await conn.execute(""" - INSERT INTO files (file_id, user_id, original_name, stored_name, file_path, size) - VALUES ($1, $2, $3, $4, $5, $6) - """, file_id, uuid.UUID(user_id), file.filename, stored_name, str(file_path), file_size) - - uploaded_files.append({ - "id": str(file_id), - "filename": file.filename, - "size": file_size - }) - except Exception as e: - # Clean up file if database insert fails - if file_path.exists(): - os.remove(file_path) - raise HTTPException(status_code=500, detail=f"Failed to upload {file.filename}: {str(e)}") - - return {"message": "Files uploaded successfully", "files": uploaded_files} + return new_user -@app.get("/files") -async def list_files(user_id: str = Depends(get_user_id)): - """List all files for the authenticated user""" - async with db_pool.acquire() as conn: - files = await conn.fetch(""" - SELECT file_id, original_name, size, uploaded_at - FROM files - WHERE user_id = $1 - ORDER BY uploaded_at DESC - """, uuid.UUID(user_id)) - - return [ - { - "file_id": str(file['file_id']), - "original_name": file['original_name'], - "size": file['size'], - "uploaded_at": file['uploaded_at'].isoformat() - } - for file in files - ] +@app.get("/api/users", response_model=List[UserResponse]) +async def list_users( + db: Session = Depends(get_db), + admin: User = Depends(require_admin) +): + """List all users (Admin only)""" + users = db.query(User).all() + return users -@app.get("/download/{file_id}") +@app.delete("/api/users/{user_id}") +async def delete_user( + user_id: int, + db: Session = Depends(get_db), + admin: User = Depends(require_admin) +): + """Delete a user (Admin only)""" + if user_id == admin.id: + raise HTTPException(status_code=400, detail="Cannot delete yourself") + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + db.delete(user) + db.commit() + + return {"message": "User deleted successfully"} + + +@app.patch("/api/users/{user_id}/block") +async def toggle_block_user( + user_id: int, + block: bool, + db: Session = Depends(get_db), + admin: User = Depends(require_admin) +): + """Block or unblock a user (Admin only)""" + if user_id == admin.id: + raise HTTPException(status_code=400, detail="Cannot block yourself") + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user.is_blocked = block + db.commit() + + return {"message": f"User {'blocked' if block else 'unblocked'} successfully"} + + +@app.get("/api/users/me/api-key") +async def get_my_api_key(user: User = Depends(get_current_user)): + """Get your API key""" + return {"api_key": user.api_key} + + +@app.post("/api/users/me/api-key/regenerate") +async def regenerate_api_key( + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Regenerate your API key""" + user.api_key = generate_api_key() + db.commit() + return {"api_key": user.api_key} + + +# File Management +@app.post("/api/files/upload", response_model=FileInfo) +async def upload_file( + file: UploadFile = File(...), + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """Upload a file""" + # Generate unique stored filename + stored_filename = f"{secrets.token_urlsafe(16)}_{file.filename}" + file_path = UPLOAD_DIR / stored_filename + + # Save file + content = await file.read() + with open(file_path, "wb") as f: + f.write(content) + + # Create metadata + file_metadata = FileMetadata( + filename=file.filename, + stored_filename=stored_filename, + file_size=len(content), + content_type=file.content_type, + share_token=generate_share_token(), + user_id=user.id + ) + + db.add(file_metadata) + db.commit() + db.refresh(file_metadata) + + return FileInfo( + id=file_metadata.id, + filename=file_metadata.filename, + file_size=file_metadata.file_size, + content_type=file_metadata.content_type, + uploaded_at=file_metadata.uploaded_at, + share_link=f"/share/{file_metadata.share_token}" + ) + + +@app.get("/api/files", response_model=List[FileInfo]) +async def list_files( + user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """List user's files""" + files = db.query(FileMetadata).filter(FileMetadata.user_id == user.id).all() + + return [ + FileInfo( + id=f.id, + filename=f.filename, + file_size=f.file_size, + content_type=f.content_type, + uploaded_at=f.uploaded_at, + share_link=f"/share/{f.share_token}" + ) + for f in files + ] + + +@app.get("/api/files/{file_id}/download") async def download_file( - file_id: str, - api_key: Optional[str] = None, - x_api_key: Optional[str] = Header(None) + file_id: int, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) ): - """Download a file by ID (requires API key)""" - # Accept API key from query param or header - key = api_key or x_api_key + """Download a file""" + file_metadata = db.query(FileMetadata).filter( + FileMetadata.id == file_id, + FileMetadata.user_id == user.id + ).first() - if not key: - raise HTTPException(status_code=401, detail="Missing API key") + if not file_metadata: + raise HTTPException(status_code=404, detail="File not found") - async with db_pool.acquire() as conn: - # Verify API key and get user - user = await conn.fetchrow( - "SELECT user_id FROM users WHERE api_key = $1", - key - ) + file_path = UPLOAD_DIR / file_metadata.stored_filename + if not file_path.exists(): + raise HTTPException(status_code=404, detail="File not found on disk") - if not user: - raise HTTPException(status_code=401, detail="Invalid API key") - - # Get file info and verify ownership - file_info = await conn.fetchrow(""" - SELECT file_path, original_name - FROM files - WHERE file_id = $1 AND user_id = $2 - """, uuid.UUID(file_id), user['user_id']) - - if not file_info: - raise HTTPException(status_code=404, detail="File not found or access denied") - - file_path = Path(file_info['file_path']) - - if not file_path.exists(): - raise HTTPException(status_code=404, detail="File not found on disk") - - return FileResponse( - path=file_path, - filename=file_info['original_name'], - media_type="application/octet-stream" - ) + from fastapi.responses import FileResponse as FastAPIFileResponse + return FastAPIFileResponse( + path=str(file_path), + filename=file_metadata.filename, + media_type=file_metadata.content_type + ) -@app.get("/dn/{file_id}") -async def download_link( - file_id: str -): - """Download a file by DIRECT LINK (without API key)""" - async with db_pool.acquire() as conn: - # Get file info and verify ownership - file_info = await conn.fetchrow(""" - SELECT file_path, original_name - FROM files - WHERE file_id = $1 - """, uuid.UUID(file_id)) - - if not file_info: - raise HTTPException(status_code=404, detail="File not found or access denied") - - file_path = Path(file_info['file_path']) - - if not file_path.exists(): - raise HTTPException(status_code=404, detail="File not found on disk") - - return FileResponse( - path=file_path, - filename=file_info['original_name'], - media_type="application/octet-stream" - ) - - -@app.delete("/files/{file_id}") +@app.delete("/api/files/{file_id}") async def delete_file( - file_id: str, - user_id: str = Depends(get_user_id) + file_id: int, + user: User = Depends(get_current_user), + db: Session = Depends(get_db) ): - """Delete a file by ID (only owner can delete)""" - async with db_pool.acquire() as conn: - # Get file info and verify ownership - file_info = await conn.fetchrow(""" - SELECT file_path - FROM files - WHERE file_id = $1 AND user_id = $2 - """, uuid.UUID(file_id), uuid.UUID(user_id)) + """Delete a file""" + file_metadata = db.query(FileMetadata).filter( + FileMetadata.id == file_id, + FileMetadata.user_id == user.id + ).first() - if not file_info: - raise HTTPException(status_code=404, detail="File not found or access denied") + if not file_metadata: + raise HTTPException(status_code=404, detail="File not found") - file_path = Path(file_info['file_path']) + # Delete physical file + file_path = UPLOAD_DIR / file_metadata.stored_filename + if file_path.exists(): + file_path.unlink() - # Delete file from disk - if file_path.exists(): - os.remove(file_path) - - # Remove from database - await conn.execute( - "DELETE FROM files WHERE file_id = $1", - uuid.UUID(file_id) - ) + # Delete metadata + db.delete(file_metadata) + db.commit() return {"message": "File deleted successfully"} -@app.get("/files/{file_id}/info") -async def get_file_info( - file_id: str, - user_id: str = Depends(get_user_id) -): - """Get file information by ID (only owner can view)""" - async with db_pool.acquire() as conn: - file_info = await conn.fetchrow(""" - SELECT file_id, original_name, stored_name, size, uploaded_at - FROM files - WHERE file_id = $1 AND user_id = $2 - """, uuid.UUID(file_id), uuid.UUID(user_id)) +# Public Share Endpoint +@app.get("/share/{share_token}") +async def download_shared_file(share_token: str, db: Session = Depends(get_db)): + """Download a shared file (no authentication required)""" + file_metadata = db.query(FileMetadata).filter( + FileMetadata.share_token == share_token + ).first() - if not file_info: - raise HTTPException(status_code=404, detail="File not found or access denied") + if not file_metadata: + raise HTTPException(status_code=404, detail="File not found") - return { - "file_id": str(file_info['file_id']), - "original_name": file_info['original_name'], - "stored_name": file_info['stored_name'], - "size": file_info['size'], - "uploaded_at": file_info['uploaded_at'].isoformat() - } + file_path = UPLOAD_DIR / file_metadata.stored_filename + if not file_path.exists(): + raise HTTPException(status_code=404, detail="File not found on disk") + + from fastapi.responses import FileResponse as FastAPIFileResponse + return FastAPIFileResponse( + path=str(file_path), + filename=file_metadata.filename, + media_type=file_metadata.content_type + ) + + +# Health Check +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy"} if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=app_config.APP_PORT) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5053b89..2d94f48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,9 @@ -fastapi~=0.118.0 -uvicorn~=0.37.0 -python-multipart -fastapi[standard] -asyncpg~=0.30.0 -PyYAML~=6.0.3 \ No newline at end of file +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +psycopg2-binary==2.9.9 +python-multipart==0.0.19 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-dotenv==1.0.0 +jinja2==3.1.6 \ No newline at end of file diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..522a7f1 --- /dev/null +++ b/setup.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +echo "🚀 Setting up Das File Storage Application..." + +# Create necessary directories +echo "📁 Creating directories..." +mkdir -p uploads +mkdir -p templates + +# Check if templates exist +if [ ! -f "templates/base.html" ]; then + echo "⚠️ Warning: Template files not found in templates/ directory" + echo "Please make sure to copy all HTML template files to the templates/ directory:" + echo " - base.html" + echo " - login.html" + echo " - user_files.html" + echo " - settings.html" + echo " - admin.html" +fi + +# Create .env if it doesn't exist +if [ ! -f ".env" ]; then + echo "📝 Creating .env file..." + cp .env.example .env + echo "✅ Created .env file. Please update with your database credentials." +fi + +# Check if Python is installed +if ! command -v python3 &> /dev/null; then + echo "❌ Python 3 is not installed. Please install Python 3.8 or higher." + exit 1 +fi + +# Check if PostgreSQL is running (optional check) +if command -v pg_isready &> /dev/null; then + if pg_isready &> /dev/null; then + echo "✅ PostgreSQL is running" + else + echo "⚠️ PostgreSQL is not running. Please start PostgreSQL." + fi +fi + +# Create virtual environment +if [ ! -d "venv" ]; then + echo "🐍 Creating virtual environment..." + python3 -m venv venv +fi + +# Activate virtual environment +echo "🔧 Activating virtual environment..." +source venv/bin/activate + +# Install requirements +echo "📦 Installing Python packages..." +pip install --upgrade pip +pip install -r requirements.txt + +echo "" +echo "✅ Setup complete!" +echo "" +echo "📋 Next steps:" +echo "1. Update .env file with your PostgreSQL credentials" +echo "2. Make sure all template files are in the templates/ directory" +echo "3. Create PostgreSQL database: createdb filestore" +echo "4. Run the application: python main.py" +echo "5. Open browser: http://localhost:8000" +echo "6. Login with default admin credentials:" +echo " Username: admin" +echo " Password: admin123" +echo "" +echo "⚠️ Remember to change the admin password immediately!" +echo "" \ No newline at end of file diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..5e85aaf --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,266 @@ +{% extends "base.html" %} + +{% block title %}Admin Panel - Das File Storage{% endblock %} + +{% block content %} + + +{% if message %} +
    + {{ message }} +
    +{% endif %} + +{% if error %} +
    + {{ error }} +
    +{% endif %} + +
    +

    User Management

    + +
    + +
    +

    📊 Statistics

    +
    +
    +
    Total Users
    +
    {{ stats.total_users }}
    +
    +
    +
    Active Users
    +
    {{ stats.active_users }}
    +
    +
    +
    Blocked Users
    +
    {{ stats.blocked_users }}
    +
    +
    +
    Total Files
    +
    {{ stats.total_files }}
    +
    +
    +
    + + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + {% endfor %} + +
    UsernameRoleFilesCreatedStatusActions
    + {{ user.username }} + + {% if user.is_admin %} + Admin + {% else %} + User + {% endif %} + {{ user.file_count }} files{{ user.created_at.strftime('%Y-%m-%d') }} + {% if user.is_blocked %} + 🚫 Blocked + {% else %} + ✅ Active + {% endif %} + + {% if user.username != username %} + {% if user.is_blocked %} + + {% else %} + + {% endif %} + + {% else %} + Current User + {% endif %} +
    + + + + +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..94a7ab1 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,334 @@ + + + + + + {% block title %}Das File Storage{% endblock %} + + {% block extra_style %}{% endblock %} + + +
    +
    +

    📁 File Storage Service by -=:dAs:=-

    + +
    + +
    + {% block content %}{% endblock %} +
    +
    + + {% block extra_scripts %}{% endblock %} + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..1077af1 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,139 @@ + + + + + + Das File Storage + + + +
    + + + {% if error %} +
    + {{ error }} +
    + {% endif %} + +
    +
    + + +
    + +
    + + +
    + + +
    +
    + + \ No newline at end of file diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..747aa06 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} + +{% block title %}Settings - Das File Storage{% endblock %} + +{% block content %} + + +{% if message %} +
    + {{ message }} +
    +{% endif %} + +{% if error %} +
    + {{ error }} +
    +{% endif %} + +

    Account Settings

    + +
    + +
    +

    🔒 Change Password

    +
    +
    + + +
    +
    + + + Minimum 8 characters +
    +
    + + +
    + +
    +
    + + +
    +

    🔑 API Key

    +

    + Use this key for API authentication with the X-API-Key header. +

    + +
    +
    + + +
    +
    + +
    + ⚠️ Warning: Regenerating will invalidate your current API key. All applications using the old key will stop working. +
    + + +
    + + +
    +

    👤 Account Information

    +
    +
    + Username: +
    {{ username }}
    +
    +
    + Account Type: +
    + {% if is_admin %} + Administrator + {% else %} + Regular User + {% endif %} +
    +
    +
    + Total Files: +
    {{ file_count }} files
    +
    +
    +
    +
    + +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/user_files.html b/templates/user_files.html new file mode 100644 index 0000000..cd193f0 --- /dev/null +++ b/templates/user_files.html @@ -0,0 +1,228 @@ +{% extends "base.html" %} + +{% block title %}My Files - Das File Storage{% endblock %} + +{% block content %} + + +{% if message %} +
    + {{ message }} +
    +{% endif %} + +{% if error %} +
    + {{ error }} +
    +{% endif %} + +
    +

    My Files

    + +
    + +{% if files %} + + + + + + + + + + + {% for file in files %} + + + + + + + {% endfor %} + +
    FilenameSizeUploadedActions
    + {{ file.filename }} +
    {{ file.content_type or 'Unknown' }}
    +
    {{ format_size(file.file_size) }}{{ file.uploaded_at.strftime('%Y-%m-%d %H:%M') }} + + + +
    +{% else %} +
    + + + +

    No files yet

    +

    Upload your first file to get started

    +
    +{% endif %} + + + + + + + +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file