commit b73af5bf117fdb0bc997852e825502e4a51005bd Author: ComputerTech312 Date: Sat Sep 27 17:45:52 2025 +0100 Uploaded code diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b2044a7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Colby + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MAINTENANCE.md b/MAINTENANCE.md new file mode 100644 index 0000000..7624b20 --- /dev/null +++ b/MAINTENANCE.md @@ -0,0 +1,67 @@ +# Maintenance Mode + +Sharey includes a maintenance mode feature that allows you to temporarily disable the service and show a maintenance page to users. + +## How to Enable Maintenance Mode + +```bash +python config_util.py maintenance enable +``` + +This will: +- Ask for a custom maintenance message (optional) +- Ask for an estimated return time (optional) +- Enable maintenance mode immediately + +## How to Disable Maintenance Mode + +```bash +python config_util.py maintenance disable +``` + +## Check Maintenance Status + +```bash +python config_util.py maintenance status +``` + +## Manual Configuration + +You can also manually edit `config.json` to enable/disable maintenance mode: + +```json +{ + "maintenance": { + "enabled": true, + "message": "Sharey is currently under maintenance. Please check back later!", + "estimated_return": "2025-08-22 15:00 UTC" + } +} +``` + +## What Happens During Maintenance + +When maintenance mode is enabled: + +- ✅ **Health check endpoint** (`/health`) still works (for monitoring) +- ❌ **All other routes** show the maintenance page +- ❌ **API endpoints** return maintenance page with 503 status +- ❌ **File uploads** are disabled +- ❌ **File downloads** are disabled +- ❌ **Paste creation** is disabled +- ❌ **Paste viewing** is disabled + +## Maintenance Page Features + +- 🎨 **Themed**: Respects user's light/dark theme preference +- 📱 **Responsive**: Works on mobile and desktop +- 🔄 **Refresh button**: Users can easily check if maintenance is over +- ⏰ **Estimated return time**: Shows when service is expected to return (optional) +- 💬 **Custom message**: Display custom maintenance information + +## Use Cases + +- **Server updates**: When updating the application +- **Database maintenance**: When performing B2 bucket operations +- **Security issues**: When temporarily disabling service for security reasons +- **Planned downtime**: When performing infrastructure maintenance diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d6b733 --- /dev/null +++ b/README.md @@ -0,0 +1,237 @@ +# Sharey 📁 + +A secure, modern file sharing platform with configurable storage backends, encryption, themes, and flexible deployment options. + +## ✨ Features + +- 📁 **File Sharing**: Upload files and get shareable links +- 📝 **Pastebin**: Share text snippets with syntax highlighting +- 💾 **Flexible Storage**: Choose between Backblaze B2 cloud storage or local filesystem +- 🔒 **Client-side Encryption**: Optional encryption for sensitive files +- 🎨 **Dark/Light Themes**: Toggle between themes with preference saving +- 🔗 **Short URLs**: Clean, easy-to-share links +- 🛡️ **Admin Panel**: Secure administration interface +- 🔧 **Maintenance Mode**: Graceful service management + +## ⚡ Quick Storage Setup + +**Choose your storage type in `config.json`:** + +```json +{ + "storage": { + "backend": "local" // Use "local" for filesystem or "b2" for cloud + } +} +``` + +- 🏠 **`"local"`** = Files stored on your server (free, fast) +- ☁️ **`"b2"`** = Files stored in Backblaze B2 cloud (scalable, reliable) + +**Test your choice:** `python test_storage.py` + +## 🏗️ Project Structure + +``` +sharey/ +├── src/ # Application source code +│ ├── app.py # Main Flask application +│ ├── config.py # Configuration management +│ ├── config_util.py # Configuration utilities +│ ├── static/ # CSS, JS, and static assets +│ └── templates/ # HTML templates +├── tests/ # Test files +├── scripts/ # Utility scripts +│ ├── migrate.py # Database migration +│ ├── setup.py # Setup script +│ ├── setup.sh # Shell setup script +│ └── set_admin_password.py +├── docs/ # Documentation +├── logs/ # Application logs +├── config.json # Application configuration +├── requirements.txt # Python dependencies +├── run.py # Application entry point +└── README.md # This file +``` + +## 🚀 Quick Start + +### Method 1: Enhanced Python Runner (Recommended) +```bash +python run.py +``` +This will automatically: +- Check Python version (3.8+ required) +- Create and activate virtual environment if needed +- Install dependencies +- Validate configuration +- Create necessary directories +- Start the application + +## 💾 Storage Configuration + +Sharey supports multiple storage backends for maximum flexibility. Choose the one that best fits your needs: + +### 🔄 **How to Choose Storage Type** + +**Method 1: Edit config.json directly (Easiest)** +Open your `config.json` file and change the `backend` field: + +```json +{ + "storage": { + "backend": "local" // Change to "local" or "b2" + } +} +``` + +**Method 2: Interactive setup** +```bash +python src/config_util.py set # Interactive configuration +python src/config_util.py validate # Validate your choice +python test_storage.py # Test the storage backend +``` + +**Method 3: Environment variables** +```bash +export STORAGE_BACKEND=local # or "b2" +``` + +### Option 1: Backblaze B2 Cloud Storage (Recommended for Production) +```json +{ + "storage": { + "backend": "b2" + }, + "b2": { + "application_key_id": "your_key_id", + "application_key": "your_application_key", + "bucket_name": "your_bucket_name" + } +} +``` +- ✅ Unlimited scalability +- ✅ Built-in redundancy and reliability +- ✅ No local storage requirements +- 💰 Small cost (~$0.005/GB/month) + +### Option 2: Local Filesystem Storage (Good for Development/Self-hosting) +```json +{ + "storage": { + "backend": "local", + "local_path": "storage" + } +} +``` +- ✅ No external dependencies or costs +- ✅ Fast local access +- ✅ Complete data control +- ⚠️ Limited by disk space +- ⚠️ No built-in redundancy + +### 🧪 **Test Your Storage Choice** +After changing the storage backend, always test it: + +```bash +python src/config_util.py validate # Validate configuration +python test_storage.py # Test storage operations +``` + +**Quick Test Results:** +- ✅ `Storage backend initialized: local` = Local storage working +- ✅ `Storage backend initialized: b2` = B2 cloud storage working +- ❌ `Storage test failed` = Check your configuration +- ✅ Fast local access +- ✅ Complete data control +- ⚠️ Limited by disk space +- ⚠️ No built-in redundancy +``` + +**Interactive Configuration:** +```bash +python src/config_util.py set # Interactive setup +python src/config_util.py validate # Validate configuration +python test_storage.py # Test storage backend +``` + +See [docs/STORAGE.md](docs/STORAGE.md) for complete storage configuration guide. + +### Method 2: Python Utility +```bash +# Direct Python usage +python sharey.py start # Full application start +python sharey.py dev # Quick development mode +python sharey.py setup # Set up environment +python sharey.py status # Check system status +python sharey.py clean # Clean temporary files +python sharey.py test # Run tests +python sharey.py logs # Show recent logs +python sharey.py service # Install as system service (Linux) + +# Or use the wrapper script (shorter) +./sharey status # Unix/Linux/macOS +``` + +### Method 3: Manual Setup +```bash +# Create virtual environment +python3 -m venv .venv +source .venv/bin/activate # Linux/macOS +# .venv\Scripts\activate # Windows + +# Install dependencies +pip install -r requirements.txt + +# Configure storage backend +cp config.json.example config.json +# Edit config.json with your settings + +# Run +python src/app.py +``` + +## 📋 Features + +- 🔐 **Security**: Optional file encryption with password protection +- 🎨 **Themes**: Light/dark mode with elegant UI +- ☁️ **Cloud Storage**: Backblaze B2 integration +- 📊 **Progress Tracking**: Real-time upload progress with percentage +- 📱 **Mobile Friendly**: Responsive design for all devices +- 🔗 **Easy Sharing**: Direct links, QR codes, and clipboard integration +- 📝 **Pastebin**: Share text content with syntax highlighting + +## 🛠️ Development + +### Running Tests +```bash +cd tests +python -m pytest +``` + +### Development Mode +```bash +# Set debug mode in config.json +python run.py +``` + +## 📚 Documentation + +See the `docs/` directory for: +- Configuration guide +- Deployment instructions +- API documentation +- Migration notes + +## 🔧 Configuration + +Key configuration options in `config.json`: +- `host`: Server host (default: 0.0.0.0) +- `port`: Server port (default: 5000) +- `debug`: Debug mode (default: false) +- `max_file_size`: Maximum upload size +- `b2_*`: Backblaze B2 credentials + +## 📄 License + +See LICENSE file for details. diff --git a/STORAGE_QUICKSTART.md b/STORAGE_QUICKSTART.md new file mode 100644 index 0000000..1876d4a --- /dev/null +++ b/STORAGE_QUICKSTART.md @@ -0,0 +1,73 @@ +# 🚀 Storage Quick Start + +**Just want to get started quickly? Here's how to choose your storage type:** + +## 📁 Local Storage (Easiest) + +**Best for:** Development, testing, small self-hosted setups + +1. **Edit config.json:** +```json +{ + "storage": { + "backend": "local" + } +} +``` + +2. **Test it:** +```bash +python test_storage.py +``` + +3. **Done!** Files will be stored in the `storage/` folder. + +## ☁️ Cloud Storage (B2) + +**Best for:** Production, scaling, reliability + +1. **Get B2 credentials:** + - Sign up at [backblaze.com](https://www.backblaze.com/b2/) + - Create a bucket + - Create application key + +2. **Edit config.json:** +```json +{ + "storage": { + "backend": "b2" + }, + "b2": { + "application_key_id": "your_key_id", + "application_key": "your_application_key", + "bucket_name": "your_bucket_name" + } +} +``` + +3. **Test it:** +```bash +python test_storage.py +``` + +4. **Done!** Files will be stored in your B2 bucket. + +## 🔄 Switch Anytime + +Want to switch? Just change the `"backend"` field: + +```json +{ + "storage": { + "backend": "local" // or "b2" + } +} +``` + +Then run `python test_storage.py` to verify. + +## 🆘 Need Help? + +- **Validation errors?** Run `python src/config_util.py validate` +- **Want interactive setup?** Run `python src/config_util.py set` +- **Full documentation:** See [docs/STORAGE.md](docs/STORAGE.md) diff --git a/__pycache__/expiry_db.cpython-312.pyc b/__pycache__/expiry_db.cpython-312.pyc new file mode 100644 index 0000000..660d414 Binary files /dev/null and b/__pycache__/expiry_db.cpython-312.pyc differ diff --git a/__pycache__/gunicorn.conf.cpython-312.pyc b/__pycache__/gunicorn.conf.cpython-312.pyc new file mode 100644 index 0000000..8e4ba8b Binary files /dev/null and b/__pycache__/gunicorn.conf.cpython-312.pyc differ diff --git a/__pycache__/wsgi.cpython-312.pyc b/__pycache__/wsgi.cpython-312.pyc new file mode 100644 index 0000000..ecde6b6 Binary files /dev/null and b/__pycache__/wsgi.cpython-312.pyc differ diff --git a/cleanup_daemon.py b/cleanup_daemon.py new file mode 100755 index 0000000..1fa3c18 --- /dev/null +++ b/cleanup_daemon.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Cleanup daemon for expired files and pastes in Sharey +Runs continuously and checks for expired files at regular intervals +Supports both local storage and Backblaze B2 storage backends +""" +import os +import sys +import json +import sqlite3 +import time +import signal +from datetime import datetime, timezone +from pathlib import Path + +# Add the project root to Python path +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +from src.config import config +from src.storage import StorageManager +from expiry_db import ExpiryDatabase + +# Configuration +CLEANUP_INTERVAL = 60 # seconds (1 minute) +CHECK_INTERVAL = 5 # seconds between startup checks + +# Global flag for graceful shutdown +running = True + +def signal_handler(signum, frame): + """Handle shutdown signals gracefully""" + global running + print(f"\n🛑 Received signal {signum}, shutting down gracefully...") + running = False + +def cleanup_expired_files(): + """Perform cleanup of expired files (same logic as cleanup script)""" + try: + # Initialize storage manager + storage_manager = StorageManager(config) + backend_info = storage_manager.get_backend_info() + + # Initialize expiry database + expiry_db = ExpiryDatabase() + + # Get expired files from database + expired_files = expiry_db.get_expired_files() + + if not expired_files: + print(f"⏰ {datetime.now().strftime('%H:%M:%S')} - No expired files found") + return 0 + + print(f"🗑️ {datetime.now().strftime('%H:%M:%S')} - Found {len(expired_files)} expired files to delete") + + deleted_count = 0 + for file_info in expired_files: + file_path = file_info['file_path'] + expires_at = file_info['expires_at'] + + try: + print(f"🗑️ Deleting: {file_path} (expired: {expires_at})") + + # Delete from storage backend + if storage_manager.delete_file(file_path): + # Remove from expiry database + expiry_db.remove_file(file_path) + deleted_count += 1 + print(f"✅ Deleted: {file_path}") + else: + print(f"❌ Failed to delete: {file_path}") + + except Exception as e: + print(f"❌ Error deleting {file_path}: {e}") + + if deleted_count > 0: + print(f"✅ Cleanup completed! Deleted {deleted_count} expired files") + + return deleted_count + + except Exception as e: + print(f"❌ Cleanup error: {e}") + return -1 + +def main(): + """Main daemon function""" + # Set up signal handlers for graceful shutdown + signal.signal(signal.SIGINT, signal_handler) # Ctrl+C + signal.signal(signal.SIGTERM, signal_handler) # systemctl stop + + print("🧹 Sharey Cleanup Daemon") + print("=" * 40) + print(f"🚀 Started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"⏰ Cleanup interval: {CLEANUP_INTERVAL} seconds") + print(f"📁 Storage backend: {config.get('storage', {}).get('backend', 'b2')}") + print(f"🗄️ Database: expiry.db") + print("=" * 40) + print("Press Ctrl+C to stop") + print() + + last_cleanup = 0 + + try: + while running: + current_time = time.time() + + # Check if it's time for cleanup + if current_time - last_cleanup >= CLEANUP_INTERVAL: + try: + cleanup_expired_files() + last_cleanup = current_time + except Exception as e: + print(f"❌ Cleanup cycle failed: {e}") + + # Sleep for a short interval to avoid busy waiting + time.sleep(CHECK_INTERVAL) + + except KeyboardInterrupt: + pass # Handle Ctrl+C gracefully + + print(f"\n🛑 Cleanup daemon stopped at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print("👋 Goodbye!") + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"❌ Daemon failed to start: {e}") + sys.exit(1) + + sys.exit(0) diff --git a/cleanup_expired.py b/cleanup_expired.py new file mode 100755 index 0000000..0e8b5c7 --- /dev/null +++ b/cleanup_expired.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +Cleanup script for expired files and pastes in Sharey +Supports both local storage and Backblaze B2 storage backends +""" +import os +import sys +import json +from datetime import datetime, timezone +from pathlib import Path + +# Add src directory to Python path +src_path = Path(__file__).parent / "src" +sys.path.insert(0, str(src_path)) + +from config import config +from storage import StorageManager + + +def cleanup_local_storage(storage_manager): + """Clean up expired files from local storage""" + print("🧹 Cleaning up local storage...") + + backend = storage_manager.backend + base_path = backend.base_path + deleted_count = 0 + + # Check files directory + files_dir = base_path / "files" + if files_dir.exists(): + for file_path in files_dir.glob("*"): + if file_path.is_file() and not file_path.name.endswith('.meta'): + meta_file = file_path.with_suffix(file_path.suffix + '.meta') + + if meta_file.exists(): + try: + with open(meta_file, 'r') as f: + metadata = json.load(f) + + expires_at = metadata.get('expires_at') + if expires_at: + expiry_time = datetime.fromisoformat(expires_at.replace('Z', '+00:00')) + if datetime.now() > expiry_time: + # Delete both file and metadata + file_path.unlink() + meta_file.unlink() + print(f"🗑️ Deleted expired file: {file_path.name}") + deleted_count += 1 + except Exception as e: + print(f"❌ Error processing {file_path.name}: {e}") + + # Check pastes directory + pastes_dir = base_path / "pastes" + if pastes_dir.exists(): + for file_path in pastes_dir.glob("*.txt"): + if file_path.is_file(): + meta_file = file_path.with_suffix('.txt.meta') + + if meta_file.exists(): + try: + with open(meta_file, 'r') as f: + metadata = json.load(f) + + expires_at = metadata.get('expires_at') + if expires_at: + expiry_time = datetime.fromisoformat(expires_at.replace('Z', '+00:00')) + if datetime.now() > expiry_time: + # Delete both file and metadata + file_path.unlink() + meta_file.unlink() + print(f"🗑️ Deleted expired paste: {file_path.name}") + deleted_count += 1 + except Exception as e: + print(f"❌ Error processing {file_path.name}: {e}") + + return deleted_count + + +def cleanup_b2_storage(storage_manager): + """Clean up expired files from Backblaze B2 storage""" + print("🧹 Cleaning up B2 storage...") + + deleted_count = 0 + + try: + # List all files using the storage manager + all_files = storage_manager.list_files("") + + for file_path in all_files: + try: + # Get metadata for this file + metadata = storage_manager.get_metadata(file_path) + if not metadata: + continue + + expires_at = metadata.get('expires_at') + if expires_at: + expiry_time = datetime.fromisoformat(expires_at.replace('Z', '+00:00')) + current_time = datetime.now(timezone.utc).replace(tzinfo=None) + + if current_time > expiry_time: + print(f"🗑️ Deleting expired B2 file: {file_path}") + if storage_manager.delete_file(file_path): + deleted_count += 1 + else: + print(f"❌ Failed to delete B2 file: {file_path}") + + except Exception as e: + print(f"❌ Error processing B2 file {file_path}: {e}") + + except Exception as e: + print(f"❌ Error listing B2 files: {e}") + return 0 + + return deleted_count + + +def main(): + """Main cleanup function""" + print("🧹 Sharey Cleanup Script") + print("=" * 40) + print(f"⏰ Started at: {datetime.now().isoformat()}") + + try: + # Initialize storage manager + storage_manager = StorageManager(config) + backend_info = storage_manager.get_backend_info() + + print(f"📁 Storage backend: {backend_info['type']}") + + # Run appropriate cleanup based on storage type + if backend_info['type'] == 'local': + deleted_count = cleanup_local_storage(storage_manager) + elif backend_info['type'] == 'b2': + deleted_count = cleanup_b2_storage(storage_manager) + else: + print(f"❌ Unknown storage backend: {backend_info['type']}") + sys.exit(1) + + print("=" * 40) + if deleted_count > 0: + print(f"✅ Cleanup completed! Deleted {deleted_count} expired items.") + else: + print("✅ Cleanup completed! No expired items found.") + + print(f"⏰ Finished at: {datetime.now().isoformat()}") + + except Exception as e: + print(f"❌ Cleanup failed: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/cleanup_expired_db.py b/cleanup_expired_db.py new file mode 100755 index 0000000..74574e7 --- /dev/null +++ b/cleanup_expired_db.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +Cleanup script for expired files using expiry database +Works with both local storage and Backblaze B2 storage backends +""" +import os +import sys +from datetime import datetime +from pathlib import Path + +# Add the project root to Python path +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +from src.config import config +from src.storage import StorageManager +from expiry_db import ExpiryDatabase + +def main(): + """Main cleanup function""" + print("🧹 Sharey Cleanup Script (Database-driven)") + print("=" * 50) + print(f"⏰ Started at: {datetime.utcnow().isoformat()}") + + try: + # Load configuration + print("✅ Loaded configuration from config.json") + + # Initialize storage manager + storage_manager = StorageManager(config) + backend_info = storage_manager.get_backend_info() + print(f"📁 Storage backend: {backend_info['type']}") + + # Initialize expiry database + expiry_db = ExpiryDatabase() + + # Get expired files from database + expired_files = expiry_db.get_expired_files() + + if not expired_files: + print("✅ No expired files found.") + return 0 + + print(f"🗑️ Found {len(expired_files)} expired files to delete:") + + deleted_count = 0 + for file_info in expired_files: + file_path = file_info['file_path'] + expires_at = file_info['expires_at'] + storage_backend = file_info['storage_backend'] + + try: + print(f"🗑️ Deleting expired file: {file_path} (expired: {expires_at})") + + # Delete from storage backend + if storage_manager.delete_file(file_path): + # Remove from expiry database + expiry_db.remove_file(file_path) + deleted_count += 1 + print(f"✅ Deleted: {file_path}") + else: + print(f"❌ Failed to delete from storage: {file_path}") + + except Exception as e: + print(f"❌ Error deleting {file_path}: {e}") + + print("=" * 50) + print(f"✅ Cleanup completed! Deleted {deleted_count} expired files.") + + # Show database stats + stats = expiry_db.get_stats() + print(f"📊 Database stats:") + print(f" • Total tracked files: {stats.get('total_files', 0)}") + print(f" • Active files: {stats.get('active_files', 0)}") + print(f" • Expired files: {stats.get('expired_files', 0)}") + + return deleted_count + + except Exception as e: + print(f"❌ Cleanup failed: {e}") + return -1 + + finally: + print(f"⏰ Finished at: {datetime.utcnow().isoformat()}") + +if __name__ == "__main__": + result = main() + sys.exit(0 if result >= 0 else 1) diff --git a/config.json b/config.json new file mode 100644 index 0000000..73236d6 --- /dev/null +++ b/config.json @@ -0,0 +1,36 @@ +{ + "storage": { + "backend": "b2", + "local_path": "storage" + }, + "b2": { + "application_key_id": "0023cd3169723c50000000001", + "application_key": "K002rXzeiUfyfpEabzU3ikvnJy5FU1M", + "bucket_name": "sharey" + }, + "flask": { + "host": "0.0.0.0", + "port": 8866, + "debug": true + }, + "upload": { + "max_file_size_mb": 100 + }, + "paste": { + "max_length": 1000000 + }, + "security": { + "rate_limit_enabled": false, + "max_uploads_per_hour": 50 + }, + "maintenance": { + "enabled": false, + "message": "Sharey is currently under maintenance. Please check back later!", + "estimated_return": "" + }, + "admin": { + "enabled": true, + "password_hash": "240be518fabd2724ddb6f04eeb1da5967448d7e831c08c8fa822809f74c720a9", + "session_timeout_minutes": 60 + } +} \ No newline at end of file diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..ff3aa60 --- /dev/null +++ b/config.json.example @@ -0,0 +1,44 @@ +{ + "storage": { + "backend": "b2", + "local_path": "storage" + }, + "b2": { + "application_key_id": "your_key_id_here", + "application_key": "your_application_key_here", + "bucket_name": "your_bucket_name_here" + }, + "flask": { + "host": "127.0.0.1", + "port": 8866, + "debug": true + }, + "upload": { + "max_file_size_mb": 100, + "allowed_extensions": [".jpg", ".jpeg", ".png", ".gif", ".pdf", ".txt", ".doc", ".docx", ".zip", ".mp4", ".mp3"], + "default_expiry": "7d" + }, + "paste": { + "max_length": 1000000, + "default_expiry": "24h" + }, + "expiry": { + "enabled": true, + "cleanup_interval_hours": 1, + "available_options": ["1h", "24h", "7d", "30d", "90d", "never"] + }, + "security": { + "rate_limit_enabled": false, + "max_uploads_per_hour": 50 + }, + "maintenance": { + "enabled": false, + "message": "Sharey is currently under maintenance. Please check back later!", + "estimated_return": "" + }, + "admin": { + "enabled": true, + "password_hash": "", + "session_timeout_minutes": 60 + } +} diff --git a/cron_example.txt b/cron_example.txt new file mode 100644 index 0000000..e683f30 --- /dev/null +++ b/cron_example.txt @@ -0,0 +1,15 @@ +# Cron job example for automatic file cleanup +# This will run the cleanup script every hour to remove expired files +# Edit your crontab with: crontab -e +# Add this line to run cleanup every hour: + +0 * * * * cd /home/colby/sharey && python3 cleanup_expired.py >> logs/cleanup.log 2>&1 + +# Alternative schedules: +# Every 15 minutes: */15 * * * * cd /home/colby/sharey && python3 cleanup_expired.py >> logs/cleanup.log 2>&1 +# Every 6 hours: 0 */6 * * * cd /home/colby/sharey && python3 cleanup_expired.py >> logs/cleanup.log 2>&1 +# Once daily at 2 AM: 0 2 * * * cd /home/colby/sharey && python3 cleanup_expired.py >> logs/cleanup.log 2>&1 + +# For production, adjust the path to your actual installation directory +# Example for production: +# 0 * * * * cd /var/www/sharey && /usr/bin/python3 cleanup_expired.py >> logs/cleanup.log 2>&1 diff --git a/docs/CONFIG_SYSTEM.md b/docs/CONFIG_SYSTEM.md new file mode 100644 index 0000000..42ea0f5 --- /dev/null +++ b/docs/CONFIG_SYSTEM.md @@ -0,0 +1,147 @@ +# Sharey Configuration System + +## Overview + +Sharey now features a comprehensive JSON-based configuration system that replaces environment variables with a more structured and feature-rich approach. + +## Configuration Files + +### Primary Configuration: `config.json` +```json +{ + "b2": { + "application_key_id": "your_key_id_here", + "application_key": "your_application_key_here", + "bucket_name": "your_bucket_name_here" + }, + "flask": { + "host": "127.0.0.1", + "port": 8866, + "debug": true + }, + "upload": { + "max_file_size_mb": 100, + "allowed_extensions": [".jpg", ".jpeg", ".png", ".gif", ".pdf", ".txt", ".doc", ".docx", ".zip", ".mp4", ".mp3"] + }, + "paste": { + "max_length": 1000000 + }, + "security": { + "rate_limit_enabled": false, + "max_uploads_per_hour": 50 + } +} +``` + +### Fallback: Environment Variables +For backwards compatibility, Sharey still supports `.env` files and environment variables. + +## Configuration Management Tools + +### Setup Script: `setup.py` +- Creates `config.json` from template +- Sets up virtual environment +- Installs dependencies +- Validates configuration + +### Configuration Utility: `config_util.py` +```bash +python config_util.py show # Display current config +python config_util.py validate # Validate configuration +python config_util.py set # Interactive setup +python config_util.py reset # Reset to defaults +``` + +### Test Utility: `test_b2.py` +- Tests B2 connection +- Validates credentials +- Shows configuration summary + +## New Features + +### File Upload Controls +- **File size limits**: Configurable max file size in MB +- **File type restrictions**: Whitelist of allowed extensions +- **Validation**: Automatic file type checking + +### Paste Controls +- **Length limits**: Configurable maximum paste length +- **Validation**: Automatic length checking + +### Flask Configuration +- **Host/Port**: Configurable server settings +- **Debug mode**: Environment-specific settings + +### Security Features (Future) +- **Rate limiting**: Configurable upload limits +- **Extensible**: Ready for additional security features + +## Configuration Loading Priority + +1. **config.json** (if exists) +2. **Environment variables** (fallback) +3. **Built-in defaults** (last resort) + +## Benefits of JSON Configuration + +### ✅ **Structured Data** +- Hierarchical organization +- Type safety (strings, numbers, booleans, arrays) +- Better validation + +### ✅ **Feature Rich** +- Upload restrictions +- File type filtering +- Paste length limits +- Server configuration + +### ✅ **User Friendly** +- Interactive setup utility +- Configuration validation +- Clear error messages +- Comprehensive documentation + +### ✅ **Developer Friendly** +- Easy to extend +- Version controllable (with secrets excluded) +- IDE support with syntax highlighting +- Better tooling support + +### ✅ **Backwards Compatible** +- Still supports .env files +- Smooth migration path +- No breaking changes + +## Migration from .env + +Old `.env` approach: +```env +B2_APPLICATION_KEY_ID=key123 +B2_APPLICATION_KEY=secret456 +B2_BUCKET_NAME=mybucket +``` + +New `config.json` approach: +```json +{ + "b2": { + "application_key_id": "key123", + "application_key": "secret456", + "bucket_name": "mybucket" + } +} +``` + +## Quick Start + +1. **Run setup**: `python setup.py` +2. **Configure**: `python config_util.py set` +3. **Test**: `python test_b2.py` +4. **Run**: `python app.py` + +## Security Considerations + +- `config.json` is excluded from git by default +- Sensitive data can be hidden in display utilities +- Environment variable fallback for production deployments +- Configuration validation prevents common mistakes diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..70dc07c --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,79 @@ +# Deployment Configuration + +## Environment Variables for Production + +When deploying Sharey to production, make sure to set these environment variables: + +```bash +# Required B2 Configuration +export B2_APPLICATION_KEY_ID="your_key_id" +export B2_APPLICATION_KEY="your_key" +export B2_BUCKET_NAME="your_bucket_name" + +# Flask Configuration +export FLASK_ENV="production" +export FLASK_DEBUG="False" +``` + +## Docker Deployment + +### Dockerfile +```dockerfile +FROM python:3.9-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8866 + +CMD ["python", "app.py"] +``` + +### Docker Compose +```yaml +version: '3.8' +services: + sharey: + build: . + ports: + - "8866:8866" + environment: + - B2_APPLICATION_KEY_ID=${B2_APPLICATION_KEY_ID} + - B2_APPLICATION_KEY=${B2_APPLICATION_KEY} + - B2_BUCKET_NAME=${B2_BUCKET_NAME} + volumes: + - .env:/app/.env +``` + +## Nginx Reverse Proxy + +```nginx +server { + listen 80; + server_name your-domain.com; + + location / { + proxy_pass http://127.0.0.1:8866; + 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; + + # Increase client max body size for file uploads + client_max_body_size 100M; + } +} +``` + +## Production Considerations + +1. **File Size Limits**: Configure your web server to handle large file uploads +2. **HTTPS**: Use SSL/TLS certificates for secure file transfers +3. **Rate Limiting**: Implement rate limiting to prevent abuse +4. **Monitoring**: Set up logging and monitoring for the application +5. **Backup**: B2 provides versioning, but consider backup strategies +6. **Security**: Restrict B2 bucket access and use environment variables for secrets diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md new file mode 100644 index 0000000..7624b20 --- /dev/null +++ b/docs/MAINTENANCE.md @@ -0,0 +1,67 @@ +# Maintenance Mode + +Sharey includes a maintenance mode feature that allows you to temporarily disable the service and show a maintenance page to users. + +## How to Enable Maintenance Mode + +```bash +python config_util.py maintenance enable +``` + +This will: +- Ask for a custom maintenance message (optional) +- Ask for an estimated return time (optional) +- Enable maintenance mode immediately + +## How to Disable Maintenance Mode + +```bash +python config_util.py maintenance disable +``` + +## Check Maintenance Status + +```bash +python config_util.py maintenance status +``` + +## Manual Configuration + +You can also manually edit `config.json` to enable/disable maintenance mode: + +```json +{ + "maintenance": { + "enabled": true, + "message": "Sharey is currently under maintenance. Please check back later!", + "estimated_return": "2025-08-22 15:00 UTC" + } +} +``` + +## What Happens During Maintenance + +When maintenance mode is enabled: + +- ✅ **Health check endpoint** (`/health`) still works (for monitoring) +- ❌ **All other routes** show the maintenance page +- ❌ **API endpoints** return maintenance page with 503 status +- ❌ **File uploads** are disabled +- ❌ **File downloads** are disabled +- ❌ **Paste creation** is disabled +- ❌ **Paste viewing** is disabled + +## Maintenance Page Features + +- 🎨 **Themed**: Respects user's light/dark theme preference +- 📱 **Responsive**: Works on mobile and desktop +- 🔄 **Refresh button**: Users can easily check if maintenance is over +- ⏰ **Estimated return time**: Shows when service is expected to return (optional) +- 💬 **Custom message**: Display custom maintenance information + +## Use Cases + +- **Server updates**: When updating the application +- **Database maintenance**: When performing B2 bucket operations +- **Security issues**: When temporarily disabling service for security reasons +- **Planned downtime**: When performing infrastructure maintenance diff --git a/docs/MIGRATION_SUMMARY.md b/docs/MIGRATION_SUMMARY.md new file mode 100644 index 0000000..81a50ab --- /dev/null +++ b/docs/MIGRATION_SUMMARY.md @@ -0,0 +1,118 @@ +# Sharey B2 Migration Summary + +## What Changed + +Your Sharey application has been successfully modified to use Backblaze B2 cloud storage instead of local file storage. Here are the key changes: + +### 🔄 Core Changes + +1. **Storage Backend**: + - Removed local file storage (`uploads/` and `pastes/` folders) + - Added Backblaze B2 cloud storage integration + - Files and pastes now stored in B2 bucket + +2. **Dependencies Added**: + - `b2sdk`: Official Backblaze B2 SDK + - `python-dotenv`: Environment variable management + +3. **Configuration**: + - Added `.env` file support for B2 credentials + - Added connection validation and error handling + - Added health check endpoint + +### 📁 New Files Created + +- `requirements.txt` - Python dependencies +- `.env.example` - Environment template +- `setup.py` - Automated setup script (Python-based) +- `test_b2.py` - B2 connection testing utility +- `DEPLOYMENT.md` - Production deployment guide +- Updated `.gitignore` - Improved git ignore rules +- Updated `README.md` - Comprehensive setup instructions + +### 🔧 Modified Functions + +1. **File Upload** (`/api/upload`): + - Now uploads files directly to B2 bucket under `files/` prefix + - Returns B2 direct download URLs + - Better error handling + +2. **File Serving** (`/files/`): + - Now redirects to B2 download URLs + - No longer serves files locally + +3. **Paste Creation** (`/api/paste`): + - Stores paste content in B2 under `pastes/` prefix + - UTF-8 encoding support + +4. **Paste Viewing** (`/pastes/` and `/pastes/raw/`): + - Downloads paste content from B2 + - Maintains same user interface + +### 🏗️ Bucket Organization + +Your B2 bucket will be organized as: +``` +your-bucket/ +├── files/ # Uploaded files (images, documents, etc.) +│ ├── abc123.jpg +│ ├── def456.pdf +│ └── ... +└── pastes/ # Text pastes + ├── ghi789.txt + ├── jkl012.txt + └── ... +``` + +## Next Steps + +1. **Get B2 Credentials**: + - Sign up at [Backblaze B2](https://www.backblaze.com/b2) + - Create application key and bucket + - Note down: Key ID, Application Key, Bucket Name + +2. **Configure Environment**: + ```bash + cp .env.example .env + # Edit .env with your B2 credentials + ``` + +3. **Install Dependencies**: + ```bash + python setup.py # or manually: pip install -r requirements.txt + ``` + +4. **Test Configuration**: + ```bash + python test_b2.py + ``` + +5. **Run Application**: + ```bash + python app.py + ``` + +## Benefits of B2 Storage + +- ✅ **Scalable**: No local disk space limitations +- ✅ **Reliable**: Built-in redundancy and backups +- ✅ **Cost-effective**: Pay only for what you use +- ✅ **Global CDN**: Fast downloads worldwide +- ✅ **Secure**: Encrypted storage with access controls +- ✅ **Maintenance-free**: No local file management needed + +## Migration Notes + +- Existing local files/pastes will remain in local folders +- New uploads will go to B2 +- Old URLs will break (files are now served from B2) +- No data migration script provided (manual migration needed if desired) +- B2 bucket should be set to "Public" for file sharing to work properly + +## Support + +If you encounter issues: +1. Run `python test_b2.py` to test your configuration +2. Check the health endpoint: `http://localhost:8866/health` +3. Verify B2 bucket permissions and settings +4. Ensure your .env file has correct credentials diff --git a/docs/ORGANIZATION.md b/docs/ORGANIZATION.md new file mode 100644 index 0000000..8b3cb17 --- /dev/null +++ b/docs/ORGANIZATION.md @@ -0,0 +1,99 @@ +# Project Organization Summary + +## 📁 Reorganized Structure + +The Sharey project has been reorganized into a clean, professional structure: + +### 🏗️ Directory Structure +``` +sharey/ +├── src/ # Main application code +│ ├── app.py # Flask application +│ ├── config.py # Configuration management +│ ├── config_util.py # Config utilities +│ ├── static/ # CSS, JS, assets +│ │ ├── script.js # Main JavaScript +│ │ ├── style.css # Stylesheets +│ │ └── script_backup.js +│ └── templates/ # HTML templates +│ ├── index.html # Main page +│ ├── admin.html # Admin panel +│ ├── admin_login.html +│ ├── maintenance.html +│ ├── view_file.html # File viewer +│ └── view_paste.html # Paste viewer +├── tests/ # Test files +│ ├── test_b2.py +│ ├── test_bucket_contents.py +│ └── test_paste.py +├── scripts/ # Utility scripts +│ ├── clean.sh # Cleanup script +│ ├── migrate.py # Database migration +│ ├── set_admin_password.py +│ ├── setup.py +│ └── setup.sh +├── docs/ # Documentation +│ ├── CONFIG_SYSTEM.md +│ ├── DEPLOYMENT.md +│ ├── MAINTENANCE.md +│ ├── MIGRATION_SUMMARY.md +│ └── README.md (old) +├── logs/ # Application logs +│ ├── app.log +│ └── migration_log_20250815_121855.txt +├── config.json # Main configuration +├── config.json.example # Configuration template +├── requirements.txt # Python dependencies +├── run.py # Application entry point +├── dev-setup.sh # Development setup +└── README.md # Project documentation +``` + +## 🚀 Running the Application + +### New Entry Point +```bash +python run.py +``` + +### Development Setup +```bash +./dev-setup.sh +``` + +### Cleanup +```bash +./scripts/clean.sh +``` + +## ✅ Changes Made + +1. **Moved core application** (`app.py`, `config.py`) to `src/` +2. **Organized static assets** (`static/`, `templates/`) under `src/` +3. **Collected tests** in dedicated `tests/` directory +4. **Grouped scripts** in `scripts/` directory +5. **Centralized documentation** in `docs/` directory +6. **Created logs directory** for log files +7. **Added entry point** (`run.py`) for easy execution +8. **Created development setup** script for easy onboarding +9. **Added cleanup script** for maintenance +10. **Removed temporary files** and debug artifacts +11. **Updated README.md** with new structure + +## 🎯 Benefits + +- **Cleaner root directory** - Only essential files at project root +- **Logical grouping** - Related files organized together +- **Professional structure** - Follows Python project best practices +- **Easy navigation** - Clear separation of concerns +- **Better maintainability** - Easier to find and modify files +- **Development friendly** - Scripts for common tasks + +## 🔧 Next Steps + +1. Update any deployment scripts to use new structure +2. Test the new entry point thoroughly +3. Update CI/CD pipelines if applicable +4. Consider adding more development tools (linting, formatting) + +The project is now much cleaner and follows modern Python project conventions! diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..65a8cb2 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,196 @@ +# Sharey + +A simple file sharing and pastebin service using Backblaze B2 cloud storage. + +## Features + +- 📁 **File Sharing**: Upload files and get shareable links +- 📝 **Pastebin**: Share text snippets with syntax highlighting support +- ☁️ **Cloud Storage**: Files stored securely in Backblaze B2 +- 🔗 **Short URLs**: Clean, easy-to-share links +- 🎨 **Clean UI**: Simple, responsive web interface + +## Setup + +### Prerequisites + +- Python 3.7+ +- Backblaze B2 account with: + - Application Key ID + - Application Key + - Bucket name + +### Installation + +1. **Clone the repository** + ```bash + git clone + cd sharey + ``` + +2. **Run the setup script** + ```bash + python setup.py + ``` + + This will automatically create a virtual environment and install dependencies. + +3. **Configure B2 credentials** + + Sharey supports two configuration methods: + + **Option 1: JSON Configuration (Recommended)** + ```bash + # Edit config.json with your credentials + nano config.json + ``` + + **Option 2: Environment Variables** + ```bash + # Edit .env file with your credentials + nano .env + ``` + + **Interactive Configuration** + ```bash + python config_util.py set + ``` + +4. **Test B2 connection** + ```bash + python test_b2.py + ``` + +5. **Run the application** + ```bash + # If using virtual environment (recommended) + source venv/bin/activate + python app.py + + # Or directly + venv/bin/python app.py + ``` + +The application will be available at `http://127.0.0.1:8866` + +## Backblaze B2 Setup + +1. **Create a Backblaze B2 account** at [backblaze.com](https://www.backblaze.com/b2/cloud-storage.html) + +2. **Create an application key**: + - Go to [App Keys](https://secure.backblaze.com/app_keys.htm) + - Click "Add a New Application Key" + - Name it "Sharey" or similar + - Choose appropriate permissions (read/write access to your bucket) + - Save the Key ID and Application Key + +3. **Create a bucket**: + - Go to [Buckets](https://secure.backblaze.com/b2_buckets.htm) + - Click "Create a Bucket" + - Choose "Public" if you want files to be publicly accessible + - Note the bucket name + +4. **Configure bucket for public access** (if desired): + - Set bucket type to "Public" + - Configure CORS settings if needed for web access + +## File Organization in B2 + +Files are organized in your B2 bucket as follows: +``` +your-bucket/ +├── files/ +│ ├── abc123.jpg +│ ├── def456.pdf +│ └── ... +└── pastes/ + ├── ghi789.txt + ├── jkl012.txt + └── ... +``` + +## Development + +### Manual Installation + +If you prefer not to use the setup script: + +```bash +# Install dependencies +pip install -r requirements.txt + +# Copy environment template +cp .env.example .env + +# Edit .env with your credentials +nano .env + +# Test configuration +python test_b2.py + +# Run the app +python app.py +``` + +## Configuration + +Sharey uses a flexible configuration system that supports both JSON files and environment variables. + +### Configuration Files + +- `config.json` - Main configuration file (recommended) +- `.env` - Environment variables (fallback/legacy support) + +### Configuration Management + +**View current configuration:** +```bash +python config_util.py show +``` + +**Interactive setup:** +```bash +python config_util.py set +``` + +**Validate configuration:** +```bash +python config_util.py validate +``` + +**Reset to defaults:** +```bash +python config_util.py reset +``` + +### Configuration Options + +```json +{ + "b2": { + "application_key_id": "your_key_id_here", + "application_key": "your_application_key_here", + "bucket_name": "your_bucket_name_here" + }, + "flask": { + "host": "127.0.0.1", + "port": 8866, + "debug": true + }, + "upload": { + "max_file_size_mb": 100, + "allowed_extensions": [".jpg", ".jpeg", ".png", ".gif", ".pdf", ".txt", ".doc", ".docx", ".zip", ".mp4", ".mp3"] + }, + "paste": { + "max_length": 1000000 + }, + "security": { + "rate_limit_enabled": false, + "max_uploads_per_hour": 50 + } +} +``` + +## License + +MIT License - see [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/docs/STORAGE.md b/docs/STORAGE.md new file mode 100644 index 0000000..efe72ae --- /dev/null +++ b/docs/STORAGE.md @@ -0,0 +1,375 @@ +# Storage Configuration Guide + +Sharey supports multiple storage backends for maximum flexibility. You can choose between cloud storage (Backblaze B2) or local filesystem storage. + +## 🔄 How to Choose Storage Type + +### **Quick Method: Edit config.json** +1. Open your `config.json` file +2. Find the `"storage"` section +3. Change `"backend"` to either `"local"` or `"b2"` + +```json +{ + "storage": { + "backend": "local" // Change this to "local" or "b2" + } +} +``` + +### **Interactive Method** +```bash +python src/config_util.py set # Interactive setup +python src/config_util.py validate # Verify configuration +python test_storage.py # Test the backend +``` + +### **Environment Variable Method** +```bash +export STORAGE_BACKEND=local # or "b2" +``` + +## 📊 Storage Backend Comparison + +| Feature | **Local Storage** | **B2 Cloud Storage** | +|---------|------------------|-------------------| +| **Setup Difficulty** | ✅ Easy | ⚠️ Requires B2 account | +| **Cost** | ✅ Free | 💰 ~$0.005/GB/month | +| **Speed** | ✅ Very fast | ⚠️ Network dependent | +| **Reliability** | ⚠️ Single machine | ✅ Cloud redundancy | +| **Scalability** | ⚠️ Disk space limited | ✅ Unlimited | +| **Best for** | Development, self-hosting | Production, scaling | + +## Storage Backends + +### 1. Backblaze B2 (Cloud Storage) +- ✅ **Best for production deployments** +- ✅ **Scalable and reliable** +- ✅ **No local disk space requirements** +- ❌ **Requires B2 account and credentials** + +### 2. Local Filesystem +- ✅ **No external dependencies** +- ✅ **Fast access** +- ✅ **No cloud costs** +- ❌ **Limited by local disk space** +- ❌ **Not suitable for distributed deployments** + +## Configuration Details + +## Configuration Details + +### Option 1: config.json (Recommended) + +```json +{ + "storage": { + "backend": "b2", // "b2" or "local" + "local_path": "storage" // Path for local storage (only used if backend is "local") + }, + "b2": { + "application_key_id": "your_key_id_here", + "application_key": "your_application_key_here", + "bucket_name": "your_bucket_name_here" + } +} +``` + +### Option 2: Environment Variables + +```bash +# Storage configuration +export STORAGE_BACKEND=local +export STORAGE_LOCAL_PATH=./my_storage + +# B2 configuration (only needed if using B2) +export B2_APPLICATION_KEY_ID=your_key_id_here +export B2_APPLICATION_KEY=your_application_key_here +export B2_BUCKET_NAME=your_bucket_name_here +``` + +## 🚀 Quick Start Guide + +### **For Development/Testing (Local Storage)** +```bash +# 1. Edit config.json +{ + "storage": { + "backend": "local" + } +} + +# 2. Test it +python test_storage.py + +# 3. Start the app +python run.py +``` +Files will be stored in the `storage/` directory. + +### **For Production (B2 Cloud Storage)** +```bash +# 1. Get B2 credentials from backblaze.com +# 2. Edit config.json +{ + "storage": { + "backend": "b2" + }, + "b2": { + "application_key_id": "your_key_id", + "application_key": "your_key", + "bucket_name": "your_bucket" + } +} + +# 3. Test it +python test_storage.py + +# 4. Start the app +python run.py +``` + +## Setup Examples + +### Using Local Storage + +1. **Edit config.json:** +```json +{ + "storage": { + "backend": "local", + "local_path": "storage" + } +} +``` + +2. **Start the application:** +```bash +python run.py +``` + +The application will automatically create the `storage` directory and subdirectories as needed. + +### Using Backblaze B2 + +1. **Create a B2 account** at [backblaze.com](https://www.backblaze.com/b2/) + +2. **Create a bucket** and application key with read/write permissions + +3. **Edit config.json:** +```json +{ + "storage": { + "backend": "b2" + }, + "b2": { + "application_key_id": "your_actual_key_id", + "application_key": "your_actual_key", + "bucket_name": "your_bucket_name" + } +} +``` + +4. **Start the application:** +```bash +python run.py +``` + +## Interactive Configuration + +Use the configuration utility for easy setup: + +```bash +# Configure storage interactively +python src/config_util.py set + +# Validate your configuration +python src/config_util.py validate + +# Show current configuration +python src/config_util.py show +``` + +## Testing Storage + +Test your storage configuration: + +```bash +# Test storage backend +python test_storage.py +``` + +This will: +- ✅ Initialize the storage backend +- ✅ Upload a test file +- ✅ Download and verify the file +- ✅ Test file operations (exists, size, list) +- ✅ Clean up test files + +## Migration Between Backends + +### From B2 to Local Storage + +1. **Download your data** from B2 (optional - keep as backup) +2. **Update config.json:** +```json +{ + "storage": { + "backend": "local", + "local_path": "storage" + } +} +``` +3. **Restart the application** + +**Note:** Existing files in B2 won't be automatically transferred. URLs will break unless you migrate the data. + +### From Local to B2 Storage + +1. **Set up B2 bucket and credentials** +2. **Update config.json:** +```json +{ + "storage": { + "backend": "b2" + }, + "b2": { + "application_key_id": "your_key_id", + "application_key": "your_key", + "bucket_name": "your_bucket" + } +} +``` +3. **Restart the application** + +**Migration Script (Optional):** +If you want to migrate existing local files to B2, create a simple script: + +```python +#!/usr/bin/env python3 +import os +from pathlib import Path +from src.storage import LocalStorageBackend, B2StorageBackend + +# Initialize both backends +local = LocalStorageBackend("storage") +b2 = B2StorageBackend("key_id", "key", "bucket_name") + +# Migrate files +for file_path in local.list_files(): + print(f"Migrating: {file_path}") + content = local.download_file(file_path) + b2.upload_file(content, file_path) + print(f"✅ Migrated: {file_path}") +``` + +## Directory Structure + +### Local Storage Structure +``` +storage/ +├── files/ +│ ├── abc123.jpg +│ ├── def456.pdf +│ └── ... +└── pastes/ + ├── xyz789.txt + ├── uvw012.txt + └── ... +``` + +### B2 Storage Structure +``` +bucket/ +├── files/ +│ ├── abc123.jpg +│ ├── def456.pdf +│ └── ... +└── pastes/ + ├── xyz789.txt + ├── uvw012.txt + └── ... +``` + +## Performance Considerations + +### Local Storage +- **Pros:** Very fast access, no network latency +- **Cons:** Limited by disk I/O, single point of failure + +### B2 Storage +- **Pros:** Unlimited capacity, built-in redundancy, CDN capabilities +- **Cons:** Network latency, dependent on internet connection + +## Security + +### Local Storage +- Files are stored with filesystem permissions +- Secure as your server's filesystem +- Access controlled by Sharey application + +### B2 Storage +- Files stored with B2's encryption at rest +- Access controlled by application keys +- Private bucket - files not publicly accessible +- All access proxied through Sharey application + +## Troubleshooting + +### Common Issues + +**"Storage backend not initialized"** +- Check your configuration syntax +- Verify credentials (for B2) +- Run `python test_storage.py` + +**"B2 authentication failed"** +- Verify your application key ID and key +- Check bucket name spelling +- Ensure key has read/write permissions + +**"Permission denied" (Local storage)** +- Check filesystem permissions on storage directory +- Ensure Sharey has write access to the path + +**"File not found"** +- Check if storage backend was changed without migration +- Verify file was uploaded successfully + +### Debug Commands + +```bash +# Test storage backend +python test_storage.py + +# Validate configuration +python src/config_util.py validate + +# Check application logs +tail -f logs/sharey.log + +# Test B2 connection (if using B2) +python tests/test_b2.py +``` + +## Best Practices + +1. **Production:** Use B2 for scalability and reliability +2. **Development:** Local storage is fine for testing +3. **Backup:** Consider periodic backups regardless of backend +4. **Monitoring:** Monitor disk space (local) or B2 costs (cloud) +5. **Security:** Use environment variables for sensitive credentials in production + +## Cost Considerations + +### Local Storage +- **Cost:** Server disk space and bandwidth +- **Scaling:** Limited by hardware + +### B2 Storage +- **Storage:** $0.005/GB/month +- **Download:** $0.01/GB +- **API calls:** Mostly free (10,000 free per day) +- **Scaling:** Pay as you grow + +For most small to medium deployments, B2 costs are minimal (under $5/month for moderate usage). diff --git a/expiry.db b/expiry.db new file mode 100644 index 0000000..16d7ea4 Binary files /dev/null and b/expiry.db differ diff --git a/expiry_db.py b/expiry_db.py new file mode 100644 index 0000000..6089e3f --- /dev/null +++ b/expiry_db.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +""" +Database for tracking file expiry in Sharey +Simple SQLite database to store file paths and their expiry times +""" +import sqlite3 +import os +from datetime import datetime +from pathlib import Path + +class ExpiryDatabase: + """Manages file expiry tracking database""" + + def __init__(self, db_path="expiry.db"): + self.db_path = db_path + self.init_database() + + def init_database(self): + """Initialize the expiry database with required tables""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # Create expiry table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS file_expiry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_path TEXT UNIQUE NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + storage_backend TEXT NOT NULL + ) + ''') + + # Create URL shortener table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS url_redirects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + short_code TEXT UNIQUE NOT NULL, + target_url TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT, + click_count INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + created_by_ip TEXT, + notes TEXT + ) + ''') + + conn.commit() + conn.close() + print(f"✅ Expiry database initialized: {self.db_path}") + + def add_file(self, file_path: str, expires_at: str, storage_backend: str = "b2"): + """Add a file with expiry time to the database""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + created_at = datetime.utcnow().isoformat() + 'Z' + + cursor.execute(''' + INSERT OR REPLACE INTO file_expiry + (file_path, expires_at, created_at, storage_backend) + VALUES (?, ?, ?, ?) + ''', (file_path, expires_at, created_at, storage_backend)) + + conn.commit() + conn.close() + print(f"📝 Added expiry tracking: {file_path} expires at {expires_at}") + return True + except Exception as e: + print(f"❌ Failed to add expiry tracking for {file_path}: {e}") + return False + + def get_expired_files(self) -> list: + """Get list of files that have expired""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + current_time = datetime.utcnow().isoformat() + 'Z' + + cursor.execute(''' + SELECT file_path, expires_at, storage_backend + FROM file_expiry + WHERE expires_at <= ? + ORDER BY expires_at ASC + ''', (current_time,)) + + expired_files = cursor.fetchall() + conn.close() + + return [ + { + 'file_path': row[0], + 'expires_at': row[1], + 'storage_backend': row[2] + } + for row in expired_files + ] + except Exception as e: + print(f"❌ Failed to get expired files: {e}") + return [] + + def remove_file(self, file_path: str): + """Remove a file from expiry tracking (after deletion)""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute('DELETE FROM file_expiry WHERE file_path = ?', (file_path,)) + + conn.commit() + conn.close() + print(f"🗑️ Removed from expiry tracking: {file_path}") + return True + except Exception as e: + print(f"❌ Failed to remove expiry tracking for {file_path}: {e}") + return False + + def get_all_files(self) -> list: + """Get all files in expiry tracking""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + SELECT file_path, expires_at, created_at, storage_backend + FROM file_expiry + ORDER BY expires_at ASC + ''') + + all_files = cursor.fetchall() + conn.close() + + return [ + { + 'file_path': row[0], + 'expires_at': row[1], + 'created_at': row[2], + 'storage_backend': row[3] + } + for row in all_files + ] + except Exception as e: + print(f"❌ Failed to get all files: {e}") + return [] + + def get_stats(self) -> dict: + """Get database statistics""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # Total files + cursor.execute('SELECT COUNT(*) FROM file_expiry') + total_files = cursor.fetchone()[0] + + # Expired files + current_time = datetime.utcnow().isoformat() + 'Z' + cursor.execute('SELECT COUNT(*) FROM file_expiry WHERE expires_at <= ?', (current_time,)) + expired_files = cursor.fetchone()[0] + + # Files by storage backend + cursor.execute('SELECT storage_backend, COUNT(*) FROM file_expiry GROUP BY storage_backend') + by_backend = dict(cursor.fetchall()) + + conn.close() + + return { + 'total_files': total_files, + 'expired_files': expired_files, + 'active_files': total_files - expired_files, + 'by_backend': by_backend + } + except Exception as e: + print(f"❌ Failed to get stats: {e}") + return {} + + # URL Shortener Methods + def add_redirect(self, short_code, target_url, expires_at=None, created_by_ip=None, notes=None): + """Add a URL redirect""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + created_at = datetime.utcnow().isoformat() + 'Z' + + cursor.execute(''' + INSERT INTO url_redirects + (short_code, target_url, created_at, expires_at, created_by_ip, notes) + VALUES (?, ?, ?, ?, ?, ?) + ''', (short_code, target_url, created_at, expires_at, created_by_ip, notes)) + + conn.commit() + conn.close() + print(f"✅ Added redirect: {short_code} -> {target_url}") + return True + except sqlite3.IntegrityError: + print(f"❌ Short code already exists: {short_code}") + return False + except Exception as e: + print(f"❌ Failed to add redirect: {e}") + return False + + def get_redirect(self, short_code): + """Get redirect target URL and increment click count""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + # Check if redirect exists and is active + cursor.execute(''' + SELECT target_url, expires_at, is_active + FROM url_redirects + WHERE short_code = ? AND is_active = 1 + ''', (short_code,)) + + result = cursor.fetchone() + if not result: + conn.close() + return None + + target_url, expires_at, is_active = result + + # Check if expired + if expires_at: + current_time = datetime.utcnow().isoformat() + 'Z' + if expires_at <= current_time: + conn.close() + return None + + # Increment click count + cursor.execute(''' + UPDATE url_redirects + SET click_count = click_count + 1 + WHERE short_code = ? + ''', (short_code,)) + + conn.commit() + conn.close() + return target_url + + except Exception as e: + print(f"❌ Failed to get redirect: {e}") + return None + + def disable_redirect(self, short_code): + """Disable a redirect (for abuse/takedown)""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + UPDATE url_redirects + SET is_active = 0 + WHERE short_code = ? + ''', (short_code,)) + + rows_affected = cursor.rowcount + conn.commit() + conn.close() + + if rows_affected > 0: + print(f"✅ Disabled redirect: {short_code}") + return True + else: + print(f"❌ Redirect not found: {short_code}") + return False + + except Exception as e: + print(f"❌ Failed to disable redirect: {e}") + return False + + def list_redirects(self, limit=100): + """List recent redirects for admin purposes""" + try: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + SELECT short_code, target_url, created_at, expires_at, + click_count, is_active, created_by_ip + FROM url_redirects + ORDER BY created_at DESC + LIMIT ? + ''', (limit,)) + + redirects = [] + for row in cursor.fetchall(): + redirects.append({ + 'short_code': row[0], + 'target_url': row[1], + 'created_at': row[2], + 'expires_at': row[3], + 'click_count': row[4], + 'is_active': bool(row[5]), + 'created_by_ip': row[6] + }) + + conn.close() + return redirects + + except Exception as e: + print(f"❌ Failed to list redirects: {e}") + return [] diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..2cfe948 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,70 @@ +# Gunicorn configuration file for Sharey +# Save as: gunicorn.conf.py + +import multiprocessing +import os + +# Server socket +bind = "0.0.0.0:8866" +backlog = 2048 + +# Worker processes +workers = multiprocessing.cpu_count() * 2 + 1 +worker_class = "sync" +worker_connections = 1000 +timeout = 30 +keepalive = 2 + +# Restart workers after this many requests, to help prevent memory leaks +max_requests = 1000 +max_requests_jitter = 50 + +# Logging +accesslog = "logs/gunicorn_access.log" +errorlog = "logs/gunicorn_error.log" +loglevel = "info" +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' + +# Process naming +proc_name = "sharey" + +# Server mechanics +daemon = False +pidfile = "logs/gunicorn.pid" +user = None +group = None +tmp_upload_dir = None + +# SSL (uncomment if you have SSL certificates) +# keyfile = "/path/to/keyfile" +# certfile = "/path/to/certfile" + +# Security +limit_request_line = 4096 +limit_request_fields = 100 +limit_request_field_size = 8190 + +# Performance tuning +preload_app = True +enable_stdio_inheritance = True + +# Worker timeout for file uploads (increase for large files) +graceful_timeout = 120 + +def when_ready(server): + server.log.info("Sharey server is ready. Listening on: %s", server.address) + +def worker_int(worker): + worker.log.info("worker received INT or QUIT signal") + +def pre_fork(server, worker): + server.log.info("Worker spawned (pid: %s)", worker.pid) + +def post_fork(server, worker): + server.log.info("Worker spawned (pid: %s)", worker.pid) + +def post_worker_init(worker): + worker.log.info("Worker initialized (pid: %s)", worker.pid) + +def worker_abort(worker): + worker.log.info("Worker received SIGABRT signal") diff --git a/horizontal-layout.js b/horizontal-layout.js new file mode 100644 index 0000000..aa7b0a7 --- /dev/null +++ b/horizontal-layout.js @@ -0,0 +1,122 @@ +// Horizontal bar layout for file results + +// Replace the entire displayUploadedFiles function with this: +function displayUploadedFiles(results) { + console.log('📁 Displaying file results:', results); + + result.innerHTML = ''; + + const header = document.createElement('div'); + header.innerHTML = '

📁 Files Uploaded

'; + result.appendChild(header); + + results.forEach((fileResult, index) => { + const fileContainer = document.createElement('div'); + fileContainer.style.cssText = ` + margin-bottom: 15px; + padding: 15px; + border: 2px solid #4CAF50; + border-radius: 8px; + background-color: #f0f8f0; + display: flex; + align-items: center; + gap: 15px; + flex-wrap: wrap; + `; + + // Left: File info + const fileInfo = document.createElement('div'); + fileInfo.style.cssText = ` + flex: 1; + min-width: 180px; + `; + + const fileName = document.createElement('div'); + fileName.innerHTML = `📄 ${fileResult.file.name}`; + fileName.style.marginBottom = '5px'; + fileInfo.appendChild(fileName); + + const fileSize = document.createElement('div'); + fileSize.innerHTML = `📊 ${(fileResult.originalSize / 1024).toFixed(1)} KB`; + fileSize.style.cssText = ` + font-size: 14px; + color: #666; + `; + fileInfo.appendChild(fileSize); + + fileContainer.appendChild(fileInfo); + + // Middle: Action buttons + const buttonContainer = document.createElement('div'); + buttonContainer.style.cssText = ` + display: flex; + gap: 8px; + flex-wrap: wrap; + `; + + // Share button + const shareButton = document.createElement('button'); + shareButton.textContent = '🔗 Copy'; + shareButton.className = 'share-button'; + shareButton.style.cssText = `padding: 6px 10px; font-size: 13px;`; + shareButton.addEventListener('click', () => { + navigator.clipboard.writeText(fileResult.url).then(() => { + shareButton.textContent = '✅ Copied!'; + setTimeout(() => { + shareButton.textContent = '🔗 Copy'; + }, 2000); + }); + }); + buttonContainer.appendChild(shareButton); + + // View button + const viewButton = document.createElement('button'); + viewButton.textContent = '👁️ View'; + viewButton.className = 'view-button'; + viewButton.style.cssText = `padding: 6px 10px; font-size: 13px;`; + viewButton.addEventListener('click', () => { + window.open(fileResult.url, '_blank'); + }); + buttonContainer.appendChild(viewButton); + + // QR Code button + const qrButton = document.createElement('button'); + qrButton.textContent = '📱 QR'; + qrButton.className = 'qr-button'; + qrButton.style.cssText = ` + background-color: #9C27B0; + color: white; + padding: 6px 10px; + font-size: 13px; + `; + qrButton.addEventListener('click', () => { + showQRCode(fileResult.url, fileResult.file.name); + }); + buttonContainer.appendChild(qrButton); + + fileContainer.appendChild(buttonContainer); + + // Right: Image thumbnail (if it's an image) + if (isImageFile(fileResult.file)) { + const thumbnail = document.createElement('img'); + thumbnail.src = fileResult.url; + thumbnail.alt = fileResult.file.name; + thumbnail.style.cssText = ` + width: 60px; + height: 60px; + object-fit: cover; + border-radius: 6px; + border: 1px solid #ddd; + flex-shrink: 0; + `; + + thumbnail.onerror = () => { + thumbnail.style.display = 'none'; + }; + + fileContainer.appendChild(thumbnail); + } + + result.appendChild(fileContainer); + }); +} diff --git a/production.py b/production.py new file mode 100644 index 0000000..30dab66 --- /dev/null +++ b/production.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Production runner for Sharey using Gunicorn +""" +import sys +import os +import subprocess +from pathlib import Path + +def check_gunicorn(): + """Check if gunicorn is installed""" + try: + import gunicorn + return True + except ImportError: + return False + +def install_gunicorn(): + """Install gunicorn""" + print("📦 Installing gunicorn...") + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", "gunicorn"]) + print("✅ Gunicorn installed successfully") + return True + except subprocess.CalledProcessError: + print("❌ Failed to install gunicorn") + return False + +def start_production_server(): + """Start Sharey with Gunicorn""" + + # Add src directory to Python path + src_path = Path(__file__).parent / "src" + if str(src_path) not in sys.path: + sys.path.insert(0, str(src_path)) + + # Check if gunicorn is available + if not check_gunicorn(): + print("⚠️ Gunicorn not found") + if input("📦 Install gunicorn? (y/n): ").lower().startswith('y'): + if not install_gunicorn(): + return False + else: + print("❌ Cannot start production server without gunicorn") + return False + + print("🚀 Starting Sharey with Gunicorn (Production Mode)") + print("=" * 60) + + # Load configuration + try: + from config import config + host = config.get('flask.host', '0.0.0.0') + port = config.get('flask.port', 8866) + + print(f"🌐 Host: {host}") + print(f"🔌 Port: {port}") + print(f"📁 Working directory: {Path.cwd()}") + print("=" * 60) + + except Exception as e: + print(f"❌ Error loading configuration: {e}") + return False + + # Gunicorn command arguments + gunicorn_args = [ + sys.executable, "-m", "gunicorn", + "--bind", f"{host}:{port}", + "--workers", "4", + "--worker-class", "sync", + "--timeout", "120", + "--max-requests", "1000", + "--max-requests-jitter", "100", + "--access-logfile", "-", + "--error-logfile", "-", + "--log-level", "info", + "--pythonpath", str(src_path), + "wsgi:app" + ] + + try: + print("🎯 Starting server...") + print("Press Ctrl+C to stop") + print("=" * 60) + subprocess.run(gunicorn_args) + + except KeyboardInterrupt: + print("\n👋 Server stopped by user") + except Exception as e: + print(f"❌ Error starting server: {e}") + return False + + return True + +def main(): + """Main entry point""" + print("🏭 Sharey Production Server") + print("=" * 40) + + if start_production_server(): + print("✅ Server stopped gracefully") + else: + print("❌ Server failed to start") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..450cc7b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask==2.3.3 +b2sdk>=2.9.0 +python-dotenv==1.0.0 +setuptools<75 +gunicorn>=21.2.0 diff --git a/scripts/clean.sh b/scripts/clean.sh new file mode 100755 index 0000000..70159f1 --- /dev/null +++ b/scripts/clean.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Clean up temporary files and caches + +echo "🧹 Cleaning up Sharey project..." + +# Remove Python cache +echo "🐍 Removing Python cache..." +find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true +find . -name "*.pyc" -delete 2>/dev/null || true +find . -name "*.pyo" -delete 2>/dev/null || true + +# Remove temporary files +echo "🗑️ Removing temporary files..." +rm -f *.tmp *.temp debug_*.html test_*.ppm + +# Remove old log files (keep recent ones) +echo "📜 Cleaning old logs..." +find logs/ -name "*.log" -mtime +7 -delete 2>/dev/null || true + +# Remove backup files +echo "💾 Removing backup files..." +rm -f *.backup *.bak config.json.backup.* + +echo "✅ Cleanup complete!" diff --git a/scripts/config.json b/scripts/config.json new file mode 100644 index 0000000..fc91283 --- /dev/null +++ b/scripts/config.json @@ -0,0 +1,35 @@ +{ + "b2": { + "application_key_id": "your_key_id_here", + "application_key": "your_application_key_here", + "bucket_name": "your_bucket_name_here" + }, + "flask": { + "host": "127.0.0.1", + "port": 8866, + "debug": true + }, + "upload": { + "max_file_size_mb": 100, + "allowed_extensions": [ + ".jpg", + ".jpeg", + ".png", + ".gif", + ".pdf", + ".txt", + ".doc", + ".docx", + ".zip", + ".mp4", + ".mp3" + ] + }, + "paste": { + "max_length": 1000000 + }, + "security": { + "rate_limit_enabled": false, + "max_uploads_per_hour": 50 + } +} \ No newline at end of file diff --git a/scripts/migrate.py b/scripts/migrate.py new file mode 100644 index 0000000..aa69699 --- /dev/null +++ b/scripts/migrate.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +""" +Sharey Local-to-B2 Migration Script + +This script migrates existing local files and pastes to Backblaze B2 +while preserving their original IDs and structure. + +Sharey Naming Conventions: +- Files: 6-char random ID + original extension (e.g., abc123.jpg) +- Pastes: 6-char UUID prefix + .txt extension (e.g., def456.txt) +- B2 Structure: files/{file_id} and pastes/{paste_id}.txt +""" + +import os +import sys +import mimetypes +from pathlib import Path +from typing import Dict, List, Tuple +import json +from datetime import datetime + +try: + from b2sdk.v2 import InMemoryAccountInfo, B2Api + from config import config +except ImportError as e: + print(f"❌ Missing dependencies: {e}") + print("💡 Make sure you're running this script in the same environment as your Sharey app") + print("💡 Run: pip install -r requirements.txt") + sys.exit(1) + + +class ShareyMigrator: + """Handles migration of local Sharey files to B2""" + + def __init__(self): + self.b2_api = None + self.bucket = None + self.stats = { + 'files_migrated': 0, + 'pastes_migrated': 0, + 'files_skipped': 0, + 'pastes_skipped': 0, + 'errors': 0, + 'total_size': 0 + } + self.migration_log = [] + + def initialize_b2(self) -> bool: + """Initialize B2 connection""" + print("🔧 Initializing B2 connection...") + + # Validate B2 configuration + if not config.validate_b2_config(): + print("❌ Invalid B2 configuration. Please check your config.json") + return False + + try: + b2_config = config.get_b2_config() + print(f"📋 Target bucket: {b2_config['bucket_name']}") + + info = InMemoryAccountInfo() + self.b2_api = B2Api(info) + self.b2_api.authorize_account("production", b2_config['key_id'], b2_config['key']) + self.bucket = self.b2_api.get_bucket_by_name(b2_config['bucket_name']) + print("✅ B2 connection established") + return True + + except Exception as e: + print(f"❌ Failed to connect to B2: {e}") + return False + + def scan_local_directories(self, base_path: str = ".") -> Tuple[List[str], List[str]]: + """Scan for local uploads and pastes directories""" + print(f"🔍 Scanning for local files in: {os.path.abspath(base_path)}") + + uploads_dir = os.path.join(base_path, "uploads") + pastes_dir = os.path.join(base_path, "pastes") + + file_paths = [] + paste_paths = [] + + # Scan uploads directory + if os.path.exists(uploads_dir): + print(f"📁 Found uploads directory: {uploads_dir}") + for root, dirs, files in os.walk(uploads_dir): + for file in files: + # Skip hidden files, metadata files, and any Sharey system files + if (not file.startswith('.') and + not file.endswith('.sharey-meta') and + '.sharey-meta' not in file): + file_paths.append(os.path.join(root, file)) + print(f" Found {len(file_paths)} files (skipped .sharey-meta files)") + else: + print(f"⚠️ No uploads directory found at: {uploads_dir}") + + # Scan pastes directory + if os.path.exists(pastes_dir): + print(f"📝 Found pastes directory: {pastes_dir}") + for root, dirs, files in os.walk(pastes_dir): + for file in files: + if not file.startswith('.'): # Skip hidden files + paste_paths.append(os.path.join(root, file)) + print(f" Found {len(paste_paths)} pastes") + else: + print(f"⚠️ No pastes directory found at: {pastes_dir}") + + return file_paths, paste_paths + + def extract_id_from_path(self, file_path: str, base_dir: str) -> str: + """Extract the file ID from the file path""" + # Get relative path from base directory + rel_path = os.path.relpath(file_path, base_dir) + + # Extract filename without extension for ID + filename = os.path.basename(rel_path) + file_id = os.path.splitext(filename)[0] + + # Validate ID format (should be 6 characters for Sharey) + if len(file_id) != 6: + print(f"⚠️ Warning: {filename} has non-standard ID length ({len(file_id)} chars, expected 6)") + + return file_id + + def file_exists_in_b2(self, b2_path: str) -> bool: + """Check if a file already exists in B2""" + try: + # Try different methods depending on B2 SDK version + if hasattr(self.bucket, 'get_file_info_by_name'): + file_info = self.bucket.get_file_info_by_name(b2_path) + return True + elif hasattr(self.bucket, 'ls'): + for file_version, _ in self.bucket.ls(b2_path, recursive=False): + if file_version.file_name == b2_path: + return True + return False + else: + # Fallback - assume doesn't exist to avoid skipping + return False + except: + return False + + def migrate_file(self, local_path: str, uploads_dir: str, dry_run: bool = False) -> bool: + """Migrate a single file to B2""" + try: + # Extract file ID and determine B2 path + file_id = self.extract_id_from_path(local_path, uploads_dir) + file_extension = os.path.splitext(local_path)[1] + b2_path = f"files/{file_id}{file_extension}" + + # Check if file already exists in B2 + if self.file_exists_in_b2(b2_path): + print(f"⏭️ Skipping {file_id} (already exists in B2)") + self.stats['files_skipped'] += 1 + return True + + # Get file info + file_size = os.path.getsize(local_path) + content_type = mimetypes.guess_type(local_path)[0] or 'application/octet-stream' + + print(f"📤 Uploading file: {file_id}{file_extension} ({file_size:,} bytes)") + + if dry_run: + print(f" [DRY RUN] Would upload to: {b2_path}") + self.stats['files_migrated'] += 1 + self.stats['total_size'] += file_size + return True + + # Upload to B2 - try different methods for different SDK versions + with open(local_path, 'rb') as file_data: + data = file_data.read() + + # Try different upload methods + try: + # Method 1: upload_bytes (newer SDK) + if hasattr(self.bucket, 'upload_bytes'): + file_info = self.bucket.upload_bytes( + data, + b2_path, + content_type=content_type + ) + # Method 2: upload with file-like object (older SDK) + elif hasattr(self.bucket, 'upload_file'): + from io import BytesIO + file_obj = BytesIO(data) + file_info = self.bucket.upload_file( + file_obj, + b2_path, + content_type=content_type + ) + # Method 3: upload with upload source (alternative) + elif hasattr(self.bucket, 'upload'): + from io import BytesIO + file_obj = BytesIO(data) + file_info = self.bucket.upload( + file_obj, + b2_path, + content_type=content_type + ) + else: + raise Exception("No compatible upload method found in B2 SDK") + + except Exception as upload_error: + raise Exception(f"Upload failed: {upload_error}") + + self.stats['files_migrated'] += 1 + self.stats['total_size'] += file_size + self.migration_log.append(f"FILE: {file_id}{file_extension} -> {b2_path}") + print(f" ✅ Uploaded successfully") + return True + + except Exception as e: + print(f" ❌ Failed to upload {local_path}: {e}") + self.stats['errors'] += 1 + self.migration_log.append(f"ERROR: {local_path} -> {e}") + return False + + def migrate_paste(self, local_path: str, pastes_dir: str, dry_run: bool = False) -> bool: + """Migrate a single paste to B2""" + try: + # Extract paste ID and determine B2 path + paste_id = self.extract_id_from_path(local_path, pastes_dir) + b2_path = f"pastes/{paste_id}.txt" + + # Check if paste already exists in B2 + if self.file_exists_in_b2(b2_path): + print(f"⏭️ Skipping paste {paste_id} (already exists in B2)") + self.stats['pastes_skipped'] += 1 + return True + + # Get paste info + file_size = os.path.getsize(local_path) + + print(f"📝 Uploading paste: {paste_id} ({file_size:,} bytes)") + + if dry_run: + print(f" [DRY RUN] Would upload to: {b2_path}") + self.stats['pastes_migrated'] += 1 + self.stats['total_size'] += file_size + return True + + # Read and upload paste content + with open(local_path, 'r', encoding='utf-8', errors='ignore') as file: + content = file.read() + + # Upload to B2 as UTF-8 text - try different methods + data = content.encode('utf-8') + + try: + # Method 1: upload_bytes (newer SDK) + if hasattr(self.bucket, 'upload_bytes'): + self.bucket.upload_bytes( + data, + b2_path, + content_type='text/plain; charset=utf-8' + ) + # Method 2: upload with file-like object (older SDK) + elif hasattr(self.bucket, 'upload_file'): + from io import BytesIO + file_obj = BytesIO(data) + self.bucket.upload_file( + file_obj, + b2_path, + content_type='text/plain; charset=utf-8' + ) + # Method 3: upload with upload source (alternative) + elif hasattr(self.bucket, 'upload'): + from io import BytesIO + file_obj = BytesIO(data) + self.bucket.upload( + file_obj, + b2_path, + content_type='text/plain; charset=utf-8' + ) + else: + raise Exception("No compatible upload method found in B2 SDK") + + except Exception as upload_error: + raise Exception(f"Upload failed: {upload_error}") + + self.stats['pastes_migrated'] += 1 + self.stats['total_size'] += file_size + self.migration_log.append(f"PASTE: {paste_id} -> {b2_path}") + print(f" ✅ Uploaded successfully") + return True + + except Exception as e: + print(f" ❌ Failed to upload paste {local_path}: {e}") + self.stats['errors'] += 1 + self.migration_log.append(f"ERROR: {local_path} -> {e}") + return False + + def migrate_all(self, base_path: str = ".", dry_run: bool = False, skip_files: bool = False, skip_pastes: bool = False): + """Migrate all local files and pastes to B2""" + if dry_run: + print("🧪 DRY RUN MODE - No files will actually be uploaded") + + print(f"\n🚀 Starting migration from: {os.path.abspath(base_path)}") + print("=" * 60) + + # Scan for local files + file_paths, paste_paths = self.scan_local_directories(base_path) + + if not file_paths and not paste_paths: + print("❌ No files or pastes found to migrate") + return False + + total_items = len(file_paths) + len(paste_paths) + print(f"\n📊 Migration Plan:") + print(f" Files to migrate: {len(file_paths)}") + print(f" Pastes to migrate: {len(paste_paths)}") + print(f" Total items: {total_items}") + + if not dry_run: + confirm = input(f"\n❓ Proceed with migration? (y/N): ").strip().lower() + if confirm != 'y': + print("Migration cancelled") + return False + + print(f"\n🔄 Starting migration...") + print("-" * 40) + + # Migrate files + if file_paths and not skip_files: + print(f"\n📁 Migrating {len(file_paths)} files...") + uploads_dir = os.path.join(base_path, "uploads") + + for i, file_path in enumerate(file_paths, 1): + print(f"[{i}/{len(file_paths)}] ", end="") + self.migrate_file(file_path, uploads_dir, dry_run) + + # Migrate pastes + if paste_paths and not skip_pastes: + print(f"\n📝 Migrating {len(paste_paths)} pastes...") + pastes_dir = os.path.join(base_path, "pastes") + + for i, paste_path in enumerate(paste_paths, 1): + print(f"[{i}/{len(paste_paths)}] ", end="") + self.migrate_paste(paste_path, pastes_dir, dry_run) + + self.print_summary(dry_run) + self.save_migration_log() + return True + + def print_summary(self, dry_run: bool = False): + """Print migration summary""" + print("\n" + "=" * 60) + print("📊 MIGRATION SUMMARY") + print("=" * 60) + + if dry_run: + print("🧪 DRY RUN RESULTS:") + + print(f"✅ Files migrated: {self.stats['files_migrated']}") + print(f"✅ Pastes migrated: {self.stats['pastes_migrated']}") + print(f"⏭️ Files skipped: {self.stats['files_skipped']}") + print(f"⏭️ Pastes skipped: {self.stats['pastes_skipped']}") + print(f"❌ Errors: {self.stats['errors']}") + print(f"📦 Total data: {self.stats['total_size']:,} bytes ({self.stats['total_size'] / 1024 / 1024:.2f} MB)") + + success_rate = ((self.stats['files_migrated'] + self.stats['pastes_migrated']) / + max(1, self.stats['files_migrated'] + self.stats['pastes_migrated'] + self.stats['errors'])) * 100 + print(f"📈 Success rate: {success_rate:.1f}%") + + if not dry_run and (self.stats['files_migrated'] > 0 or self.stats['pastes_migrated'] > 0): + print(f"\n🎉 Migration completed successfully!") + print(f"💡 Your files are now accessible via your Sharey B2 URLs") + + def save_migration_log(self): + """Save migration log to file""" + if not self.migration_log: + return + + log_filename = f"migration_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt" + + try: + with open(log_filename, 'w') as f: + f.write(f"Sharey B2 Migration Log\n") + f.write(f"Generated: {datetime.now().isoformat()}\n") + f.write(f"=" * 50 + "\n\n") + + for entry in self.migration_log: + f.write(f"{entry}\n") + + f.write(f"\n" + "=" * 50 + "\n") + f.write(f"SUMMARY:\n") + f.write(f"Files migrated: {self.stats['files_migrated']}\n") + f.write(f"Pastes migrated: {self.stats['pastes_migrated']}\n") + f.write(f"Files skipped: {self.stats['files_skipped']}\n") + f.write(f"Pastes skipped: {self.stats['pastes_skipped']}\n") + f.write(f"Errors: {self.stats['errors']}\n") + f.write(f"Total size: {self.stats['total_size']:,} bytes\n") + + print(f"📄 Migration log saved to: {log_filename}") + + except Exception as e: + print(f"⚠️ Failed to save migration log: {e}") + + +def main(): + """Main migration function""" + print("🚀 Sharey Local-to-B2 Migration Tool") + print("=" * 50) + + # Parse command line arguments + import argparse + parser = argparse.ArgumentParser(description='Migrate local Sharey files to Backblaze B2') + parser.add_argument('--path', '-p', default='.', help='Path to Sharey directory (default: current directory)') + parser.add_argument('--dry-run', '-d', action='store_true', help='Perform a dry run without uploading') + parser.add_argument('--skip-files', action='store_true', help='Skip file migration') + parser.add_argument('--skip-pastes', action='store_true', help='Skip paste migration') + parser.add_argument('--force', '-f', action='store_true', help='Skip confirmation prompt') + + args = parser.parse_args() + + # Initialize migrator + migrator = ShareyMigrator() + + # Initialize B2 connection + if not migrator.initialize_b2(): + print("❌ Failed to initialize B2 connection") + sys.exit(1) + + # Run migration + try: + success = migrator.migrate_all( + base_path=args.path, + dry_run=args.dry_run, + skip_files=args.skip_files, + skip_pastes=args.skip_pastes + ) + + if success: + print(f"\n💡 Next steps:") + print(f" 1. Test your Sharey app to ensure URLs work correctly") + print(f" 2. Consider backing up your local files before deletion") + print(f" 3. Update any hardcoded URLs to use the new B2 structure") + sys.exit(0) + else: + sys.exit(1) + + except KeyboardInterrupt: + print(f"\n⏹️ Migration cancelled by user") + sys.exit(1) + except Exception as e: + print(f"\n❌ Migration failed: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/set_admin_password.py b/scripts/set_admin_password.py new file mode 100644 index 0000000..eca6df8 --- /dev/null +++ b/scripts/set_admin_password.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Simple script to set admin password for Sharey +""" +import hashlib +import json + +def set_admin_password(password): + """Set admin password in config.json""" + try: + # Load current config + with open('config.json', 'r') as f: + config = json.load(f) + + # Hash the password + password_hash = hashlib.sha256(password.encode()).hexdigest() + + # Ensure admin section exists + if 'admin' not in config: + config['admin'] = {} + + config['admin']['password_hash'] = password_hash + config['admin']['session_timeout_minutes'] = config['admin'].get('session_timeout_minutes', 30) + + # Save config + with open('config.json', 'w') as f: + json.dump(config, f, indent=2) + + print("✅ Admin password set successfully!") + print("💡 You can now access the admin panel at /admin") + return True + + except Exception as e: + print(f"❌ Error setting admin password: {e}") + return False + +if __name__ == "__main__": + import sys + + if len(sys.argv) != 2: + print("Usage: python set_admin_password.py ") + print("Example: python set_admin_password.py mySecurePassword123") + sys.exit(1) + + password = sys.argv[1] + + if len(password) < 6: + print("❌ Password must be at least 6 characters long") + sys.exit(1) + + set_admin_password(password) diff --git a/scripts/setup.py b/scripts/setup.py new file mode 100644 index 0000000..83351a9 --- /dev/null +++ b/scripts/setup.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +Sharey B2 Setup Script +""" +import os +import sys +import subprocess +import shutil + +def check_python(): + """Check if we have a compatible Python version""" + if sys.version_info < (3, 7): + print("❌ Python 3.7+ is required. Current version:", sys.version) + sys.exit(1) + print(f"✅ Python {sys.version.split()[0]} detected") + +def create_env_file(): + """Create config.json file from template if it doesn't exist""" + if not os.path.exists('config.json'): + if os.path.exists('config.json.example'): + shutil.copy('config.json.example', 'config.json') + print("📄 Created config.json file from template") + print("\nPlease edit config.json with your Backblaze B2 credentials:") + print(" - b2.application_key_id: Your B2 application key ID") + print(" - b2.application_key: Your B2 application key") + print(" - b2.bucket_name: Your B2 bucket name") + print("\nYou can get these credentials from your Backblaze account:") + print(" 1. Go to https://secure.backblaze.com/app_keys.htm") + print(" 2. Create a new application key or use an existing one") + print(" 3. Create a bucket or use an existing one") + else: + print("❌ config.json.example not found. Creating basic template...") + basic_config = { + "b2": { + "application_key_id": "your_key_id_here", + "application_key": "your_application_key_here", + "bucket_name": "your_bucket_name_here" + }, + "flask": { + "host": "127.0.0.1", + "port": 8866, + "debug": True + }, + "upload": { + "max_file_size_mb": 100, + "allowed_extensions": [".jpg", ".jpeg", ".png", ".gif", ".pdf", ".txt", ".doc", ".docx", ".zip", ".mp4", ".mp3"] + }, + "paste": { + "max_length": 1000000 + }, + "security": { + "rate_limit_enabled": False, + "max_uploads_per_hour": 50 + } + } + + import json + with open('config.json', 'w') as f: + json.dump(basic_config, f, indent=2) + print("📄 Created basic config.json template") + else: + print("📄 config.json file already exists. Please ensure it has the correct B2 credentials.") + + # Also create .env for backwards compatibility if it doesn't exist + if not os.path.exists('.env'): + if os.path.exists('.env.example'): + shutil.copy('.env.example', '.env') + print("📄 Also created .env file for backwards compatibility") + +def install_dependencies(): + """Install Python dependencies""" + print("\n📦 Installing Python dependencies...") + + if not os.path.exists('requirements.txt'): + print("❌ requirements.txt not found!") + return False + + # Check if we're in a virtual environment + in_venv = (hasattr(sys, 'real_prefix') or + (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix)) + + if not in_venv: + print("⚠️ Not in a virtual environment") + print("Creating virtual environment...") + try: + subprocess.run([sys.executable, '-m', 'venv', 'venv'], check=True) + print("✅ Virtual environment created") + + # Determine the correct pip path + if os.name == 'nt': # Windows + pip_path = os.path.join('venv', 'Scripts', 'pip') + python_path = os.path.join('venv', 'Scripts', 'python') + else: # Unix-like + pip_path = os.path.join('venv', 'bin', 'pip') + python_path = os.path.join('venv', 'bin', 'python') + + # Install dependencies in virtual environment + subprocess.run([pip_path, 'install', '-r', 'requirements.txt'], check=True) + print("✅ Dependencies installed in virtual environment") + print(f"💡 To activate the virtual environment:") + if os.name == 'nt': + print(" venv\\Scripts\\activate") + else: + print(" source venv/bin/activate") + print(f"💡 Then run the app with: {python_path} app.py") + return True + + except subprocess.CalledProcessError as e: + print(f"❌ Failed to create virtual environment or install dependencies: {e}") + return False + else: + print("✅ In virtual environment") + try: + subprocess.run([sys.executable, '-m', 'pip', 'install', '-r', 'requirements.txt'], + check=True, capture_output=True, text=True) + print("✅ Dependencies installed successfully") + return True + except subprocess.CalledProcessError as e: + print(f"❌ Failed to install dependencies: {e}") + print("Error output:", e.stderr) + return False + +def test_imports(): + """Test if required modules can be imported""" + print("\n🧪 Testing imports...") + + # Check if we're in a virtual environment or if venv was created + in_venv = (hasattr(sys, 'real_prefix') or + (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix)) + + if not in_venv and os.path.exists('venv'): + print("💡 Skipping import test - using virtual environment") + print(" Imports will be tested when you activate the virtual environment") + return True + + required_modules = ['flask', 'b2sdk', 'dotenv'] + missing_modules = [] + + for module in required_modules: + try: + __import__(module) + print(f" ✅ {module}") + except ImportError: + print(f" ❌ {module}") + missing_modules.append(module) + + if missing_modules: + print(f"\n❌ Missing modules: {', '.join(missing_modules)}") + if not in_venv: + print("💡 This is expected if using a virtual environment") + return True + else: + print("Try running: pip install -r requirements.txt") + return False + + print("✅ All required modules available") + return True + +def check_b2_config(): + """Check if B2 configuration looks valid""" + print("\n🔧 Checking B2 configuration...") + + # Check config.json first + if os.path.exists('config.json'): + try: + import json + with open('config.json', 'r') as f: + config_data = json.load(f) + + b2_config = config_data.get('b2', {}) + required_keys = ['application_key_id', 'application_key', 'bucket_name'] + invalid_values = ['your_key_id_here', 'your_application_key_here', 'your_bucket_name_here', ''] + missing_keys = [] + + for key in required_keys: + value = b2_config.get(key) + if not value or value in invalid_values: + missing_keys.append(f'b2.{key}') + + if missing_keys: + print(f"❌ Please configure these B2 settings in config.json: {', '.join(missing_keys)}") + return False + + print("✅ B2 configuration looks valid in config.json") + return True + + except (json.JSONDecodeError, KeyError) as e: + print(f"❌ Error reading config.json: {e}") + return False + + # Fall back to .env file + elif os.path.exists('.env'): + b2_config = {} + with open('.env', 'r') as f: + for line in f: + line = line.strip() + if '=' in line and not line.startswith('#'): + key, value = line.split('=', 1) + b2_config[key] = value + + required_keys = ['B2_APPLICATION_KEY_ID', 'B2_APPLICATION_KEY', 'B2_BUCKET_NAME'] + missing_keys = [] + + for key in required_keys: + if key not in b2_config or b2_config[key] in ['', 'your_key_id_here', 'your_application_key_here', 'your_bucket_name_here']: + missing_keys.append(key) + + if missing_keys: + print(f"❌ Please configure these B2 settings in .env: {', '.join(missing_keys)}") + return False + + print("✅ B2 configuration looks valid in .env") + return True + + else: + print("❌ No configuration file found (config.json or .env)") + return False + +def main(): + """Main setup function""" + print("🚀 Setting up Sharey with Backblaze B2 Storage...\n") + + # Check Python version + check_python() + + # Create .env file + create_env_file() + + # Install dependencies + if not install_dependencies(): + print("\n❌ Setup failed during dependency installation") + sys.exit(1) + + # Test imports + if not test_imports(): + print("\n❌ Setup failed: missing required modules") + sys.exit(1) + + # Check B2 configuration + config_valid = check_b2_config() + + print("\n" + "="*60) + print("🎉 Setup complete!") + print("="*60) + + # Check if we're in a virtual environment + in_venv = (hasattr(sys, 'real_prefix') or + (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix)) + + if not in_venv and os.path.exists('venv'): + print("\n💡 Virtual environment created!") + print("To use Sharey:") + if os.name == 'nt': # Windows + print(" 1. Activate virtual environment: venv\\Scripts\\activate") + print(" 2. Edit config.json with your B2 credentials") + print(" 3. Test B2 connection: venv\\Scripts\\python test_b2.py") + print(" 4. Run the app: venv\\Scripts\\python app.py") + else: # Unix-like + print(" 1. Activate virtual environment: source venv/bin/activate") + print(" 2. Edit config.json with your B2 credentials") + print(" 3. Test B2 connection: python test_b2.py") + print(" 4. Run the app: python app.py") + else: + if not config_valid: + print("\n⚠️ Next steps:") + print(" 1. Edit config.json with your B2 credentials") + print(" 2. Run: python test_b2.py (to test B2 connection)") + print(" 3. Run: python app.py (to start the application)") + else: + print("\n✅ Next steps:") + print(" 1. Run: python test_b2.py (to test B2 connection)") + print(" 2. Run: python app.py (to start the application)") + + print("\n📋 Notes:") + print(" - Make sure your B2 bucket allows public downloads") + print(" - Application will be available at http://127.0.0.1:8866") + print(" - Check DEPLOYMENT.md for production deployment guide") + +if __name__ == "__main__": + main() diff --git a/set_admin_password.py b/set_admin_password.py new file mode 100644 index 0000000..eca6df8 --- /dev/null +++ b/set_admin_password.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +Simple script to set admin password for Sharey +""" +import hashlib +import json + +def set_admin_password(password): + """Set admin password in config.json""" + try: + # Load current config + with open('config.json', 'r') as f: + config = json.load(f) + + # Hash the password + password_hash = hashlib.sha256(password.encode()).hexdigest() + + # Ensure admin section exists + if 'admin' not in config: + config['admin'] = {} + + config['admin']['password_hash'] = password_hash + config['admin']['session_timeout_minutes'] = config['admin'].get('session_timeout_minutes', 30) + + # Save config + with open('config.json', 'w') as f: + json.dump(config, f, indent=2) + + print("✅ Admin password set successfully!") + print("💡 You can now access the admin panel at /admin") + return True + + except Exception as e: + print(f"❌ Error setting admin password: {e}") + return False + +if __name__ == "__main__": + import sys + + if len(sys.argv) != 2: + print("Usage: python set_admin_password.py ") + print("Example: python set_admin_password.py mySecurePassword123") + sys.exit(1) + + password = sys.argv[1] + + if len(password) < 6: + print("❌ Password must be at least 6 characters long") + sys.exit(1) + + set_admin_password(password) diff --git a/sharey b/sharey new file mode 100755 index 0000000..a15d65e --- /dev/null +++ b/sharey @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +Sharey - Production Entry Point +Simple Python-based entry point for the Sharey file sharing platform +""" + +import sys +import os +import subprocess +import argparse +from pathlib import Path + +def main(): + parser = argparse.ArgumentParser(description='Sharey File Sharing Platform') + parser.add_argument('command', nargs='?', default='start', + choices=['start', 'stop', 'restart', 'status'], + help='Command to execute (default: start)') + parser.add_argument('--port', '-p', type=int, default=8000, + help='Port to run on (default: 8000)') + parser.add_argument('--host', default='0.0.0.0', + help='Host to bind to (default: 0.0.0.0)') + + args = parser.parse_args() + + script_dir = Path(__file__).parent + os.chdir(script_dir) + + if args.command == 'start': + print("🚀 Starting Sharey production server...") + subprocess.run([sys.executable, 'production.py'], check=True) + + elif args.command == 'stop': + print("🛑 Stopping Sharey server...") + # Kill gunicorn processes + try: + subprocess.run(['pkill', '-f', 'gunicorn.*sharey'], check=False) + print("✅ Server stopped") + except: + print("❌ Error stopping server") + + elif args.command == 'restart': + print("🔄 Restarting Sharey server...") + subprocess.run(['pkill', '-f', 'gunicorn.*sharey'], check=False) + subprocess.run([sys.executable, 'production.py'], check=True) + + elif args.command == 'status': + print("📊 Checking Sharey server status...") + result = subprocess.run(['pgrep', '-f', 'gunicorn.*sharey'], + capture_output=True, text=True) + if result.stdout.strip(): + print("✅ Server is running") + print(f"PIDs: {result.stdout.strip()}") + else: + print("❌ Server is not running") + +if __name__ == '__main__': + main() diff --git a/sharey.py b/sharey.py new file mode 100644 index 0000000..32e1584 --- /dev/null +++ b/sharey.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +Sharey - Command Line Utility +Cross-platform management script for Sharey application +""" + +import sys +import os +import subprocess +import argparse +from pathlib import Path +import json +import time + +class ShareyManager: + def __init__(self): + self.script_dir = Path(__file__).parent + os.chdir(self.script_dir) + + def print_banner(self): + """Print Sharey banner""" + print("🚀 Sharey - Command Line Utility") + print("=" * 40) + + def start_app(self, dev_mode=False, production_mode=False): + """Start the application""" + if production_mode: + print("🏭 Starting Sharey in production mode with Gunicorn...") + return subprocess.run([sys.executable, "production.py"]) + elif dev_mode: + print("🔧 Starting Sharey in development mode...") + if Path("dev.py").exists(): + return subprocess.run([sys.executable, "dev.py"]) + else: + os.environ['FLASK_ENV'] = 'development' + return subprocess.run([sys.executable, "run.py"]) + else: + print("🚀 Starting Sharey with full checks...") + return subprocess.run([sys.executable, "run.py"]) + + def setup_environment(self): + """Set up development environment""" + print("📦 Setting up development environment...") + + if Path("dev-setup.sh").exists(): + return subprocess.run(["./dev-setup.sh"]) + else: + # Fallback Python setup + print("Running Python setup...") + + # Create venv if it doesn't exist + if not Path(".venv").exists(): + print("Creating virtual environment...") + subprocess.run([sys.executable, "-m", "venv", ".venv"]) + + # Install dependencies + if Path("requirements.txt").exists(): + print("Installing dependencies...") + pip_path = ".venv/bin/pip" + subprocess.run([pip_path, "install", "-r", "requirements.txt"]) + + # Copy config if needed + if not Path("config.json").exists() and Path("config.json.example").exists(): + print("Creating config.json from example...") + import shutil + shutil.copy2("config.json.example", "config.json") + print("✏️ Please edit config.json with your settings!") + + def clean_project(self): + """Clean up temporary files""" + print("🧹 Cleaning project...") + + if Path("scripts/clean.sh").exists(): + subprocess.run(["./scripts/clean.sh"]) + else: + # Python cleanup + print("Removing Python cache...") + self._remove_pycache() + + print("Removing temporary files...") + temp_patterns = ["*.tmp", "*.temp", "debug_*.html", "test_*.ppm"] + for pattern in temp_patterns: + for file in Path(".").glob(pattern): + file.unlink(missing_ok=True) + + print("Cleaning old logs...") + logs_dir = Path("logs") + if logs_dir.exists(): + for log_file in logs_dir.glob("*.log"): + # Remove logs older than 7 days + if time.time() - log_file.stat().st_mtime > 7 * 24 * 3600: + log_file.unlink(missing_ok=True) + + print("Removing backup files...") + for backup in Path(".").glob("*.backup"): + backup.unlink(missing_ok=True) + for backup in Path(".").glob("*.bak"): + backup.unlink(missing_ok=True) + + def _remove_pycache(self): + """Remove Python cache directories""" + for pycache in Path(".").rglob("__pycache__"): + if pycache.is_dir(): + import shutil + shutil.rmtree(pycache, ignore_errors=True) + + for pyc in Path(".").rglob("*.pyc"): + pyc.unlink(missing_ok=True) + + def run_tests(self): + """Run tests""" + print("🧪 Running tests...") + + tests_dir = Path("tests") + if tests_dir.exists(): + os.chdir(tests_dir) + result = subprocess.run([sys.executable, "-m", "pytest", "-v"]) + os.chdir(self.script_dir) + return result + else: + print("❌ No tests directory found") + return subprocess.CompletedProcess([], 1) + + def show_status(self): + """Show system status""" + print("📊 Sharey System Status") + print("=" * 30) + print(f"📁 Working directory: {os.getcwd()}") + print(f"🐍 Python version: {sys.version.split()[0]}") + + # Virtual environment check + venv_path = Path(".venv") + if venv_path.exists(): + print("📦 Virtual environment: ✅ Present") + else: + print("📦 Virtual environment: ❌ Missing") + + # Configuration check + config_path = Path("config.json") + if config_path.exists(): + print("⚙️ Configuration: ✅ Present") + try: + with open(config_path, 'r') as f: + config_data = json.load(f) + print(f" Host: {config_data.get('host', 'Not set')}") + print(f" Port: {config_data.get('port', 'Not set')}") + print(f" Debug: {config_data.get('debug', 'Not set')}") + except json.JSONDecodeError: + print(" ⚠️ Invalid JSON format") + else: + print("⚙️ Configuration: ❌ Missing") + + # Dependencies check + requirements_path = Path("requirements.txt") + if requirements_path.exists(): + print("📋 Dependencies file: ✅ Present") + else: + print("📋 Dependencies file: ❌ Missing") + + # Project structure + print("\n📂 Project structure:") + structure_dirs = ["src", "tests", "scripts", "docs", "logs"] + for dir_name in structure_dirs: + dir_path = Path(dir_name) + if dir_path.exists(): + print(f" {dir_name}/: ✅") + else: + print(f" {dir_name}/: ❌") + + def show_logs(self): + """Show recent logs""" + print("📜 Recent Sharey logs:") + + logs_dir = Path("logs") + if logs_dir.exists(): + log_files = list(logs_dir.glob("*.log")) + if log_files: + for log_file in sorted(log_files, key=lambda x: x.stat().st_mtime, reverse=True)[:3]: + print(f"\n--- {log_file.name} (last 10 lines) ---") + try: + with open(log_file, 'r') as f: + lines = f.readlines() + for line in lines[-10:]: + print(line.rstrip()) + except Exception as e: + print(f"Error reading {log_file}: {e}") + else: + print("No log files found") + else: + print("❌ No logs directory found") + + def install_service(self): + """Install Sharey as a system service (Linux)""" + print("🔧 Installing Sharey as a system service...") + + service_content = f"""[Unit] +Description=Sharey File Sharing Platform +After=network.target + +[Service] +Type=simple +User={os.getenv('USER')} +WorkingDirectory={os.getcwd()} +Environment=PATH={os.getcwd()}/.venv/bin +ExecStart={os.getcwd()}/.venv/bin/python {os.getcwd()}/run.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +""" + + service_file = Path("/tmp/sharey.service") + with open(service_file, 'w') as f: + f.write(service_content) + + print("Service file created. To install, run:") + print(f"sudo cp {service_file} /etc/systemd/system/") + print("sudo systemctl daemon-reload") + print("sudo systemctl enable sharey") + print("sudo systemctl start sharey") + +def main(): + parser = argparse.ArgumentParser( + description="Sharey - Command Line Utility", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Commands: + start Start the application (full checks) + dev Start in development mode (quick) + production Start in production mode with Gunicorn + setup Set up development environment + clean Clean up temporary files + test Run tests + status Show system status + logs Show recent logs + service Install as system service (Linux) + +Examples: + python sharey.py start + python sharey.py dev + python sharey.py production + python sharey.py status + """ + ) + + parser.add_argument('command', + choices=['start', 'dev', 'production', 'setup', 'clean', 'test', 'status', 'logs', 'service'], + help='Command to execute') + + if len(sys.argv) == 1: + parser.print_help() + return + + args = parser.parse_args() + manager = ShareyManager() + + # Execute command + try: + if args.command == 'start': + manager.print_banner() + result = manager.start_app(dev_mode=False) + elif args.command == 'dev': + manager.print_banner() + result = manager.start_app(dev_mode=True) + elif args.command == 'production': + manager.print_banner() + result = manager.start_app(production_mode=True) + elif args.command == 'setup': + manager.print_banner() + manager.setup_environment() + result = subprocess.CompletedProcess([], 0) + elif args.command == 'clean': + manager.print_banner() + manager.clean_project() + result = subprocess.CompletedProcess([], 0) + elif args.command == 'test': + manager.print_banner() + result = manager.run_tests() + elif args.command == 'status': + manager.print_banner() + manager.show_status() + result = subprocess.CompletedProcess([], 0) + elif args.command == 'logs': + manager.print_banner() + manager.show_logs() + result = subprocess.CompletedProcess([], 0) + elif args.command == 'service': + manager.print_banner() + manager.install_service() + result = subprocess.CompletedProcess([], 0) + + sys.exit(result.returncode if hasattr(result, 'returncode') else 0) + + except KeyboardInterrupt: + print("\n👋 Interrupted by user") + sys.exit(0) + except Exception as e: + print(f"❌ Error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/src/__pycache__/app.cpython-312.pyc b/src/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000..7acfa7a Binary files /dev/null and b/src/__pycache__/app.cpython-312.pyc differ diff --git a/src/__pycache__/config.cpython-312.pyc b/src/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000..ae85329 Binary files /dev/null and b/src/__pycache__/config.cpython-312.pyc differ diff --git a/src/__pycache__/storage.cpython-312.pyc b/src/__pycache__/storage.cpython-312.pyc new file mode 100644 index 0000000..956ebcb Binary files /dev/null and b/src/__pycache__/storage.cpython-312.pyc differ diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..32fdf70 --- /dev/null +++ b/src/app.py @@ -0,0 +1,695 @@ +import os +import random +import string +import uuid +import time +import hashlib +import re +from datetime import datetime, timedelta +from functools import wraps +from urllib.parse import urlparse +from flask import Flask, render_template, request, jsonify, redirect, url_for, send_from_directory, session, Response +from config import config +from storage import StorageManager +import sys +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from expiry_db import ExpiryDatabase + +# Flask app configuration +app = Flask(__name__) + +# Set Flask configuration from config +flask_config = config.get_flask_config() +app.config['MAX_CONTENT_LENGTH'] = config.get('upload.max_file_size_mb', 100) * 1024 * 1024 # Convert MB to bytes +app.secret_key = config.get('flask.secret_key', os.urandom(24)) # For session management + +# Admin helper functions +def hash_password(password): + """Hash a password using SHA-256""" + return hashlib.sha256(password.encode()).hexdigest() + +def verify_password(password, password_hash): + """Verify a password against its hash""" + return hash_password(password) == password_hash + +def require_admin(f): + """Decorator to require admin authentication""" + @wraps(f) + def decorated_function(*args, **kwargs): + admin_config = config.get('admin', {}) + if not admin_config.get('enabled', False): + return jsonify({'error': 'Admin panel is disabled'}), 404 + + if 'admin_logged_in' not in session: + return redirect(url_for('admin_login')) + + # Check session timeout + timeout_minutes = admin_config.get('session_timeout_minutes', 60) + if 'admin_login_time' in session: + login_time = datetime.fromisoformat(session['admin_login_time']) + if datetime.now() - login_time > timedelta(minutes=timeout_minutes): + session.clear() + return redirect(url_for('admin_login')) + + return f(*args, **kwargs) + return decorated_function + +# Maintenance mode decorator +def check_maintenance(f): + """Decorator to check if maintenance mode is enabled""" + @wraps(f) + def decorated_function(*args, **kwargs): + maintenance_config = config.get('maintenance', {}) + if maintenance_config.get('enabled', False): + # Allow health check even in maintenance mode + if request.endpoint == 'health': + return f(*args, **kwargs) + + # Show maintenance page for all other requests + return render_template('maintenance.html', + message=maintenance_config.get('message', 'Sharey is currently under maintenance.'), + estimated_return=maintenance_config.get('estimated_return', '')), 503 + return f(*args, **kwargs) + return decorated_function + +# Storage Configuration and Initialization +try: + # Validate storage configuration + if not config.validate_storage_config(): + storage_config = config.get_storage_config() + backend = storage_config.get('backend', 'unknown') + + if backend == 'b2': + print("❌ Invalid B2 configuration. Please check your config.json or environment variables.") + print("Required fields: B2_APPLICATION_KEY_ID, B2_APPLICATION_KEY, B2_BUCKET_NAME") + else: + print(f"❌ Invalid storage configuration for backend: {backend}") + + config.print_config() + exit(1) + + # Initialize storage manager + storage = StorageManager(config) + backend_info = storage.get_backend_info() + + if backend_info['type'] == 'b2': + print(f"✅ Connected to B2 bucket: {backend_info.get('bucket', 'unknown')}") + elif backend_info['type'] == 'local': + print(f"✅ Using local storage at: {backend_info.get('path', 'unknown')}") + + # Initialize expiry database + expiry_db = ExpiryDatabase() + +except Exception as e: + print(f"❌ Failed to initialize storage: {str(e)}") + print("Please check your storage configuration.") + config.print_config() + exit(1) + +# Function to generate a random 6-character string +def generate_short_id(length=6): + return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) + +# URL Shortener utility functions +def generate_short_code(length=6): + """Generate a random short code for URL shortener""" + characters = string.ascii_letters + string.digits + while True: + code = ''.join(random.choices(characters, k=length)) + # Make sure it doesn't conflict with existing content IDs + content_type, _ = detect_content_type(code) + if content_type is None and not expiry_db.get_redirect(code): + return code + +def is_valid_url(url): + """Validate if a URL is properly formatted""" + try: + result = urlparse(url) + return all([result.scheme, result.netloc]) and result.scheme in ['http', 'https'] + except: + return False + +def is_safe_url(url): + """Basic safety check for URLs (can be extended)""" + # Block localhost, private IPs, and suspicious TLDs + parsed = urlparse(url) + hostname = parsed.hostname + + if not hostname: + return False + + # Block localhost and private IPs + if hostname.lower() in ['localhost', '127.0.0.1', '0.0.0.0']: + return False + + # Block private IP ranges (basic check) + if hostname.startswith('192.168.') or hostname.startswith('10.') or hostname.startswith('172.'): + return False + + # Block suspicious patterns + suspicious_patterns = [ + r'bit\.ly', r'tinyurl', r'short\.link', r'malware', r'phishing' + ] + + for pattern in suspicious_patterns: + if re.search(pattern, url, re.IGNORECASE): + return False + + return True + +# Helper function to determine if an ID is a file or paste +def detect_content_type(content_id): + """ + Try to determine if an ID is a file or paste by checking storage + Returns: ('file', file_id) or ('paste', paste_id) or (None, None) + """ + print(f"🔍 Detecting content type for ID: {content_id}") + + # First try as file (check if it has an extension or exists in files/) + try: + # Try direct file access + storage.download_file(f"files/{content_id}") + print(f"✅ Found as file: files/{content_id}") + return 'file', content_id + except Exception as e: + print(f"❌ Not found as file: {e}") + + # Try as paste + try: + storage.download_file(f"pastes/{content_id}.txt") + print(f"✅ Found as paste: pastes/{content_id}.txt") + return 'paste', content_id + except Exception as e: + print(f"❌ Not found as paste: {e}") + + print(f"❌ Content not found: {content_id}") + return None, None + +# Main index route +@app.route('/') +@check_maintenance +def index(): + return render_template('index.html') + +# Clean URL route - handles redirects, files, and pastes at / +@app.route('/') +@check_maintenance +def get_content(content_id): + """ + Clean URL handler - checks for URL redirect first, then files/pastes + Examples: sharey.org/ABC123, sharey.org/XYZ789.png + """ + print(f"🔍 Processing request for ID: {content_id}") + + # First check if it's a URL redirect + redirect_url = expiry_db.get_redirect(content_id) + if redirect_url: + print(f"🔗 Redirecting {content_id} to {redirect_url}") + return redirect(redirect_url, code=302) + + # Not a redirect, try as file/paste + # Skip certain paths that should not be treated as content IDs + excluded_paths = ['admin', 'health', 'api', 'static', 'files', 'pastes', 'favicon.ico'] + if content_id in excluded_paths: + return redirect(url_for('index')) + + print(f"🔍 Clean URL accessed: {content_id}") + content_type, resolved_id = detect_content_type(content_id) + + if content_type == 'file': + print(f"📁 Detected as file: {resolved_id}") + return get_file(resolved_id) + elif content_type == 'paste': + print(f"📝 Detected as paste: {resolved_id}") + return view_paste(resolved_id) + else: + print(f"❌ Content not found: {content_id}") + return render_template('404.html', + title="Content Not Found", + message=f"The content '{content_id}' could not be found."), 404 + +# Health check endpoint +@app.route('/health') +def health_check(): + try: + # Test storage connection by trying to list files + files = storage.list_files() + backend_info = storage.get_backend_info() + return jsonify({ + 'status': 'healthy', + 'storage': f"{backend_info['type']}_connected", + 'backend': backend_info + }), 200 + except Exception as e: + return jsonify({'status': 'unhealthy', 'error': str(e)}), 500 + +# Configuration endpoint +@app.route('/api/config') +@check_maintenance +def get_config(): + """Get public configuration (non-sensitive)""" + return jsonify({ + 'upload': { + 'max_file_size_mb': config.get('upload.max_file_size_mb') + }, + 'paste': { + 'max_length': config.get('paste.max_length') + } + }) + +# File upload route +@app.route('/api/upload', methods=['POST']) +@check_maintenance +def upload_file(): + print(f"📤 Upload request received") + + files = request.files.getlist('files[]') + expires_at = request.form.get('expires_at') # Get expiry from form data + + if not files or not any(file.filename for file in files): + return jsonify({'error': 'No files provided'}), 400 + + file_urls = [] + + for file in files: + if file and file.filename: + print(f"📁 Processing file: {file.filename}") + + # Generate a 6-character alphanumeric string for the file ID + short_id = generate_short_id() + + # Get the file extension + file_extension = os.path.splitext(file.filename)[1] + + # Create the file ID with extension + file_id = f"{short_id}{file_extension}" + + # Upload file to storage + try: + file_content = file.read() + file_size = len(file_content) + print(f"📊 File size: {file_size} bytes ({file_size/1024/1024:.2f} MB)") + + if file_size == 0: + return jsonify({'error': 'Empty file uploaded'}), 400 + + backend_info = storage.get_backend_info() + print(f"⬆️ Starting {backend_info['type']} upload for {file_id}") + start_time = time.time() + + # Prepare metadata + metadata = { + 'uploaded_at': datetime.now().isoformat(), + 'original_name': file.filename, + 'content_type': file.content_type or 'application/octet-stream' + } + + # Add expiry metadata if provided + if expires_at: + print(f"⏰ File expiry set to: {expires_at}") + + success = storage.upload_file( + file_content=file_content, + file_path=f"files/{file_id}", + content_type=file.content_type or 'application/octet-stream' + ) + + if not success: + return jsonify({'error': 'Upload failed'}), 500 + + # Add to expiry database if expiry time is provided + if expires_at: + backend_type = backend_info['type'] + expiry_db.add_file(f"files/{file_id}", expires_at, backend_type) + + upload_time = time.time() - start_time + print(f"✅ {backend_info['type']} upload successful for {file_id} in {upload_time:.2f} seconds") + + # Use clean URL format: sharey.org/ABC123.png (new format) + download_url = url_for('get_content', content_id=file_id, _external=True) + file_urls.append(download_url) + print(f"🔗 Generated clean URL: {download_url}") + + except Exception as e: + print(f"❌ Upload failed for {file.filename}: {str(e)}") + return jsonify({'error': f'Failed to upload file: {str(e)}'}), 500 + + print(f"🎉 All uploads completed successfully") + return jsonify({'urls': file_urls}), 201 + +# Legacy file route (for backward compatibility) +# Old format: /files/ABC123.png +@app.route('/files/', methods=['GET']) +@check_maintenance +def get_file(file_id): + try: + # Download file from storage and serve it through Flask + file_content = storage.download_file(f"files/{file_id}") + + # Get the content type from the original upload or guess it + content_type = 'application/octet-stream' + file_extension = os.path.splitext(file_id)[1].lower() + + # Simple content type mapping + content_types = { + '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', + '.gif': 'image/gif', '.pdf': 'application/pdf', '.txt': 'text/plain', + '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', '.zip': 'application/zip' + } + content_type = content_types.get(file_extension, 'application/octet-stream') + + # Return the file content with proper headers + return Response( + file_content, + mimetype=content_type, + headers={ + 'Content-Disposition': f'inline; filename="{file_id}"', + 'Cache-Control': 'public, max-age=3600' + } + ) + + except Exception as e: + return jsonify({'error': f'File not found: {str(e)}'}), 404 + +# Paste creation route +@app.route('/api/paste', methods=['POST']) +@check_maintenance +def create_paste(): + data = request.get_json() + content = data.get('content') + expires_at = data.get('expires_at') # Get expiry from request data + + if not content: + return jsonify({'error': 'Content cannot be empty'}), 400 + + # Check paste length limit + max_length = config.get('paste.max_length', 1000000) + if len(content) > max_length: + return jsonify({ + 'error': f'Paste too long. Maximum length: {max_length} characters' + }), 400 + + unique_id = str(uuid.uuid4()) + paste_id = unique_id[:6] # Shorten the UUID for pastes + + try: + # Prepare metadata + metadata = { + 'uploaded_at': datetime.now().isoformat(), + 'content_type': 'text/plain; charset=utf-8', + 'content_length': len(content) + } + + # Add expiry metadata if provided + if expires_at: + metadata['expires_at'] = expires_at + print(f"⏰ Paste expiry set to: {expires_at}") + + # Upload paste to storage + success = storage.upload_file( + file_content=content.encode('utf-8'), + file_path=f"pastes/{paste_id}.txt", + content_type='text/plain; charset=utf-8', + metadata=metadata + ) + + if not success: + return jsonify({'error': 'Failed to create paste'}), 500 + + # Use clean URL format: sharey.org/ABC123 (new format) + paste_url = url_for('get_content', content_id=paste_id, _external=True) + return jsonify({'url': paste_url}), 201 + + except Exception as e: + return jsonify({'error': f'Failed to create paste: {str(e)}'}), 500 + +# Legacy paste routes (for backward compatibility) +# Old format: /pastes/ABC123 +@app.route('/pastes/', methods=['GET']) +@check_maintenance +def view_paste(paste_id): + try: + # Download paste content from storage + content_bytes = storage.download_file(f"pastes/{paste_id}.txt") + content = content_bytes.decode('utf-8') + return render_template('view_paste.html', content=content, paste_id=paste_id) + except Exception as e: + return jsonify({'error': 'Paste not found'}), 404 + +@app.route('/pastes/raw/', methods=['GET']) +@check_maintenance +def view_paste_raw(paste_id): + try: + # Download paste content from storage + content_bytes = storage.download_file(f"pastes/{paste_id}.txt") + content = content_bytes.decode('utf-8') + return content, 200, {'Content-Type': 'text/plain; charset=utf-8'} + except Exception as e: + return jsonify({'error': 'Paste not found'}), 404 + +# URL Shortener API Routes +@app.route('/api/shorten', methods=['POST']) +@check_maintenance +def create_redirect(): + """Create a new URL redirect""" + try: + data = request.get_json() + + if not data or 'url' not in data: + return jsonify({'error': 'URL is required'}), 400 + + target_url = data['url'].strip() + custom_code = data.get('code', '').strip() + expires_in_hours = data.get('expires_in_hours') + + # Validate URL + if not is_valid_url(target_url): + return jsonify({'error': 'Invalid URL format'}), 400 + + if not is_safe_url(target_url): + return jsonify({'error': 'URL not allowed'}), 400 + + # Generate or validate short code + if custom_code: + # Custom code provided + if len(custom_code) < 3 or len(custom_code) > 20: + return jsonify({'error': 'Custom code must be 3-20 characters'}), 400 + + if not re.match(r'^[a-zA-Z0-9_-]+$', custom_code): + return jsonify({'error': 'Custom code can only contain letters, numbers, hyphens, and underscores'}), 400 + + # Check if code already exists + if expiry_db.get_redirect(custom_code) or detect_content_type(custom_code)[0]: + return jsonify({'error': 'Code already exists'}), 400 + + short_code = custom_code + else: + # Generate random code + short_code = generate_short_code() + + # Calculate expiry + expires_at = None + if expires_in_hours and expires_in_hours > 0: + expires_at = (datetime.utcnow() + timedelta(hours=expires_in_hours)).isoformat() + 'Z' + + # Get client IP for logging + client_ip = request.headers.get('X-Forwarded-For', request.remote_addr) + + # Create redirect + success = expiry_db.add_redirect( + short_code=short_code, + target_url=target_url, + expires_at=expires_at, + created_by_ip=client_ip + ) + + if not success: + return jsonify({'error': 'Failed to create redirect'}), 500 + + # Return short URL + short_url = url_for('get_content', content_id=short_code, _external=True) + + return jsonify({ + 'short_url': short_url, + 'short_code': short_code, + 'target_url': target_url, + 'expires_at': expires_at + }), 201 + + except Exception as e: + return jsonify({'error': f'Failed to create redirect: {str(e)}'}), 500 + +@app.route('/api/redirect//info', methods=['GET']) +@check_maintenance +def redirect_info(short_code): + """Get information about a redirect (for preview)""" + try: + import sqlite3 + + # Get redirect info without incrementing click count + conn = sqlite3.connect(expiry_db.db_path) + cursor = conn.cursor() + + cursor.execute(''' + SELECT target_url, created_at, expires_at, click_count, is_active + FROM url_redirects + WHERE short_code = ? + ''', (short_code,)) + + result = cursor.fetchone() + conn.close() + + if not result: + return jsonify({'error': 'Redirect not found'}), 404 + + target_url, created_at, expires_at, click_count, is_active = result + + # Check if expired + is_expired = False + if expires_at: + current_time = datetime.utcnow().isoformat() + 'Z' + is_expired = expires_at <= current_time + + return jsonify({ + 'short_code': short_code, + 'target_url': target_url, + 'created_at': created_at, + 'expires_at': expires_at, + 'click_count': click_count, + 'is_active': bool(is_active), + 'is_expired': is_expired + }) + + except Exception as e: + return jsonify({'error': f'Failed to get redirect info: {str(e)}'}), 500 + +# Admin Panel Routes +@app.route('/admin') +@require_admin +def admin_panel(): + """Admin panel dashboard""" + admin_config = config.get('admin', {}) + maintenance_config = config.get('maintenance', {}) + + return render_template('admin.html', + maintenance_enabled=maintenance_config.get('enabled', False), + maintenance_message=maintenance_config.get('message', ''), + estimated_return=maintenance_config.get('estimated_return', ''), + config=config.config + ) + +@app.route('/admin/login', methods=['GET', 'POST']) +def admin_login(): + """Admin login page""" + admin_config = config.get('admin', {}) + + if not admin_config.get('enabled', False): + return jsonify({'error': 'Admin panel is disabled'}), 404 + + if not admin_config.get('password_hash'): + return render_template('admin_login.html', + error='Admin password not configured. Please set admin.password_hash in config.json') + + if request.method == 'POST': + password = request.form.get('password') + if password and verify_password(password, admin_config['password_hash']): + session['admin_logged_in'] = True + session['admin_login_time'] = datetime.now().isoformat() + return redirect(url_for('admin_panel')) + else: + return render_template('admin_login.html', error='Invalid password') + + return render_template('admin_login.html') + +@app.route('/admin/logout') +def admin_logout(): + """Admin logout""" + session.clear() + return redirect(url_for('admin_login')) + +@app.route('/admin/maintenance', methods=['POST']) +@require_admin +def admin_maintenance(): + """Toggle maintenance mode""" + action = request.form.get('action') + message = request.form.get('maintenance_message', '') + estimated_return = request.form.get('estimated_return', '') + + current_config = config.config.copy() + + if action == 'enable': + current_config['maintenance']['enabled'] = True + current_config['maintenance']['message'] = message + current_config['maintenance']['estimated_return'] = estimated_return + elif action == 'disable': + current_config['maintenance']['enabled'] = False + + # Save the updated configuration + if config.save_config(current_config): + # Reload config to reflect changes + config.config = config._load_config() + + return redirect(url_for('admin_panel')) + +@app.route('/admin/config', methods=['POST']) +@require_admin +def admin_config(): + """Update configuration""" + max_file_size = request.form.get('max_file_size') + max_paste_length = request.form.get('max_paste_length') + + current_config = config.config.copy() + + if max_file_size: + try: + current_config['upload']['max_file_size_mb'] = int(max_file_size) + except ValueError: + pass + + if max_paste_length: + try: + current_config['paste']['max_length'] = int(max_paste_length) + except ValueError: + pass + + # Save the updated configuration + if config.save_config(current_config): + # Reload config to reflect changes + config.config = config._load_config() + # Update Flask config + app.config['MAX_CONTENT_LENGTH'] = config.get('upload.max_file_size_mb', 100) * 1024 * 1024 + + return redirect(url_for('admin_panel')) + +@app.route('/admin/urls') +@require_admin +def admin_urls(): + """Admin page for managing URL redirects""" + redirects = expiry_db.list_redirects(limit=100) + return render_template('admin.html', + tab='urls', + redirects=redirects) + +@app.route('/admin/urls/disable/', methods=['POST']) +@require_admin +def admin_disable_redirect(short_code): + """Disable a URL redirect""" + success = expiry_db.disable_redirect(short_code) + if success: + return jsonify({'success': True, 'message': f'Redirect {short_code} disabled'}) + else: + return jsonify({'success': False, 'message': 'Failed to disable redirect'}), 404 + +# Error handling for 404 routes +@app.errorhandler(404) +def page_not_found(e): + return jsonify({'error': 'Page not found'}), 404 + +# Run the Flask app +if __name__ == '__main__': + flask_config = config.get_flask_config() + app.run( + host=flask_config['host'], + port=flask_config['port'], + debug=flask_config['debug'] + ) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..1f09fba --- /dev/null +++ b/src/config.py @@ -0,0 +1,187 @@ +""" +Configuration management for Sharey +Supports both config.json and environment variables +""" +import os +import json +from typing import Dict, Any, Optional + +class Config: + """Configuration handler for Sharey""" + + def __init__(self, config_file: str = "config.json"): + self.config_file = config_file + self.config = self._load_config() + + def _load_config(self) -> Dict[str, Any]: + """Load configuration from JSON file with environment variable fallbacks""" + config = {} + + # Try to load from JSON file first + if os.path.exists(self.config_file): + try: + with open(self.config_file, 'r') as f: + config = json.load(f) + print(f"✅ Loaded configuration from {self.config_file}") + except (json.JSONDecodeError, FileNotFoundError) as e: + print(f"⚠️ Error loading {self.config_file}: {e}") + print("Falling back to environment variables...") + else: + print(f"⚠️ {self.config_file} not found, using environment variables") + + # Set defaults and apply environment variable overrides + config = self._apply_defaults_and_env(config) + + return config + + def _apply_defaults_and_env(self, config: Dict[str, Any]) -> Dict[str, Any]: + """Apply default values and environment variable overrides""" + + # Storage Configuration + storage_config = config.get('storage', {}) + storage_config['backend'] = storage_config.get('backend', os.getenv('STORAGE_BACKEND', 'b2')) + storage_config['local_path'] = storage_config.get('local_path', os.getenv('STORAGE_LOCAL_PATH', 'storage')) + config['storage'] = storage_config + + # B2 Configuration + b2_config = config.get('b2', {}) + b2_config['application_key_id'] = ( + b2_config.get('application_key_id') or + os.getenv('B2_APPLICATION_KEY_ID', 'your_key_id_here') + ) + b2_config['application_key'] = ( + b2_config.get('application_key') or + os.getenv('B2_APPLICATION_KEY', 'your_application_key_here') + ) + b2_config['bucket_name'] = ( + b2_config.get('bucket_name') or + os.getenv('B2_BUCKET_NAME', 'your_bucket_name_here') + ) + config['b2'] = b2_config + + # Flask Configuration + flask_config = config.get('flask', {}) + flask_config['host'] = flask_config.get('host', os.getenv('FLASK_HOST', '127.0.0.1')) + flask_config['port'] = int(flask_config.get('port', os.getenv('FLASK_PORT', 8866))) + flask_config['debug'] = flask_config.get('debug', os.getenv('FLASK_DEBUG', 'true').lower() == 'true') + config['flask'] = flask_config + + # Upload Configuration + upload_config = config.get('upload', {}) + upload_config['max_file_size_mb'] = upload_config.get('max_file_size_mb', 100) + config['upload'] = upload_config + + # Paste Configuration + paste_config = config.get('paste', {}) + paste_config['max_length'] = paste_config.get('max_length', 1000000) + config['paste'] = paste_config + + # Security Configuration + security_config = config.get('security', {}) + security_config['rate_limit_enabled'] = security_config.get('rate_limit_enabled', False) + security_config['max_uploads_per_hour'] = security_config.get('max_uploads_per_hour', 50) + config['security'] = security_config + + return config + + def get(self, key: str, default: Any = None) -> Any: + """Get configuration value using dot notation (e.g., 'b2.bucket_name')""" + keys = key.split('.') + value = self.config + + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + + return value + + def validate_b2_config(self) -> bool: + """Validate B2 configuration""" + required_fields = ['application_key_id', 'application_key', 'bucket_name'] + invalid_values = ['your_key_id_here', 'your_application_key_here', 'your_bucket_name_here', ''] + + for field in required_fields: + value = self.get(f'b2.{field}') + if not value or value in invalid_values: + return False + + return True + + def validate_storage_config(self) -> bool: + """Validate storage configuration""" + backend = self.get('storage.backend', 'b2').lower() + + if backend == 'b2': + return self.validate_b2_config() + elif backend == 'local': + # Local storage is always valid (will create directory if needed) + return True + else: + print(f"❌ Unknown storage backend: {backend}") + return False + + def get_storage_config(self) -> Dict[str, Any]: + """Get storage configuration as dictionary""" + return { + 'backend': self.get('storage.backend', 'b2'), + 'local_path': self.get('storage.local_path', 'storage') + } + + def get_b2_config(self) -> Dict[str, str]: + """Get B2 configuration as dictionary""" + return { + 'key_id': self.get('b2.application_key_id'), + 'key': self.get('b2.application_key'), + 'bucket_name': self.get('b2.bucket_name') + } + + def get_flask_config(self) -> Dict[str, Any]: + """Get Flask configuration as dictionary""" + return { + 'host': self.get('flask.host'), + 'port': self.get('flask.port'), + 'debug': self.get('flask.debug') + } + + def save_config(self, config_dict: Dict[str, Any], backup: bool = True) -> bool: + """Save configuration to JSON file""" + try: + # Create backup if requested + if backup and os.path.exists(self.config_file): + backup_file = f"{self.config_file}.backup" + os.rename(self.config_file, backup_file) + print(f"💾 Created backup: {backup_file}") + + # Save new configuration + with open(self.config_file, 'w') as f: + json.dump(config_dict, f, indent=2) + + print(f"✅ Configuration saved to {self.config_file}") + return True + + except Exception as e: + print(f"❌ Error saving configuration: {e}") + return False + + def print_config(self, hide_secrets: bool = True) -> None: + """Print current configuration""" + print("\n📋 Current Configuration:") + print("=" * 50) + + config_copy = json.loads(json.dumps(self.config)) # Deep copy + + if hide_secrets: + # Hide sensitive information + if 'b2' in config_copy: + if 'application_key_id' in config_copy['b2']: + config_copy['b2']['application_key_id'] = '***HIDDEN***' + if 'application_key' in config_copy['b2']: + config_copy['b2']['application_key'] = '***HIDDEN***' + + print(json.dumps(config_copy, indent=2)) + print("=" * 50) + +# Global configuration instance +config = Config() diff --git a/src/config_util.py b/src/config_util.py new file mode 100644 index 0000000..7cbc94b --- /dev/null +++ b/src/config_util.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +""" +Configuration Management Utility for Sharey +""" +import sys +import json +import hashlib +import getpass +from config import config + +def show_config(): + """Display current configuration""" + config.print_config(hide_secrets=False) + +def validate_config(): + """Validate current configuration""" + print("🔧 Validating configuration...") + + # Check storage configuration + if config.validate_storage_config(): + storage_config = config.get_storage_config() + backend = storage_config.get('backend', 'unknown') + print(f"✅ Storage configuration is valid (backend: {backend})") + + if backend == 'b2': + b2_config = config.get_b2_config() + print(f" → B2 bucket: {b2_config.get('bucket_name', 'unknown')}") + elif backend == 'local': + local_path = storage_config.get('local_path', 'storage') + print(f" → Local path: {local_path}") + else: + print("❌ Storage configuration is invalid") + return False + + # Check other configurations + max_size = config.get('upload.max_file_size_mb') + if max_size and max_size > 0: + print(f"✅ Upload max size: {max_size} MB") + else: + print("⚠️ Upload max size not configured") + + max_length = config.get('paste.max_length') + if max_length and max_length > 0: + print(f"✅ Paste max length: {max_length:,} characters") + else: + print("⚠️ Paste max length not configured") + + print("✅ Configuration validation complete") + return True + +def set_config(): + """Interactively set configuration values""" + print("🔧 Interactive Configuration Setup") + print("=" * 50) + + current_config = config.config.copy() + + # Storage Configuration + print("\n💾 Storage Configuration:") + storage_backend = input(f"Storage backend (b2/local) [{current_config.get('storage', {}).get('backend', 'b2')}]: ").strip().lower() + if storage_backend in ['b2', 'local']: + if 'storage' not in current_config: + current_config['storage'] = {} + current_config['storage']['backend'] = storage_backend + + if storage_backend == 'local': + local_path = input(f"Local storage path [{current_config.get('storage', {}).get('local_path', 'storage')}]: ").strip() + if local_path: + current_config['storage']['local_path'] = local_path + + # B2 Configuration (only if using B2 backend) + if current_config.get('storage', {}).get('backend', 'b2') == 'b2': + print("\n📦 Backblaze B2 Configuration:") + current_config['b2']['application_key_id'] = input(f"Application Key ID [{current_config['b2'].get('application_key_id', '')}]: ").strip() or current_config['b2'].get('application_key_id', '') + current_config['b2']['application_key'] = input(f"Application Key [{current_config['b2'].get('application_key', '')}]: ").strip() or current_config['b2'].get('application_key', '') + current_config['b2']['bucket_name'] = input(f"Bucket Name [{current_config['b2'].get('bucket_name', '')}]: ").strip() or current_config['b2'].get('bucket_name', '') + + # Flask Configuration + print("\n🌐 Flask Configuration:") + current_config['flask']['host'] = input(f"Host [{current_config['flask'].get('host', '127.0.0.1')}]: ").strip() or current_config['flask'].get('host', '127.0.0.1') + port_input = input(f"Port [{current_config['flask'].get('port', 8866)}]: ").strip() + if port_input: + try: + current_config['flask']['port'] = int(port_input) + except ValueError: + print("⚠️ Invalid port, keeping current value") + + debug_input = input(f"Debug mode (true/false) [{current_config['flask'].get('debug', True)}]: ").strip().lower() + if debug_input in ['true', 'false']: + current_config['flask']['debug'] = debug_input == 'true' + + # Upload Configuration + print("\n📁 Upload Configuration:") + size_input = input(f"Max file size (MB) [{current_config['upload'].get('max_file_size_mb', 100)}]: ").strip() + if size_input: + try: + current_config['upload']['max_file_size_mb'] = int(size_input) + except ValueError: + print("⚠️ Invalid size, keeping current value") + + # Paste Configuration + print("\n📝 Paste Configuration:") + length_input = input(f"Max paste length [{current_config['paste'].get('max_length', 1000000)}]: ").strip() + if length_input: + try: + current_config['paste']['max_length'] = int(length_input) + except ValueError: + print("⚠️ Invalid length, keeping current value") + + # Save configuration + if config.save_config(current_config): + print("\n✅ Configuration saved successfully!") + return True + else: + print("\n❌ Failed to save configuration") + return False + +def reset_config(): + """Reset configuration to defaults""" + print("🔄 Resetting configuration to defaults...") + + default_config = { + "b2": { + "application_key_id": "your_key_id_here", + "application_key": "your_application_key_here", + "bucket_name": "your_bucket_name_here" + }, + "flask": { + "host": "127.0.0.1", + "port": 8866, + "debug": True + }, + "upload": { + "max_file_size_mb": 100 + }, + "paste": { + "max_length": 1000000 + }, + "security": { + "rate_limit_enabled": False, + "max_uploads_per_hour": 50 + } + } + + if config.save_config(default_config): + print("✅ Configuration reset to defaults") + return True + else: + print("❌ Failed to reset configuration") + return False + +def enable_maintenance(): + """Enable maintenance mode""" + print("🔧 Enabling maintenance mode...") + + current_config = config.config.copy() + + # Ensure maintenance section exists + if 'maintenance' not in current_config: + current_config['maintenance'] = {} + + current_config['maintenance']['enabled'] = True + + # Ask for custom message + message = input("Maintenance message [Sharey is currently under maintenance. Please check back later!]: ").strip() + if message: + current_config['maintenance']['message'] = message + + # Ask for estimated return time + return_time = input("Estimated return time (optional): ").strip() + if return_time: + current_config['maintenance']['estimated_return'] = return_time + + # Save configuration + if config.save_config(current_config): + print("✅ Maintenance mode enabled!") + print("💡 Users will now see the maintenance page") + print("💡 To disable: python config_util.py maintenance disable") + else: + print("❌ Failed to enable maintenance mode") + +def disable_maintenance(): + """Disable maintenance mode""" + print("🔧 Disabling maintenance mode...") + + current_config = config.config.copy() + + # Ensure maintenance section exists + if 'maintenance' not in current_config: + current_config['maintenance'] = {} + + current_config['maintenance']['enabled'] = False + + # Save configuration + if config.save_config(current_config): + print("✅ Maintenance mode disabled!") + print("💡 Sharey is now accessible to users") + else: + print("❌ Failed to disable maintenance mode") + +def maintenance_status(): + """Show maintenance mode status""" + maintenance_config = config.get('maintenance', {}) + enabled = maintenance_config.get('enabled', False) + + print("🔧 Maintenance Mode Status") + print("=" * 30) + + if enabled: + print("🟠 Status: ENABLED") + print(f"📝 Message: {maintenance_config.get('message', 'Default message')}") + if maintenance_config.get('estimated_return'): + print(f"⏰ Estimated return: {maintenance_config['estimated_return']}") + print("\n💡 To disable: python config_util.py maintenance disable") + else: + print("🟢 Status: DISABLED") + print("💡 Sharey is accessible to users") + print("💡 To enable: python config_util.py maintenance enable") + +def handle_maintenance_command(): + """Handle maintenance mode subcommands""" + if len(sys.argv) < 3: + print("Usage: python config_util.py maintenance ") + print("Commands:") + print(" enable - Enable maintenance mode") + print(" disable - Disable maintenance mode") + print(" status - Show current maintenance status") + return + + subcommand = sys.argv[2].lower() + + if subcommand == 'enable': + enable_maintenance() + elif subcommand == 'disable': + disable_maintenance() + elif subcommand == 'status': + maintenance_status() + else: + print(f"Unknown maintenance command: {subcommand}") + +def set_admin_password(): + """Set admin panel password""" + print("🔐 Setting Admin Panel Password") + print("=" * 35) + + # Get password with confirmation + while True: + password = getpass.getpass("Enter admin password: ") + if len(password) < 6: + print("❌ Password must be at least 6 characters long") + continue + + confirm = getpass.getpass("Confirm admin password: ") + if password != confirm: + print("❌ Passwords don't match") + continue + + break + + # Hash the password + password_hash = hashlib.sha256(password.encode()).hexdigest() + + # Update config + current_config = config.config.copy() + + # Ensure admin section exists + if 'admin' not in current_config: + current_config['admin'] = {} + + current_config['admin']['password_hash'] = password_hash + current_config['admin']['session_timeout_minutes'] = current_config['admin'].get('session_timeout_minutes', 30) + + # Save configuration + if config.save_config(current_config): + print("✅ Admin password set successfully!") + print("💡 You can now access the admin panel at /admin") + else: + print("❌ Failed to save admin password") + +def admin_status(): + """Show admin panel configuration status""" + admin_config = config.get('admin', {}) + + print("🔐 Admin Panel Status") + print("=" * 25) + + if admin_config.get('password_hash'): + print("🟢 Status: CONFIGURED") + print(f"⏰ Session timeout: {admin_config.get('session_timeout_minutes', 30)} minutes") + print("💡 Access at: /admin") + print("💡 To change password: python config_util.py admin password") + else: + print("🟠 Status: NOT CONFIGURED") + print("💡 To set password: python config_util.py admin password") + +def handle_admin_command(): + """Handle admin panel subcommands""" + if len(sys.argv) < 3: + print("Usage: python config_util.py admin ") + print("Commands:") + print(" password - Set admin panel password") + print(" status - Show admin panel configuration status") + return + + subcommand = sys.argv[2].lower() + + if subcommand == 'password': + set_admin_password() + elif subcommand == 'status': + admin_status() + else: + print(f"Unknown admin command: {subcommand}") + +def main(): + """Main configuration utility""" + if len(sys.argv) < 2: + print("Sharey Configuration Utility") + print("Usage: python config_util.py ") + print("\nCommands:") + print(" show - Display current configuration") + print(" validate - Validate configuration") + print(" set - Interactively set configuration") + print(" reset - Reset configuration to defaults") + print(" maintenance - Manage maintenance mode (enable/disable/status)") + print(" admin - Manage admin panel (password/status)") + sys.exit(1) + + command = sys.argv[1].lower() + + if command == 'show': + show_config() + elif command == 'validate': + validate_config() + elif command == 'set': + set_config() + elif command == 'reset': + reset_config() + elif command == 'maintenance': + handle_maintenance_command() + elif command == 'admin': + handle_admin_command() + else: + print(f"Unknown command: {command}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/src/static/image-preview.js b/src/static/image-preview.js new file mode 100644 index 0000000..6d48ba2 --- /dev/null +++ b/src/static/image-preview.js @@ -0,0 +1,39 @@ +// Image preview functionality for Sharey +// Helper function to check if file is an image +function isImageFile(file) { + const imageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/bmp']; + return imageTypes.includes(file.type.toLowerCase()); +} + +// Helper function to create image preview +function createImagePreview(fileUrl, fileName) { + const preview = document.createElement('div'); + preview.className = 'image-preview'; + preview.style.cssText = ` + margin: 15px 0; + text-align: center; + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 15px; + background: #f9f9f9; + `; + + const img = document.createElement('img'); + img.src = fileUrl; + img.alt = fileName; + img.style.cssText = ` + max-width: 100%; + max-height: 300px; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + object-fit: contain; + `; + + // Handle image load errors + img.onerror = () => { + preview.innerHTML = '

📷 Image preview not available

'; + }; + + preview.appendChild(img); + return preview; +} diff --git a/src/static/script.js b/src/static/script.js new file mode 100644 index 0000000..6fccbcb --- /dev/null +++ b/src/static/script.js @@ -0,0 +1,1305 @@ +document.addEventListener("DOMContentLoaded", function() { + console.log('🔥 Script.js loaded!'); + + // Theme management + initializeTheme(); + + const dropArea = document.getElementById('dropArea'); + const fileInput = document.getElementById('fileInput'); + const progressContainer = document.getElementById('progressContainer'); + const progressBar = document.getElementById('progressBar'); + const progressText = document.getElementById('progressText'); + const result = document.getElementById('result'); + + const fileModeButton = document.getElementById('fileMode'); + const pasteModeButton = document.getElementById('pasteMode'); + const urlModeButton = document.getElementById('urlMode'); + const faqButton = document.getElementById('faqButton'); + + const fileSharingSection = document.getElementById('fileSharingSection'); + const pastebinSection = document.getElementById('pastebinSection'); + const urlShortenerSection = document.getElementById('urlShortenerSection'); + const faqSection = document.getElementById('faqSection'); + + const pasteContent = document.getElementById('pasteContent'); + const submitPasteButton = document.getElementById('submitPaste'); + const pasteResult = document.getElementById('pasteResult'); + + // URL Shortener elements + const urlInput = document.getElementById('urlInput'); + const customCode = document.getElementById('customCode'); + const urlExpiry = document.getElementById('urlExpiry'); + const shortenUrlButton = document.getElementById('shortenUrl'); + const urlResult = document.getElementById('urlResult'); + const urlPreviewContainer = document.getElementById('urlPreviewContainer'); + + // Mobile clipboard elements + const mobileClipboardSection = document.getElementById('mobileClipboardSection'); + const mobilePasteButton = document.getElementById('mobilePasteButton'); + + let filesToUpload = []; + let currentMode = 'file'; // Track current mode: 'file', 'paste', or 'faq' + + // Expiry elements + const expirySelect = document.getElementById('expirySelect'); + const customExpiry = document.getElementById('customExpiry'); + const pasteExpirySelect = document.getElementById('pasteExpirySelect'); + const customPasteExpiry = document.getElementById('customPasteExpiry'); + + // Helper function to check if file is an image + function isImageFile(file) { + const imageTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/bmp']; + return imageTypes.includes(file.type.toLowerCase()); + } + + // Toggle between file sharing, pastebin, and FAQ sections + fileModeButton.addEventListener('click', () => { + currentMode = 'file'; + showSection(fileSharingSection); + hideSection(pastebinSection); + hideSection(faqSection); + activateButton(fileModeButton); + }); + + pasteModeButton.addEventListener('click', () => { + currentMode = 'paste'; + showSection(pastebinSection); + hideSection(fileSharingSection); + hideSection(urlShortenerSection); + hideSection(faqSection); + activateButton(pasteModeButton); + }); + + urlModeButton.addEventListener('click', () => { + currentMode = 'url'; + showSection(urlShortenerSection); + hideSection(fileSharingSection); + hideSection(pastebinSection); + hideSection(faqSection); + activateButton(urlModeButton); + + // Initialize URL shortener after section is visible + initializeUrlShortener(); + }); + + faqButton.addEventListener('click', () => { + currentMode = 'faq'; + showSection(faqSection); + hideSection(fileSharingSection); + hideSection(pastebinSection); + hideSection(urlShortenerSection); + activateButton(faqButton); + }); + + // Helper function to show a section + function showSection(section) { + section.style.display = 'block'; + } + + // Helper function to hide a section + function hideSection(section) { + section.style.display = 'none'; + } + + // Helper function to activate a button + function activateButton(button) { + const buttons = [fileModeButton, pasteModeButton, urlModeButton, faqButton]; + buttons.forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + } + + // Make drop area clickable + dropArea.addEventListener('click', () => { + fileInput.click(); + }); + + // Expiry selection handlers + if (expirySelect) { + expirySelect.addEventListener('change', (e) => { + if (e.target.value === 'custom') { + customExpiry.style.display = 'block'; + customExpiry.focus(); + } else { + customExpiry.style.display = 'none'; + } + }); + } + + if (pasteExpirySelect) { + pasteExpirySelect.addEventListener('change', (e) => { + if (e.target.value === 'custom') { + customPasteExpiry.style.display = 'block'; + customPasteExpiry.focus(); + } else { + customPasteExpiry.style.display = 'none'; + } + }); + } + + // Helper function to calculate expiry datetime + function calculateExpiryTime(expiryValue, customValue = null) { + if (expiryValue === 'never') return null; + if (expiryValue === 'custom' && customValue) { + return new Date(customValue).toISOString(); + } + + const now = new Date(); + switch (expiryValue) { + case '1h': return new Date(now.getTime() + 60 * 60 * 1000).toISOString(); + case '24h': return new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + case '7d': return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(); + case '30d': return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(); + case '90d': return new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000).toISOString(); + default: return null; + } + } + + // Drag-and-drop events + dropArea.addEventListener('dragover', (event) => { + event.preventDefault(); + dropArea.classList.add('dragging'); + }); + + dropArea.addEventListener('dragleave', () => { + dropArea.classList.remove('dragging'); + }); + + dropArea.addEventListener('drop', (event) => { + event.preventDefault(); + dropArea.classList.remove('dragging'); + + filesToUpload = event.dataTransfer.files; + displaySelectedFiles(filesToUpload); + handleFileUpload(filesToUpload); + }); + + // Handle file input selection + fileInput.addEventListener('change', (event) => { + filesToUpload = event.target.files; + displaySelectedFiles(filesToUpload); + handleFileUpload(filesToUpload); + }); + + // Reusable clipboard paste function + async function handleClipboardPaste() { + console.log('📱 Clipboard paste triggered'); + + try { + // Try to read from clipboard using the Clipboard API + if (navigator.clipboard && navigator.clipboard.read) { + showToast('📋 Checking clipboard...', 'info', 2000); + + const clipboardItems = await navigator.clipboard.read(); + const files = []; + let hasText = false; + let textContent = ''; + + for (const clipboardItem of clipboardItems) { + for (const type of clipboardItem.types) { + if (type.startsWith('image/')) { + const blob = await clipboardItem.getType(type); + // Create a file from the blob + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + const fileExtension = type.split('/')[1] || 'png'; + const file = new File([blob], `clipboard-image-${timestamp}.${fileExtension}`, { type }); + files.push(file); + } else if (type === 'text/plain') { + hasText = true; + textContent = await clipboardItem.getType(type).then(blob => blob.text()); + } + } + } + + if (files.length > 0) { + console.log(`📋 Found ${files.length} image(s) in clipboard`); + showToast(`📋 Uploading ${files.length} image(s) from clipboard...`, 'success'); + filesToUpload = files; + displaySelectedFiles(files); + handleFileUpload(files); + } else if (hasText && textContent.trim()) { + // If no images but there's text, suggest switching to paste mode + const shouldSwitchToPaste = confirm( + `📋 Found text in clipboard: "${textContent.slice(0, 100)}${textContent.length > 100 ? '...' : ''}"\n\nWould you like to switch to Paste mode to upload this text?` + ); + + if (shouldSwitchToPaste) { + pasteModeButton.click(); + pasteContent.value = textContent; + pasteContent.focus(); + pasteContent.scrollIntoView({ behavior: 'smooth', block: 'center' }); + showToast('📋 Text pasted successfully!', 'success'); + } + } else { + showToast('📋 No supported content found in clipboard. Try copying an image or text first.', 'warning', 4000); + } + } else { + // Fallback for browsers that don't support Clipboard API + showToast('📋 Please use your browser\'s paste function (long-press and select "Paste")', 'info', 4000); + } + } catch (error) { + console.error('📋 Clipboard access error:', error); + if (error.name === 'NotAllowedError') { + showToast('📋 Clipboard access denied. Please allow clipboard permissions and try again.', 'warning', 4000); + } else { + showToast('📋 Unable to access clipboard. Try copying something first, or use long-press and "Paste".', 'warning', 4000); + } + } + } + + // Mobile paste button handler + if (mobilePasteButton) { + mobilePasteButton.addEventListener('click', handleClipboardPaste); + } + + // Clipboard paste functionality + document.addEventListener('paste', async (event) => { + console.log('📋 Paste event triggered'); + console.log('📋 Current mode:', currentMode); + console.log('📋 Event target:', event.target.tagName); + console.log('📋 User agent:', navigator.userAgent.includes('Mobile') ? 'Mobile' : 'Desktop'); + + // Only handle paste when in file mode and not typing in textarea + if (currentMode !== 'file' || event.target.tagName === 'TEXTAREA' || event.target.tagName === 'INPUT') { + console.log('📋 Paste ignored - wrong mode or target'); + return; + } + + event.preventDefault(); + console.log('📋 Paste event detected and processing...'); + + const clipboardItems = event.clipboardData.items; + console.log('📋 Clipboard items:', clipboardItems.length); + + const files = []; + let hasText = false; + let textContent = ''; + + // Process clipboard items + for (let item of clipboardItems) { + console.log('📋 Processing item type:', item.type); + if (item.type.indexOf('image') !== -1) { + // Handle image from clipboard + const file = item.getAsFile(); + if (file) { + // Create a more descriptive filename with timestamp + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + const fileExtension = file.type.split('/')[1] || 'png'; + Object.defineProperty(file, 'name', { + writable: true, + value: `clipboard-image-${timestamp}.${fileExtension}` + }); + files.push(file); + } + } else if (item.type === 'text/plain') { + hasText = true; + textContent = event.clipboardData.getData('text/plain'); + } + } + + if (files.length > 0) { + // Upload images from clipboard + console.log(`📋 Found ${files.length} image(s) in clipboard`); + showToast(`📋 Uploading ${files.length} image(s) from clipboard...`, 'info'); + result.innerHTML = '

📋 Uploading image from clipboard...

'; + filesToUpload = files; + displaySelectedFiles(files); + handleFileUpload(files); + } else if (hasText && textContent.trim()) { + // If no images but there's text, suggest switching to paste mode + const shouldSwitchToPaste = confirm( + `📋 Found text in clipboard: "${textContent.slice(0, 100)}${textContent.length > 100 ? '...' : ''}"\n\nWould you like to switch to Paste mode to upload this text?` + ); + + if (shouldSwitchToPaste) { + // Switch to paste mode and fill the textarea + pasteModeButton.click(); + pasteContent.value = textContent; + pasteContent.focus(); + // Auto-scroll to textarea + pasteContent.scrollIntoView({ behavior: 'smooth', block: 'center' }); + showToast('📋 Text pasted successfully!', 'success'); + } else { + showToast('📋 Clipboard paste cancelled', 'info', 2000); + } + } else { + // Show a helpful message with mobile-specific guidance + const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); + if (isMobile) { + showToast('📋 No content found. On mobile: long-press and select "Paste" or copy an image first.', 'warning', 4000); + } else { + showToast('📋 No supported content found in clipboard. Try copying an image or text.', 'warning'); + } + } + }); + + // Add visual feedback for clipboard functionality + document.addEventListener('keydown', (event) => { + if ((event.ctrlKey || event.metaKey) && event.key === 'v' && currentMode === 'file') { + // Only show hint if not focused on input/textarea + if (event.target.tagName !== 'TEXTAREA' && event.target.tagName !== 'INPUT') { + dropArea.style.transform = 'scale(1.02)'; + dropArea.style.borderColor = '#4CAF50'; + setTimeout(() => { + dropArea.style.transform = ''; + dropArea.style.borderColor = ''; + }, 200); + } + } + }); + + // Mobile long-press visual feedback + const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || + ('ontouchstart' in window) || + (navigator.maxTouchPoints > 0); + + // Update instructions based on device type + const desktopInstructions = document.getElementById('desktopInstructions'); + const mobileInstructions = document.getElementById('mobileInstructions'); + + if (isMobile) { + if (desktopInstructions) desktopInstructions.style.display = 'none'; + if (mobileInstructions) mobileInstructions.style.display = 'block'; + + // Show mobile-specific content + document.querySelectorAll('.mobile-only').forEach(el => el.style.display = 'block'); + document.querySelectorAll('.desktop-only').forEach(el => el.style.display = 'none'); + } else { + if (desktopInstructions) desktopInstructions.style.display = 'block'; + if (mobileInstructions) mobileInstructions.style.display = 'none'; + + // Show desktop-specific content + document.querySelectorAll('.mobile-only').forEach(el => el.style.display = 'none'); + document.querySelectorAll('.desktop-only').forEach(el => el.style.display = 'block'); + } + + // Show mobile hints if on mobile + if (isMobile) { + // Show mobile hints + const mobileHints = document.querySelectorAll('.mobile-hint'); + mobileHints.forEach(hint => hint.style.display = 'block'); + } + + // Context menu elements + const contextMenu = document.getElementById('contextMenu'); + const contextPasteButton = document.getElementById('contextPasteButton'); + const contextSelectButton = document.getElementById('contextSelectButton'); + + let longPressTimer = null; + let contextMenuVisible = false; + + // Hide context menu when clicking elsewhere + document.addEventListener('click', (event) => { + if (contextMenuVisible && !contextMenu.contains(event.target)) { + hideContextMenu(); + } + }); + + function showContextMenu(x, y) { + contextMenu.style.left = x + 'px'; + contextMenu.style.top = y + 'px'; + contextMenu.style.display = 'block'; + contextMenuVisible = true; + + // Adjust position if menu goes off-screen + const rect = contextMenu.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + contextMenu.style.left = (x - rect.width) + 'px'; + } + if (rect.bottom > window.innerHeight) { + contextMenu.style.top = (y - rect.height) + 'px'; + } + } + + function hideContextMenu() { + contextMenu.style.display = 'none'; + contextMenuVisible = false; + } + + if (isMobile) { + dropArea.addEventListener('touchstart', (event) => { + if (currentMode === 'file') { + longPressTimer = setTimeout(() => { + // Add visual feedback for long press + dropArea.style.backgroundColor = '#e3f2fd'; + dropArea.style.borderColor = '#2196F3'; + + // Show context menu at touch position + const touch = event.touches[0]; + showContextMenu(touch.clientX, touch.clientY); + + // Haptic feedback if available + if (navigator.vibrate) { + navigator.vibrate(50); + } + }, 500); // 500ms for long press + } + }); + + dropArea.addEventListener('touchend', () => { + clearTimeout(longPressTimer); + // Reset visual feedback + setTimeout(() => { + dropArea.style.backgroundColor = ''; + dropArea.style.borderColor = ''; + }, 200); + }); + + dropArea.addEventListener('touchmove', () => { + clearTimeout(longPressTimer); + }); + } + + // Context menu paste button + if (contextPasteButton) { + contextPasteButton.addEventListener('click', async () => { + hideContextMenu(); + await handleClipboardPaste(); + }); + } + + // Context menu select files button + if (contextSelectButton) { + contextSelectButton.addEventListener('click', () => { + hideContextMenu(); + fileInput.click(); + }); + } + + // Helper function to display selected files + function displaySelectedFiles(files) { + result.innerHTML = '

� Uploading files...

'; + } + + // Handle file upload (simplified, no encryption) + async function handleFileUpload(files) { + console.log('� Starting file upload...'); + + // Check file sizes first (default 100MB) + const maxSizeMB = 100; + for (let file of files) { + if (file.size > maxSizeMB * 1024 * 1024) { + result.innerHTML = `
File too large: ${file.name} (${(file.size/1024/1024).toFixed(2)}MB). Max allowed: ${maxSizeMB}MB.
`; + return; + } + } + + try { + + progressText.textContent = 'Preparing upload...'; + progressBar.style.width = '0%'; + + const results = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + console.log(`📤 Starting upload ${i + 1}/${files.length}: ${file.name}`); + + // Show starting state for this file + progressText.textContent = `Starting upload: ${file.name} (${i + 1}/${files.length})`; + + try { + const result = await uploadFileWithProgress(file, i, files.length); + results.push(result); + + // Don't manually update progress here - let the final completion handle it + console.log(`✅ File ${i + 1}/${files.length} completed: ${file.name}`); + + } catch (error) { + console.error(`❌ Failed to upload ${file.name}:`, error); + throw error; + } + } + + // Show upload completion but indicate we're still processing + progressBar.style.width = '100%'; + progressText.textContent = '📊 Processing uploaded files...'; + + // Brief delay to show processing state + await new Promise(resolve => setTimeout(resolve, 500)); + + // Display results + displayUploadedFiles(results); + + // Final completion state + progressText.textContent = '✅ Upload complete!'; + + } catch (error) { + console.error('❌ Upload process failed:', error); + result.innerHTML = `
Upload failed: ${error.message}
`; + } finally { + // Don't reset progress bar - keep it at completion state + // The page will redirect or reload anyway + console.log('📊 Upload process finished (finally block)'); + } + } + + // Upload single file with progress tracking + function uploadFileWithProgress(file, fileIndex, totalFiles) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const formData = new FormData(); + formData.append('files[]', file); + + // Add expiry information + const expiryValue = expirySelect?.value || 'never'; + const customExpiryValue = customExpiry?.value; + const expiryTime = calculateExpiryTime(expiryValue, customExpiryValue); + + if (expiryTime) { + formData.append('expires_at', expiryTime); + } + + // Track upload progress + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + // Calculate progress for this file (0-100%) + const fileProgress = (event.loaded / event.total) * 100; + + // Calculate overall progress more conservatively + // Each file gets an equal portion of the total progress + const baseProgress = (fileIndex / totalFiles) * 100; + const currentFileContribution = (fileProgress / totalFiles); + const overallProgress = Math.min(99, baseProgress + currentFileContribution); // Cap at 99% until all done + + // Update progress bar and text + progressBar.style.width = `${Math.round(overallProgress)}%`; + progressText.textContent = `Uploading ${file.name}... ${Math.round(fileProgress)}% (${fileIndex + 1}/${totalFiles})`; + } + }); + + // Handle upload completion + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const response = JSON.parse(xhr.responseText); + console.log('📤 Upload response:', response); + + resolve({ + url: response.urls[0], + file: file, + originalSize: file.size + }); + } catch (error) { + reject(new Error('Invalid response from server')); + } + } else { + reject(new Error(`Upload failed: ${xhr.statusText || 'Unknown error'}`)); + } + }); + + // Handle upload errors + xhr.addEventListener('error', () => { + reject(new Error('Network error during upload')); + }); + + // Handle upload timeout + xhr.addEventListener('timeout', () => { + reject(new Error('Upload timed out')); + }); + + // Start the upload + xhr.open('POST', '/api/upload'); + xhr.send(formData); + }); + } + + // Display uploaded files + function displayUploadedFiles(results) { + console.log('� Displaying file results:', results); + + result.innerHTML = ''; + + const header = document.createElement('div'); + header.innerHTML = '

� Files Uploaded

'; + result.appendChild(header); + + results.forEach((fileResult, index) => { + const fileContainer = document.createElement('div'); + fileContainer.className = 'file-result-bar'; + + // Create file info section + const fileInfo = document.createElement('div'); + fileInfo.className = 'file-info-section'; + + const fileName = document.createElement('div'); + fileName.className = 'file-name'; + fileName.innerHTML = `📄 ${fileResult.file.name}`; + fileInfo.appendChild(fileName); + + const fileSize = document.createElement('div'); + fileSize.className = 'file-size'; + fileSize.innerHTML = `📊 ${(fileResult.originalSize / 1024).toFixed(1)} KB`; + fileInfo.appendChild(fileSize); + + fileContainer.appendChild(fileInfo); + + const buttonContainer = document.createElement('div'); + buttonContainer.className = 'file-buttons-section'; + + // Share button + const shareButton = document.createElement('button'); + shareButton.textContent = '🔗 Copy Link'; + shareButton.className = 'share-button'; + shareButton.addEventListener('click', () => { + navigator.clipboard.writeText(fileResult.url).then(() => { + shareButton.textContent = '✅ Copied!'; + setTimeout(() => { + shareButton.textContent = '🔗 Copy Link'; + }, 2000); + }); + }); + buttonContainer.appendChild(shareButton); + + // View button + const viewButton = document.createElement('button'); + viewButton.textContent = '👁️ View'; + viewButton.className = 'view-button'; + viewButton.addEventListener('click', () => { + window.open(fileResult.url, '_blank'); + }); + buttonContainer.appendChild(viewButton); + + // QR Code button + const qrButton = document.createElement('button'); + qrButton.textContent = '📱 QR Code'; + qrButton.className = 'qr-button'; + qrButton.style.backgroundColor = '#9C27B0'; + qrButton.style.color = 'white'; + qrButton.addEventListener('click', () => { + console.log('🔍 QR button clicked for file:', fileResult.file.name); + showQRCode(fileResult.url, fileResult.file.name); + }); + buttonContainer.appendChild(qrButton); + + fileContainer.appendChild(buttonContainer); + + // Add image thumbnail if it's an image + if (isImageFile(fileResult.file)) { + const thumbnail = document.createElement('img'); + thumbnail.src = fileResult.url; + thumbnail.alt = fileResult.file.name; + thumbnail.className = 'file-thumbnail'; + + thumbnail.onerror = () => { + thumbnail.style.display = 'none'; + }; + + fileContainer.appendChild(thumbnail); + } + + result.appendChild(fileContainer); + }); + } + + // Handle paste submission (simplified, no encryption) + submitPasteButton.addEventListener('click', async () => { + const content = pasteContent.value.trim(); + + if (!content) { + pasteResult.innerHTML = '

Please enter some content

'; + return; + } + + try { + pasteResult.innerHTML = '

📝 Uploading paste...

'; + + // Calculate paste expiry + const expiryValue = pasteExpirySelect?.value || 'never'; + const customExpiryValue = customPasteExpiry?.value; + const expiryTime = calculateExpiryTime(expiryValue, customExpiryValue); + + // Prepare request body + const requestBody = { content: content }; + if (expiryTime) { + requestBody.expires_at = expiryTime; + } + + // Upload paste + const response = await fetch('/api/paste', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.statusText}`); + } + + const pasteResponse = await response.json(); + const url = pasteResponse.url; + + // Display result + displayPasteResult(url, content.length); + + // Clear the textarea + pasteContent.value = ''; + + } catch (error) { + console.error('❌ Paste upload failed:', error); + pasteResult.innerHTML = `
Paste upload failed: ${error.message}
`; + } + }); + + // Display paste result + function displayPasteResult(url, originalLength) { + pasteResult.innerHTML = ` +
+

� Paste Uploaded

+

📝 Content: ${originalLength} characters

+
+ + + +
+
+ `; + } + + // Theme management functions + function initializeTheme() { + // Get saved theme from cookie or default to light + const savedTheme = getCookie('sharey-theme') || 'light'; + + // Apply the theme + applyTheme(savedTheme); + + // Set up theme toggle button + const themeToggle = document.getElementById('themeToggle'); + console.log('🔍 Theme toggle button found:', themeToggle ? 'YES' : 'NO'); + if (themeToggle) { + console.log('🎯 Adding click listener to theme button'); + themeToggle.addEventListener('click', toggleTheme); + // Show the current theme setting on button + updateThemeButton(savedTheme); + } else { + console.log('⚠️ Theme toggle button not found on this page'); + } + } + + function toggleTheme() { + console.log('🎨 Theme toggle button clicked!'); + const currentTheme = getCookie('sharey-theme') || 'light'; + console.log('🎨 Current theme:', currentTheme); + let newTheme; + + // Toggle between light and dark only + if (currentTheme === 'light') { + newTheme = 'dark'; + } else { + newTheme = 'light'; + } + + console.log('🎨 Switching to theme:', newTheme); + applyTheme(newTheme); + setCookie('sharey-theme', newTheme, 365); // Save for 1 year + updateThemeButton(newTheme); + + console.log(`🎨 Theme switched to: ${newTheme}`); + } + + function applyTheme(themeSetting) { + if (themeSetting === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + } else { + document.documentElement.setAttribute('data-theme', 'light'); + } + } + + function getSystemTheme() { + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + function updateThemeButton(themeSetting) { + const themeToggle = document.getElementById('themeToggle'); + if (themeToggle) { + if (themeSetting === 'light') { + themeToggle.textContent = '🌙'; + themeToggle.title = 'Switch to dark mode'; + } else { + themeToggle.textContent = '☀️'; + themeToggle.title = 'Switch to light mode'; + } + } + } + + // Cookie management functions + function setCookie(name, value, days) { + const expires = new Date(); + expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000)); + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`; + } + + function getCookie(name) { + const nameEQ = name + "="; + const ca = document.cookie.split(';'); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) === ' ') c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); + } + return null; + } + +}); + +// QR Code functionality (global function for inline onclick handlers) +function showQRCode(url, title) { + console.log('🔍 QR Code button clicked!', url, title); + + // Check if QRCode library is available + if (typeof QRCode === 'undefined') { + console.warn('QRCode library not available, using fallback'); + showQRCodeFallback(url, title); + return; + } + + // Create modal overlay + const modal = document.createElement('div'); + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; + `; + + // Create modal content + const modalContent = document.createElement('div'); + modalContent.style.cssText = ` + background: white; + padding: 30px; + border-radius: 10px; + text-align: center; + max-width: 400px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + `; + + // Add title + const titleEl = document.createElement('h3'); + titleEl.textContent = `📱 QR Code for ${title}`; + titleEl.style.marginBottom = '20px'; + modalContent.appendChild(titleEl); + + // Create QR code canvas + const canvas = document.createElement('canvas'); + modalContent.appendChild(canvas); + + // Generate QR code + QRCode.toCanvas(canvas, url, { + width: 256, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + } + }, function(error) { + if (error) { + console.error('QR Code generation failed:', error); + modalContent.innerHTML = '

❌ Failed to generate QR code

'; + } + }); + + // Add close button + const closeButton = document.createElement('button'); + closeButton.textContent = '✕ Close'; + closeButton.style.cssText = ` + margin-top: 20px; + padding: 10px 20px; + background: #f44336; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 16px; + `; + closeButton.onclick = () => document.body.removeChild(modal); + modalContent.appendChild(closeButton); + + // Add URL text (truncated) + const urlText = document.createElement('p'); + urlText.textContent = url.length > 50 ? url.substring(0, 50) + '...' : url; + urlText.style.cssText = ` + font-size: 12px; + color: #666; + margin-top: 15px; + word-break: break-all; + `; + modalContent.appendChild(urlText); + + modal.appendChild(modalContent); + document.body.appendChild(modal); + + // Close on click outside + modal.onclick = (e) => { + if (e.target === modal) { + document.body.removeChild(modal); + } + }; +} + +// Fallback QR code function using online API +function showQRCodeFallback(url, title) { + console.log('🔍 Using QR code fallback for:', title); + + // Create modal overlay + const modal = document.createElement('div'); + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; + `; + + // Create modal content + const modalContent = document.createElement('div'); + modalContent.style.cssText = ` + background: white; + padding: 30px; + border-radius: 10px; + text-align: center; + max-width: 400px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + `; + + // Add title + const titleEl = document.createElement('h3'); + titleEl.textContent = `📱 QR Code for ${title}`; + titleEl.style.marginBottom = '20px'; + modalContent.appendChild(titleEl); + + // Create QR code using API service + const qrImg = document.createElement('img'); + const encodedUrl = encodeURIComponent(url); + qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodedUrl}`; + qrImg.style.cssText = ` + width: 256px; + height: 256px; + border: 2px solid #ddd; + border-radius: 8px; + `; + qrImg.onerror = () => { + qrImg.style.display = 'none'; + const errorMsg = document.createElement('p'); + errorMsg.textContent = '❌ Failed to generate QR code. You can copy the link instead.'; + errorMsg.style.color = 'red'; + modalContent.appendChild(errorMsg); + }; + modalContent.appendChild(qrImg); + + // Add close button + const closeButton = document.createElement('button'); + closeButton.textContent = '✕ Close'; + closeButton.style.cssText = ` + margin-top: 20px; + padding: 10px 20px; + background: #f44336; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 16px; + `; + closeButton.onclick = () => document.body.removeChild(modal); + modalContent.appendChild(closeButton); + + // Add copy button + const copyButton = document.createElement('button'); + copyButton.textContent = '📋 Copy Link'; + copyButton.style.cssText = ` + margin-top: 10px; + margin-left: 10px; + padding: 10px 20px; + background: #4CAF50; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 16px; + `; + copyButton.onclick = () => { + navigator.clipboard.writeText(url).then(() => { + copyButton.textContent = '✅ Copied!'; + setTimeout(() => copyButton.textContent = '📋 Copy Link', 2000); + }); + }; + modalContent.appendChild(copyButton); + + // Add URL text (truncated) + const urlText = document.createElement('p'); + urlText.textContent = url.length > 50 ? url.substring(0, 50) + '...' : url; + urlText.style.cssText = ` + font-size: 12px; + color: #666; + margin-top: 15px; + word-break: break-all; + `; + modalContent.appendChild(urlText); + + modal.appendChild(modalContent); + document.body.appendChild(modal); + + // Close on click outside + modal.onclick = (e) => { + if (e.target === modal) { + document.body.removeChild(modal); + } + }; +} + + +// Toast notification function +function showToast(message, type = "success", duration = 3000) { + const toast = document.createElement("div"); + toast.className = "clipboard-toast"; + toast.textContent = message; + + if (type === "info") { + toast.style.background = "#2196F3"; + } else if (type === "warning") { + toast.style.background = "#1d1b18ff"; + } else if (type === "error") { + toast.style.background = "#f44336"; + } + + document.body.appendChild(toast); + + // Auto remove after duration + setTimeout(() => { + if (document.body.contains(toast)) { + toast.style.animation = "slideInToast 0.3s ease-out reverse"; + setTimeout(() => { + if (document.body.contains(toast)) { + document.body.removeChild(toast); + } + }, 300); + } + }, duration); +} + +// URL Shortener initialization function +function initializeUrlShortener() { + console.log('🔗 Initializing URL shortener...'); + + // Re-query elements now that section is visible + const urlInput = document.getElementById('urlInput'); + const customCode = document.getElementById('customCode'); + const urlExpiry = document.getElementById('urlExpiry'); + const shortenUrlButton = document.getElementById('shortenUrl'); + const urlResult = document.getElementById('urlResult'); + const urlPreviewContainer = document.getElementById('urlPreviewContainer'); + + console.log('URL shortener elements:', { + urlInput: !!urlInput, + customCode: !!customCode, + urlExpiry: !!urlExpiry, + shortenUrlButton: !!shortenUrlButton, + urlResult: !!urlResult, + urlPreviewContainer: !!urlPreviewContainer + }); + + if (shortenUrlButton && urlInput && urlResult) { + console.log('✅ URL shortener elements found, adding event listeners'); + + // Remove any existing listeners to avoid duplicates + shortenUrlButton.replaceWith(shortenUrlButton.cloneNode(true)); + const newShortenButton = document.getElementById('shortenUrl'); + + newShortenButton.addEventListener('click', () => shortenUrl(urlInput, customCode, urlExpiry, urlResult, urlPreviewContainer, newShortenButton)); + + // Enter key support for URL input + urlInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + shortenUrl(urlInput, customCode, urlExpiry, urlResult, urlPreviewContainer, newShortenButton); + } + }); + + // Copy button functionality + document.addEventListener('click', (e) => { + if (e.target && e.target.id === 'copyUrlButton') { + const shortUrlInput = document.getElementById('shortUrlInput'); + if (shortUrlInput) { + shortUrlInput.select(); + shortUrlInput.setSelectionRange(0, 99999); // For mobile + + navigator.clipboard.writeText(shortUrlInput.value).then(() => { + showToast('Short URL copied to clipboard! 📋', 'success'); + }).catch(() => { + // Fallback for older browsers + document.execCommand('copy'); + showToast('Short URL copied to clipboard! �', 'success'); + }); + } + } + }); + } else { + console.log('❌ URL shortener elements not found'); + } +} + +// URL Shortener functionality +console.log('�🔗 Setting up URL shortener...'); +console.log('shortenUrlButton:', shortenUrlButton); +console.log('urlInput:', urlInput); +console.log('urlResult:', urlResult); + +if (shortenUrlButton && urlInput && urlResult) { + console.log('✅ URL shortener elements found, adding event listeners'); + shortenUrlButton.addEventListener('click', shortenUrl); + + // Enter key support for URL input + urlInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + shortenUrl(); + } + }); + + // Copy button functionality + document.addEventListener('click', (e) => { + if (e.target && e.target.id === 'copyUrlButton') { + const shortUrlInput = document.getElementById('shortUrlInput'); + if (shortUrlInput) { + shortUrlInput.select(); + shortUrlInput.setSelectionRange(0, 99999); // For mobile + + navigator.clipboard.writeText(shortUrlInput.value).then(() => { + showToast('Short URL copied to clipboard! 📋', 'success'); + }).catch(() => { + // Fallback for older browsers + document.execCommand('copy'); + showToast('Short URL copied to clipboard! 📋', 'success'); + }); + } + } + }); +} else { + console.log('❌ URL shortener elements not found'); + console.log('Missing elements:', { + shortenUrlButton: !shortenUrlButton, + urlInput: !urlInput, + urlResult: !urlResult + }); +} + +async function shortenUrl(urlInputEl, customCodeEl, urlExpiryEl, urlResultEl, urlPreviewContainerEl, shortenButtonEl) { + // Use passed elements or fallback to global ones + const urlInput = urlInputEl || document.getElementById('urlInput'); + const customCode = customCodeEl || document.getElementById('customCode'); + const urlExpiry = urlExpiryEl || document.getElementById('urlExpiry'); + const urlResult = urlResultEl || document.getElementById('urlResult'); + const urlPreviewContainer = urlPreviewContainerEl || document.getElementById('urlPreviewContainer'); + const shortenUrlButton = shortenButtonEl || document.getElementById('shortenUrl'); + + console.log('🔗 shortenUrl function called'); + + const url = urlInput.value.trim(); + const code = customCode.value.trim(); + const expiryHours = urlExpiry.value === 'never' ? null : parseInt(urlExpiry.value); + + console.log('URL:', url, 'Code:', code, 'Expiry:', expiryHours); + + if (!url) { + showError('Please enter a URL to shorten', urlResult); + return; + } + + // Basic URL validation + try { + new URL(url); + } catch (e) { + showError('Please enter a valid URL (include http:// or https://)', urlResult); + return; + } + + // Show loading state + shortenUrlButton.disabled = true; + shortenUrlButton.textContent = '⏳ Shortening...'; + showInfo('Creating short URL...', urlResult); + + try { + const payload = { + url: url + }; + + if (code) { + payload.code = code; + } + + if (expiryHours) { + payload.expires_in_hours = expiryHours; + } + + const response = await fetch('/api/shorten', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + + if (response.ok) { + showUrlSuccess(data, urlResult, urlPreviewContainer, urlInput, customCode, urlExpiry); + } else { + showError(data.error || 'Failed to create short URL', urlResult); + } + } catch (error) { + console.error('URL shortening error:', error); + showError('Failed to create short URL. Please try again.', urlResult); + } finally { + // Reset button state + shortenUrlButton.disabled = false; + shortenUrlButton.textContent = '✨ Shorten URL'; + } +} + +function showUrlSuccess(data, urlResult, urlPreviewContainer, urlInput, customCode, urlExpiry) { + urlResult.className = 'result-message success'; + urlResult.textContent = '✅ Short URL created successfully!'; + + // Show preview container + urlPreviewContainer.style.display = 'block'; + + // Update preview elements + const shortUrlInput = document.getElementById('shortUrlInput'); + const targetUrl = document.getElementById('targetUrl'); + const clickCount = document.getElementById('clickCount'); + const expiryInfo = document.getElementById('expiryInfo'); + const expiryDate = document.getElementById('expiryDate'); + + if (shortUrlInput) { + shortUrlInput.value = data.short_url; + } + + if (targetUrl) { + targetUrl.textContent = data.target_url; + } + + if (clickCount) { + clickCount.textContent = '0'; + } + + if (data.expires_at && expiryInfo && expiryDate) { + expiryDate.textContent = new Date(data.expires_at).toLocaleString(); + expiryInfo.style.display = 'block'; + } else if (expiryInfo) { + expiryInfo.style.display = 'none'; + } + + // Clear form + urlInput.value = ''; + customCode.value = ''; + urlExpiry.value = '24'; +} diff --git a/src/static/script_backup.js b/src/static/script_backup.js new file mode 100644 index 0000000..782c92e --- /dev/null +++ b/src/static/script_backup.js @@ -0,0 +1,619 @@ +document.addEventListener("DOMContentLoaded", function() { + console.log('🔥 Script.js loaded!'); + + // Theme management + initializeTheme(); + + const dropArea = document.getElementById('drop-area'); + const fileInput = document.getElementById('fileInput'); + const progressContainer = document.getElementById('progressContainer'); + const function updateThemeButton(themeSetting) { + const themeToggle = document.getElementById('themeToggle'); + if (themeToggle) { + if (themeSetting === 'light') { + themeToggle.textContent = '🌙'; + themeToggle.title = 'Switch to dark mode'; + } else { + themeToggle.textContent = '☀️'; + themeToggle.title = 'Switch to light mode'; + } + } + } = document.getElementById('progressBar'); + const progressText = document.getElementById('progressText'); + const result = document.getElementById('result'); + + const fileModeButton = document.getElementById('fileMode'); + const pasteModeButton = document.getElementById('pasteMode'); + const faqButton = document.getElementById('faqButton'); + + const fileSharingSection = document.getElementById('fileSharingSection'); + const pastebinSection = document.getElementById('pastebinSection'); + const faqSection = document.getElementById('faqSection'); + + const pasteContent = document.getElementById('pasteContent'); + const submitPasteButton = document.getElementById('submitPaste'); + const pasteResult = document.getElementById('pasteResult'); + + let filesToUpload = []; + + // Toggle between file sharing, pastebin, and FAQ sections + fileModeButton.addEventListener('click', () => { + showSection(fileSharingSection); + hideSection(pastebinSection); + hideSection(faqSection); + activateButton(fileModeButton); + }); + + pasteModeButton.addEventListener('click', () => { + showSection(pastebinSection); + hideSection(fileSharingSection); + hideSection(faqSection); + activateButton(pasteModeButton); + }); + + faqButton.addEventListener('click', () => { + showSection(faqSection); + hideSection(fileSharingSection); + hideSection(pastebinSection); + activateButton(faqButton); + }); + + // Helper function to show a section + function showSection(section) { + section.style.display = 'block'; + } + + // Helper function to hide a section + function hideSection(section) { + section.style.display = 'none'; + } + + // Helper function to activate a button + function activateButton(button) { + const buttons = [fileModeButton, pasteModeButton, faqButton]; + buttons.forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + } + + // Make drop area clickable + dropArea.addEventListener('click', () => { + fileInput.click(); + }); + + // Drag-and-drop events + dropArea.addEventListener('dragover', (event) => { + event.preventDefault(); + dropArea.classList.add('dragging'); + }); + + dropArea.addEventListener('dragleave', () => { + dropArea.classList.remove('dragging'); + }); + + dropArea.addEventListener('drop', (event) => { + event.preventDefault(); + dropArea.classList.remove('dragging'); + + filesToUpload = event.dataTransfer.files; + displaySelectedFiles(filesToUpload); + handleFileUpload(filesToUpload); + }); + + // Handle file input selection + fileInput.addEventListener('change', (event) => { + filesToUpload = event.target.files; + displaySelectedFiles(filesToUpload); + handleFileUpload(filesToUpload); + }); + + // Helper function to display selected files + function displaySelectedFiles(files) { + result.innerHTML = '

� Uploading files...

'; + } + + // Handle file upload (simplified, no encryption) + async function handleFileUpload(files) { + console.log('� Starting file upload...'); + + // Check file sizes first (default 100MB) + const maxSizeMB = 100; + for (let file of files) { + if (file.size > maxSizeMB * 1024 * 1024) { + result.innerHTML = `
File too large: ${file.name} (${(file.size/1024/1024).toFixed(2)}MB). Max allowed: ${maxSizeMB}MB.
`; + return; + } + } + + try { + progressContainer.style.display = 'block'; + progressText.textContent = 'Uploading files...'; + + const uploadPromises = Array.from(files).map(async (file, index) => { + try { + console.log(`� Uploading file: ${file.name}`); + + // Create form data with file + const formData = new FormData(); + formData.append('file', file); + + // Upload file + const response = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.statusText}`); + } + + const result = await response.json(); + console.log('📤 Upload response:', result); + + return { + url: result.urls[0], + file: file, + originalSize: file.size + }; + + } catch (error) { + console.error(`❌ Failed to upload ${file.name}:`, error); + throw error; + } + }); + + // Wait for all uploads to complete + const results = await Promise.all(uploadPromises); + + // Update progress + progressBar.style.width = '100%'; + progressText.textContent = '100%'; + + // Display results + displayUploadedFiles(results); + + } catch (error) { + console.error('❌ Upload process failed:', error); + result.innerHTML = `
Upload failed: ${error.message}
`; + } finally { + // Hide progress after a short delay + setTimeout(() => { + progressContainer.style.display = 'none'; + progressBar.style.width = '0%'; + progressText.textContent = '0%'; + }, 2000); + } + } + + // Display uploaded files + function displayUploadedFiles(results) { + console.log('� Displaying file results:', results); + + result.innerHTML = ''; + + const header = document.createElement('div'); + header.innerHTML = '

� Files Uploaded

'; + result.appendChild(header); + + results.forEach((fileResult, index) => { + const fileContainer = document.createElement('div'); + fileContainer.style.marginBottom = '15px'; + fileContainer.style.padding = '15px'; + fileContainer.style.border = '2px solid #4CAF50'; + fileContainer.style.borderRadius = '8px'; + fileContainer.style.backgroundColor = '#f0f8f0'; + + const fileName = document.createElement('div'); + fileName.innerHTML = `📄 ${fileResult.file.name}`; + fileName.style.marginBottom = '10px'; + fileContainer.appendChild(fileName); + + const fileSize = document.createElement('div'); + fileSize.innerHTML = `� Size: ${(fileResult.originalSize / 1024).toFixed(1)} KB`; + fileSize.style.marginBottom = '10px'; + fileSize.style.fontSize = '14px'; + fileContainer.appendChild(fileSize); + + const buttonContainer = document.createElement('div'); + buttonContainer.style.display = 'flex'; + buttonContainer.style.gap = '10px'; + buttonContainer.style.flexWrap = 'wrap'; + + // Share button + const shareButton = document.createElement('button'); + shareButton.textContent = '🔗 Copy Link'; + shareButton.className = 'share-button'; + shareButton.addEventListener('click', () => { + navigator.clipboard.writeText(fileResult.url).then(() => { + shareButton.textContent = '✅ Copied!'; + setTimeout(() => { + shareButton.textContent = '🔗 Copy Link'; + }, 2000); + }); + }); + buttonContainer.appendChild(shareButton); + + // View button + const viewButton = document.createElement('button'); + viewButton.textContent = '👁️ View'; + viewButton.className = 'view-button'; + viewButton.addEventListener('click', () => { + window.open(fileResult.url, '_blank'); + }); + buttonContainer.appendChild(viewButton); + + // QR Code button + const qrButton = document.createElement('button'); + qrButton.textContent = '📱 QR Code'; + qrButton.className = 'qr-button'; + qrButton.style.backgroundColor = '#9C27B0'; + qrButton.style.color = 'white'; + qrButton.addEventListener('click', () => { + console.log('🔍 QR button clicked for file:', fileResult.file.name); + showQRCode(fileResult.url, fileResult.file.name); + }); + buttonContainer.appendChild(qrButton); + + fileContainer.appendChild(buttonContainer); + + result.appendChild(fileContainer); + }); + } + + // Handle paste submission (simplified, no encryption) + submitPasteButton.addEventListener('click', async () => { + const content = pasteContent.value.trim(); + + if (!content) { + pasteResult.innerHTML = '

Please enter some content

'; + return; + } + + try { + pasteResult.innerHTML = '

� Uploading paste...

'; + + // Upload paste + const response = await fetch('/api/paste', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + content: content + }) + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.statusText}`); + } + + const result = await response.json(); + const url = result.url; + + // Display result + displayPasteResult(url, content.length); + + // Clear the textarea + pasteContent.value = ''; + + } catch (error) { + console.error('❌ Paste upload failed:', error); + pasteResult.innerHTML = `
Paste upload failed: ${error.message}
`; + } + }); + + // Display paste result + function displayPasteResult(url, originalLength) { + pasteResult.innerHTML = ` +
+

� Paste Uploaded

+

📝 Content: ${originalLength} characters

+
+ + + +
+
+ `; + } + + // Theme management functions + function initializeTheme() { + // Get saved theme from cookie or default to light + const savedTheme = getCookie('sharey-theme') || 'light'; + + // Apply the theme + applyTheme(savedTheme); + + // Set up theme toggle button + const themeToggle = document.getElementById('themeToggle'); + console.log('🔍 Theme toggle button found:', themeToggle ? 'YES' : 'NO'); + if (themeToggle) { + console.log('🎯 Adding click listener to theme button'); + themeToggle.addEventListener('click', toggleTheme); + // Show the current theme setting on button + updateThemeButton(savedTheme); + } else { + console.log('⚠️ Theme toggle button not found on this page'); + } + } + + function toggleTheme() { + console.log('🎨 Theme toggle button clicked!'); + const currentTheme = getCookie('sharey-theme') || 'light'; + console.log('🎨 Current theme:', currentTheme); + let newTheme; + + // Toggle between light and dark only + if (currentTheme === 'light') { + newTheme = 'dark'; + } else { + newTheme = 'light'; + } + + console.log('🎨 Switching to theme:', newTheme); + applyTheme(newTheme); + setCookie('sharey-theme', newTheme, 365); // Save for 1 year + updateThemeButton(newTheme); + + console.log(`🎨 Theme switched to: ${newTheme}`); + } + + function applyTheme(themeSetting) { + if (themeSetting === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + } else { + document.documentElement.removeAttribute('data-theme'); + } + } + + function getSystemTheme() { + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + function updateThemeButton(themeSetting) { + const themeToggle = document.getElementById('themeToggle'); + if (themeToggle) { + if (themeSetting === 'light') { + themeToggle.textContent = '�'; + themeToggle.title = 'Switch to dark mode'; + } else { + themeToggle.textContent = '☀️'; + themeToggle.title = 'Switch to light mode'; + } + } + } + + // Cookie management functions + function setCookie(name, value, days) { + const expires = new Date(); + expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000)); + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`; + } + + function getCookie(name) { + const nameEQ = name + "="; + const ca = document.cookie.split(';'); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) === ' ') c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); + } + return null; + } + +}); + +// QR Code functionality (global function for inline onclick handlers) +function showQRCode(url, title) { + console.log('🔍 QR Code button clicked!', url, title); + + // Check if QRCode library is available + if (typeof QRCode === 'undefined') { + console.warn('QRCode library not available, using fallback'); + showQRCodeFallback(url, title); + return; + } + + // Create modal overlay + const modal = document.createElement('div'); + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; + `; + + // Create modal content + const modalContent = document.createElement('div'); + modalContent.style.cssText = ` + background: white; + padding: 30px; + border-radius: 10px; + text-align: center; + max-width: 400px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + `; + + // Add title + const titleEl = document.createElement('h3'); + titleEl.textContent = `📱 QR Code for ${title}`; + titleEl.style.marginBottom = '20px'; + modalContent.appendChild(titleEl); + + // Create QR code canvas + const canvas = document.createElement('canvas'); + modalContent.appendChild(canvas); + + // Generate QR code + QRCode.toCanvas(canvas, url, { + width: 256, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + } + }, function(error) { + if (error) { + console.error('QR Code generation failed:', error); + modalContent.innerHTML = '

❌ Failed to generate QR code

'; + } + }); + + // Add close button + const closeButton = document.createElement('button'); + closeButton.textContent = '✕ Close'; + closeButton.style.cssText = ` + margin-top: 20px; + padding: 10px 20px; + background: #f44336; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 16px; + `; + closeButton.onclick = () => document.body.removeChild(modal); + modalContent.appendChild(closeButton); + + // Add URL text (truncated) + const urlText = document.createElement('p'); + urlText.textContent = url.length > 50 ? url.substring(0, 50) + '...' : url; + urlText.style.cssText = ` + font-size: 12px; + color: #666; + margin-top: 15px; + word-break: break-all; + `; + modalContent.appendChild(urlText); + + modal.appendChild(modalContent); + document.body.appendChild(modal); + + // Close on click outside + modal.onclick = (e) => { + if (e.target === modal) { + document.body.removeChild(modal); + } + }; +} + +// Fallback QR code function using online API +function showQRCodeFallback(url, title) { + console.log('🔍 Using QR code fallback for:', title); + + // Create modal overlay + const modal = document.createElement('div'); + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; + `; + + // Create modal content + const modalContent = document.createElement('div'); + modalContent.style.cssText = ` + background: white; + padding: 30px; + border-radius: 10px; + text-align: center; + max-width: 400px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + `; + + // Add title + const titleEl = document.createElement('h3'); + titleEl.textContent = `📱 QR Code for ${title}`; + titleEl.style.marginBottom = '20px'; + modalContent.appendChild(titleEl); + + // Create QR code using API service + const qrImg = document.createElement('img'); + const encodedUrl = encodeURIComponent(url); + qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodedUrl}`; + qrImg.style.cssText = ` + width: 256px; + height: 256px; + border: 2px solid #ddd; + border-radius: 8px; + `; + qrImg.onerror = () => { + qrImg.style.display = 'none'; + const errorMsg = document.createElement('p'); + errorMsg.textContent = '❌ Failed to generate QR code. You can copy the link instead.'; + errorMsg.style.color = 'red'; + modalContent.appendChild(errorMsg); + }; + modalContent.appendChild(qrImg); + + // Add close button + const closeButton = document.createElement('button'); + closeButton.textContent = '✕ Close'; + closeButton.style.cssText = ` + margin-top: 20px; + padding: 10px 20px; + background: #f44336; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 16px; + `; + closeButton.onclick = () => document.body.removeChild(modal); + modalContent.appendChild(closeButton); + + // Add copy button + const copyButton = document.createElement('button'); + copyButton.textContent = '📋 Copy Link'; + copyButton.style.cssText = ` + margin-top: 10px; + margin-left: 10px; + padding: 10px 20px; + background: #4CAF50; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + font-size: 16px; + `; + copyButton.onclick = () => { + navigator.clipboard.writeText(url).then(() => { + copyButton.textContent = '✅ Copied!'; + setTimeout(() => copyButton.textContent = '📋 Copy Link', 2000); + }); + }; + modalContent.appendChild(copyButton); + + // Add URL text (truncated) + const urlText = document.createElement('p'); + urlText.textContent = url.length > 50 ? url.substring(0, 50) + '...' : url; + urlText.style.cssText = ` + font-size: 12px; + color: #666; + margin-top: 15px; + word-break: break-all; + `; + modalContent.appendChild(urlText); + + modal.appendChild(modalContent); + document.body.appendChild(modal); + + // Close on click outside + modal.onclick = (e) => { + if (e.target === modal) { + document.body.removeChild(modal); + } + }; +} diff --git a/src/static/style.css b/src/static/style.css new file mode 100644 index 0000000..c02ad63 --- /dev/null +++ b/src/static/style.css @@ -0,0 +1,1579 @@ +/* CSS Custom Properties for theming */ +:root { + /* Light theme (default) */ + --bg-primary: #f8f9fa; + --bg-secondary: white; + --bg-accent: #e8f5e8; + --text-primary: #333; + --text-secondary: #2e7d32; + --border-color: #4CAF50; + --shadow: rgba(0, 0, 0, 0.1); +} + +/* Paste textarea styling */ +#pasteContent { + width: 100%; + padding: clamp(12px, 3vw, 18px); + font-size: clamp(14px, 4vw, 18px); + line-height: 1.5; + border: 1px solid #ccc; + border-radius: 8px; + margin-bottom: clamp(15px, 4vw, 25px); + background-color: #f9f9f9; + resize: vertical; + box-sizing: border-box; + min-height: clamp(200px, 50vw, 350px); + color: var(--text-primary); + transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease; +} + +/* Dark theme variables */ +[data-theme="dark"] { + --bg-primary: #1a1a1a; + --bg-secondary: #2d2d2d; + --bg-accent: #1e3a1e; + --text-primary: #e0e0e0; + --text-secondary: #81c784; + --border-color: #66bb6a; + --shadow: rgba(0, 0, 0, 0.3); +} + +/* Light theme variables (explicit override for OS dark mode users) */ +[data-theme="light"] { + --bg-primary: #f8f9fa; + --bg-secondary: white; + --bg-accent: #e8f5e8; + --text-primary: #333; + --text-secondary: #2e7d32; + --border-color: #4CAF50; + --shadow: rgba(0, 0, 0, 0.1); +} + +/* General body styling with mobile optimizations */ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); + text-align: center; + margin: 0; + padding: clamp(10px, 2vw, 20px); + line-height: 1.6; + transition: background-color 0.3s ease, color 0.3s ease; + -webkit-text-size-adjust: 100%; /* Prevent text scaling on iOS */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-size: clamp(14px, 2.5vw, 16px); +} + +/* Main container styling */ +.container { + max-width: 1200px; + margin: clamp(20px, 5vw, 50px) auto; + background-color: var(--bg-secondary); + padding: clamp(20px, 5vw, 50px) clamp(15px, 4vw, 50px); + border-radius: clamp(8px, 2vw, 12px); + box-shadow: 0 4px 12px var(--shadow); + min-height: auto; + transition: background-color 0.3s ease, box-shadow 0.3s ease; +} + +/* Heading styling */ +h1 { + font-size: clamp(1.8rem, 5vw, 2.5rem); + color: var(--border-color); + margin-bottom: clamp(20px, 4vw, 30px); + transition: color 0.3s ease; + line-height: 1.2; +} + +/* Theme toggle button */ +.theme-toggle { + position: absolute; + top: clamp(15px, 3vw, 20px); + right: clamp(15px, 3vw, 20px); + background: var(--bg-secondary); + border: 2px solid var(--border-color); + border-radius: 50px; + padding: clamp(10px, 2vw, 16px); + cursor: pointer; + font-size: clamp(16px, 3vw, 18px); + transition: all 0.3s ease; + color: var(--text-primary); + box-shadow: 0 2px 6px var(--shadow); + min-width: 44px; /* Minimum touch target */ + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; +} + +.theme-toggle:hover { + transform: scale(1.05); + box-shadow: 0 4px 12px var(--shadow); +} + +/* Toggle button container */ +.toggle-container { + margin-bottom: clamp(20px, 4vw, 30px); + display: flex; + justify-content: center; + align-items: center; + gap: clamp(8px, 2vw, 15px); + flex-wrap: wrap; +} + +/* Inline expiry selection */ +.expiry-inline { + display: flex; + align-items: center; + gap: 6px; + margin-left: clamp(8px, 2vw, 15px); + padding: clamp(8px, 2vw, 12px); + background-color: var(--bg-secondary); + border-radius: clamp(6px, 1.5vw, 8px); + border: 1px solid var(--border-color); +} + +.expiry-inline .expiry-label { + font-size: clamp(14px, 3vw, 16px); + color: var(--text-secondary); + margin: 0; +} + +.expiry-select-inline { + padding: clamp(4px, 1vw, 6px) clamp(8px, 2vw, 10px); + font-size: clamp(12px, 2.5vw, 14px); + border: 1px solid var(--border-color); + border-radius: clamp(4px, 1vw, 6px); + background-color: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + min-width: 60px; +} + +.custom-expiry-inline { + padding: clamp(4px, 1vw, 6px) clamp(8px, 2vw, 10px); + font-size: clamp(12px, 2.5vw, 14px); + border: 1px solid var(--border-color); + border-radius: clamp(4px, 1vw, 6px); + background-color: var(--bg-secondary); + color: var(--text-primary); + margin-left: 6px; +} + +/* Toggle button styling */ +.toggle-button { + padding: clamp(12px, 3vw, 20px) clamp(16px, 4vw, 24px); + font-size: clamp(15px, 3vw, 16px); + cursor: pointer; + border: none; + background-color: var(--bg-accent); + color: var(--text-primary); + border-radius: clamp(6px, 1.5vw, 8px); + transition: all 0.3s ease; + min-height: 44px; /* Minimum touch target */ + min-width: 100px; + font-weight: 500; + touch-action: manipulation; /* Optimize for touch */ +} + +.toggle-button.active { + background-color: var(--border-color); + color: white; +} + +.toggle-button:hover { + background-color: var(--border-color); + color: white; +} + +/* FAQ button styling */ +.faq-button { + padding: 10px 20px; + font-size: 16px; + background-color: #f0f0f0; + color: #333; + border: none; + border-radius: 5px; + text-decoration: none; + cursor: pointer; + margin-left: 10px; + transition: background-color 0.3s ease, color 0.3s ease; +} + +.faq-button:hover { + background-color: #4CAF50; + color: white; +} + +/* Drag-and-drop area styling */ +.drop-area { + border: 2px dashed var(--border-color); + padding: clamp(40px, 8vw, 90px); + border-radius: clamp(8px, 2vw, 12px); + background-color: var(--bg-accent); + cursor: pointer; + margin-bottom: clamp(20px, 4vw, 30px); + width: 100%; + box-sizing: border-box; + transition: all 0.3s ease; + min-height: clamp(120px, 20vw, 200px); + display: flex; + align-items: center; + justify-content: center; + touch-action: manipulation; +} + +.drop-area p { + margin: 0; + font-size: clamp(16px, 3.5vw, 18px); + font-weight: 500; + pointer-events: none; +} + +.drop-area.dragging { + background-color: #e0e0e0; + border-color: #999; + transform: scale(1.02); +} + +/* Hover effect when the user hovers over the drop area */ +.drop-area:hover { + background-color: #f0f0f0; + border-color: #aaa; +} + +/* Dark theme hover effect for drop area */ +[data-theme="dark"] .drop-area:hover { + background-color: #333333; + border-color: #666666; +} + +/* Clipboard paste visual feedback */ +.drop-area.clipboard-active { + background-color: #e8f5e8; + border-color: #4CAF50; + transform: scale(1.02); + transition: all 0.2s ease; +} + +[data-theme="dark"] .drop-area.clipboard-active { + background-color: #2d4a2d; + border-color: #66bb6a; +} + +/* Mobile-specific hint */ +.mobile-hint { + display: none; + font-size: 0.9em; + color: #666; + margin-top: 0.5rem; +} + +[data-theme="dark"] .mobile-hint { + color: #999; +} + +/* Mobile tutorial styling */ +.mobile-tutorial { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 1rem; + margin-top: 1rem; +} + +.mobile-tutorial h4 { + margin-top: 0; + margin-bottom: 0.5rem; + color: #495057; +} + +.mobile-tutorial ol { + margin-bottom: 0; + padding-left: 1.2rem; +} + +.mobile-tutorial li { + margin-bottom: 0.3rem; +} + +[data-theme="dark"] .mobile-tutorial { + background-color: #2d3748; + border-color: #4a5568; +} + +[data-theme="dark"] .mobile-tutorial h4 { + color: #e2e8f0; +} + +/* Mobile clipboard paste section */ +.mobile-clipboard-section { + display: none; /* Hidden by default, shown via JavaScript on mobile */ + margin: 20px 0; + padding: 15px; + background-color: var(--bg-accent); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.mobile-paste-button { + background: linear-gradient(135deg, #4CAF50, #45a049); + color: white; + border: none; + padding: 12px 24px; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 2px 8px rgba(76, 175, 80, 0.3); + margin-bottom: 8px; + min-width: 200px; +} + +.mobile-paste-button:hover { + background: linear-gradient(135deg, #45a049, #3d8b40); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4); +} + +.mobile-paste-button:active { + transform: translateY(0); + box-shadow: 0 2px 6px rgba(76, 175, 80, 0.3); +} + +.mobile-paste-hint { + color: var(--text-secondary); + font-size: 14px; + margin: 0; + font-style: italic; +} + +[data-theme="dark"] .mobile-paste-button { + background: linear-gradient(135deg, #66bb6a, #5a9f5a); + box-shadow: 0 2px 8px rgba(102, 187, 106, 0.3); +} + +[data-theme="dark"] .mobile-paste-button:hover { + background: linear-gradient(135deg, #5a9f5a, #4f8c4f); + box-shadow: 0 4px 12px rgba(102, 187, 106, 0.4); +} + +/* Clipboard notification/toast */ +.clipboard-toast { + position: fixed; + top: 20px; + right: 20px; + background: #4CAF50; + color: white; + padding: 12px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + animation: slideInToast 0.3s ease-out; + font-size: 14px; + max-width: 300px; +} + +[data-theme="dark"] .clipboard-toast { + background: #66bb6a; + color: #1a1a1a; +} + +@keyframes slideInToast { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* FAQ keyboard shortcut styling */ +kbd { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 2px 6px; + font-family: monospace; + font-size: 0.9em; + box-shadow: 0 1px 2px var(--shadow); +} + +[data-theme="dark"] kbd { + background: #404040; + border-color: #666; + color: var(--text-primary); +} + +/* Hidden file input */ +#fileInput { + display: none; +} + +/* Progress bar container styling */ +.progress-bar { + width: 100%; + background: #e0e0e0; /* Light grey background for unfilled area */ + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + margin: clamp(15px, 4vw, 25px) 0; + box-shadow: + inset 0 2px 4px rgba(0, 0, 0, 0.1), + 0 1px 3px rgba(0, 0, 0, 0.1); + position: relative; + height: clamp(36px, 8vw, 48px); +} + +/* Dark theme progress bar background */ +[data-theme="dark"] .progress-bar { + background: #404040; /* Darker grey for dark theme */ +} + +.progress { + height: 100%; + background: linear-gradient(135deg, #4CAF50 0%, #45a049 50%, #66bb6a 100%); + width: 0%; + transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + border-radius: 11px; /* Slightly smaller radius to fit inside container */ + overflow: hidden; + z-index: 2; /* Ensure it's above the background */ +} + +.progress::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.3) 50%, + transparent + ); + animation: progressShine 2s infinite; +} + +@keyframes progressShine { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +#progressText { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: clamp(12px, 3.5vw, 16px); + font-weight: 600; + color: var(--text-primary); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + z-index: 10; + margin: 0; + width: 100%; + text-align: center; +} + +/* Styling for result messages (uploaded files or errors) */ +.result-message { + margin-top: 20px; + font-size: 18px; + color: #333; +} + +.result-message a { + color: #4CAF50; + text-decoration: none; + font-weight: 500; +} + +.result-message a:hover { + text-decoration: underline; + color: #45a049; +} + +/* Uploaded file list styling */ +.uploaded-file { + margin-top: 10px; + display: flex; + align-items: center; + gap: 10px; +} + +.uploaded-file a { + color: #4CAF50; + text-decoration: none; + font-weight: 500; + font-size: 16px; +} + +.uploaded-file a:hover { + text-decoration: underline; + color: #45a049; +} + +/* Thumbnail styling for previews */ +.thumbnail-small { + width: 50px; + height: 50px; + object-fit: cover; + border-radius: 5px; + border: 1px solid #ddd; +} + +/* Pastebin textarea styling */ +#pasteContent { + width: 100%; + padding: 15px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 5px; + margin-bottom: 20px; + background-color: #f9f9f9; + resize: vertical; + box-sizing: border-box; + min-height: 300px; /* Increase the minimum height */ + color: var(--text-primary); + transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease; +} + +/* Dark theme styling for paste textarea */ +[data-theme="dark"] #pasteContent { + background-color: var(--bg-secondary); + border-color: #555555; + color: var(--text-primary); +} + +/* Submit button styling */ +.submit-button { + padding: clamp(12px, 3vw, 16px) clamp(20px, 5vw, 32px); + background-color: #4CAF50; + color: white; + border: none; + cursor: pointer; + border-radius: 8px; + font-size: clamp(14px, 4vw, 18px); + font-weight: 600; + min-height: 44px; + min-width: 88px; + transition: background-color 0.3s ease, transform 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.submit-button:hover { + background-color: #45a049; + transform: translateY(-1px); +} + +.submit-button:active { + transform: translateY(0); +} + +/* Raw view and button for view_paste.html */ +pre { + background-color: #f4f4f4; + border: 1px solid #ddd; + padding: clamp(12px, 4vw, 24px); + border-radius: 8px; + text-align: left; + white-space: pre-wrap; + word-wrap: break-word; + font-size: clamp(12px, 3.5vw, 16px); + line-height: 1.6; + overflow-x: auto; + max-width: 100%; + box-sizing: border-box; +} + +/* Button container and button styling */ +.button-container { + margin-top: clamp(15px, 4vw, 25px); + display: flex; + flex-wrap: wrap; + gap: clamp(8px, 2vw, 12px); + align-items: center; + justify-content: flex-start; +} + +/* Image preview styling */ +.image-preview { + margin: clamp(15px, 4vw, 20px) 0 !important; + text-align: center; + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: clamp(10px, 3vw, 15px) !important; + background: #f9f9f9 !important; + transition: border-color 0.3s ease, background-color 0.3s ease; +} + +/* File result bar layout - horizontal layout */ +.file-result-bar { + display: flex !important; + align-items: center !important; + gap: clamp(10px, 2.5vw, 15px) !important; + flex-wrap: wrap !important; + padding: clamp(12px, 3vw, 18px) !important; + margin-bottom: clamp(12px, 3vw, 18px) !important; + border: 2px solid #4CAF50 !important; + border-radius: 8px !important; + background-color: #f0f8f0 !important; +} + +.file-info-section { + flex: 1 !important; + min-width: 160px !important; +} + +.file-info-section .file-name { + font-weight: bold !important; + margin-bottom: 4px !important; +} + +.file-info-section .file-size { + font-size: clamp(12px, 3vw, 14px) !important; + color: #666 !important; +} + +.file-buttons-section { + display: flex !important; + gap: clamp(6px, 1.5vw, 10px) !important; + flex-wrap: wrap !important; +} + +.file-buttons-section button { + padding: clamp(6px, 1.5vw, 8px) clamp(8px, 2vw, 12px) !important; + font-size: clamp(11px, 2.5vw, 13px) !important; +} + +.file-thumbnail { + width: clamp(50px, 12vw, 70px) !important; + height: clamp(50px, 12vw, 70px) !important; + object-fit: cover !important; + border-radius: 6px !important; + border: 1px solid #ddd !important; + flex-shrink: 0 !important; +} + +.image-preview img { + max-width: 100% !important; + max-height: clamp(120px, 30vw, 200px) !important; + border-radius: 4px !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important; + object-fit: contain !important; + transition: transform 0.2s ease; +} + +.image-preview img:hover { + transform: scale(1.02); +} + +/* Dark theme image preview */ +[data-theme="dark"] .image-preview { + background: #2d2d2d !important; + border-color: #555555; +} + +[data-theme="dark"] .image-preview img { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important; +} + +.button { + padding: 10px 20px; + background-color: #4CAF50; + color: white; + border: none; + cursor: pointer; + border-radius: 5px; + transition: background-color 0.3s ease; +} + +.button:hover { + background-color: #45a049; +} + +/* Raw link styling */ +.raw-link { + display: block; + margin-top: 10px; + font-size: 14px; + color: #333; + text-decoration: none; +} + +.raw-link:hover { + text-decoration: underline; +} + +/* Dark theme styling using prefers-color-scheme (only when no explicit theme is set) */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + --bg-primary: #1a1a1a; + --bg-secondary: #2d2d2d; + --bg-accent: #1e3a1e; + --text-primary: #e0e0e0; + --text-secondary: #81c784; + --border-color: #66bb6a; + --shadow: rgba(0, 0, 0, 0.3); + } +} + +/* Media query for mobile responsiveness */ +/* Enhanced mobile responsiveness */ +@media only screen and (max-width: 768px) { + body { + padding: 10px; + font-size: 16px; /* Prevent zoom on iOS */ + } + + .container { + margin: 20px auto; + padding: 20px 15px; + border-radius: 8px; + min-height: auto; + box-shadow: 0 2px 8px var(--shadow); + } + + h1 { + font-size: 2.2em; + margin-bottom: 20px; + line-height: 1.2; + } + + /* Show mobile hint on smaller screens */ + .mobile-hint { + display: block; + } + + /* Theme toggle - better mobile positioning */ + .theme-toggle { + top: 15px; + right: 15px; + padding: 10px 14px; + font-size: 18px; + border-radius: 25px; + min-width: 60px; + } + + /* Toggle buttons - stack vertically on very small screens */ + .toggle-container { + flex-direction: column; + gap: 10px; + margin-bottom: 25px; + } + + .toggle-button { + padding: 12px 20px; + font-size: 16px; + border-radius: 8px; + width: 100%; + max-width: 200px; + margin: 0; + touch-action: manipulation; /* Prevent double-tap zoom */ + } + + /* Mobile expiry inline styles */ + .expiry-inline { + margin-left: 0; + margin-top: 10px; + width: 100%; + max-width: 200px; + justify-content: center; + padding: 10px; + } + + .expiry-select-inline { + min-width: 70px; + font-size: 14px; + } + + /* Drop area improvements */ + .drop-area { + padding: 40px 20px; + border-radius: 8px; + border: 3px dashed var(--border-color); + margin: 20px 0; + min-height: 120px; + display: flex; + flex-direction: column; + justify-content: center; + } + + .drop-area p { + font-size: 16px; + margin: 8px 0; + line-height: 1.4; + } + + /* File input styling for mobile */ + .file-input-wrapper { + margin: 15px 0; + } + + .file-input-wrapper input[type="file"] { + width: 100%; + padding: 12px; + font-size: 16px; + border: 2px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + } + + /* Progress bar enhancements */ + .progress { + border-radius: 11px; /* Match the main style */ + } + + .progress-bar { + border-radius: 12px; + background: #e0e0e0; /* Consistent background */ + } + + /* Dark theme mobile progress bar */ + [data-theme="dark"] .progress-bar { + background: #404040; + } + + #progressText { + font-size: 15px; + padding: 8px; + font-weight: 500; + } + + /* Textarea improvements */ + #pasteContent { + font-size: 16px; + padding: 15px; + border-radius: 8px; + border: 2px solid var(--border-color); + min-height: 150px; + resize: vertical; + line-height: 1.4; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + } + + /* Button improvements */ + button { + padding: 12px 20px; + font-size: 16px; + border-radius: 8px; + border: none; + cursor: pointer; + touch-action: manipulation; + min-height: 48px; /* Touch target size */ + margin: 5px; + transition: all 0.2s ease; + } + + button:hover, button:focus { + transform: translateY(-1px); + box-shadow: 0 4px 8px var(--shadow); + } + + button:active { + transform: translateY(0); + } + + /* Results display */ + #result { + margin-top: 25px; + padding: 15px; + border-radius: 8px; + font-size: 15px; + } + + /* Share buttons in results */ + .share-button, .view-button, .qr-button { + padding: clamp(10px, 2.5vw, 14px) clamp(16px, 4vw, 20px) !important; + font-size: clamp(12px, 3.5vw, 16px) !important; + margin: clamp(3px, 1vw, 6px); + border-radius: 8px; + min-width: clamp(100px, 25vw, 130px); + min-height: 44px; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; + } + + /* Code blocks */ + pre { + font-size: 14px; + padding: 15px; + border-radius: 8px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + code { + font-size: 14px; + word-break: break-word; + } +} + +/* Specific optimizations for very small screens */ +@media only screen and (max-width: 480px) { + .container { + margin: 10px auto; + padding: 15px 10px; + } + + h1 { + font-size: 1.8em; + margin-bottom: 15px; + } + + .theme-toggle { + top: 10px; + right: 10px; + padding: 8px 12px; + font-size: 16px; + } + + .drop-area { + padding: 30px 15px; + min-height: 100px; + } + + .drop-area p { + font-size: 15px; + } + + .toggle-button { + padding: 10px 16px; + font-size: 15px; + } + + #pasteContent { + min-height: 120px; + padding: 12px; + } + + .progress { + border-radius: 10px; /* Slightly smaller for very small screens */ + } + + .progress-bar { + background: #e0e0e0; + } + + [data-theme="dark"] .progress-bar { + background: #404040; + } + + #progressText { + font-size: 14px; + } + + .thumbnail-small { + width: 40px; + height: 40px; + } +} + +/* QR Code button styling */ +.qr-button { + padding: clamp(10px, 2.5vw, 14px) clamp(15px, 4vw, 20px) !important; + background-color: #9C27B0 !important; + color: white !important; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: clamp(12px, 3.5vw, 16px); + font-weight: 600; + min-height: 44px; + min-width: 88px; + transition: background-color 0.3s ease, transform 0.2s ease; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; +} + +.qr-button:hover { + background-color: #7B1FA2 !important; + transform: translateY(-1px); +} + +/* Paste and File Viewing Styles */ +.paste-container, .file-container { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + margin: 20px 0; + overflow: hidden; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.paste-header, .file-header { + background: var(--bg-secondary); + padding: 15px 20px; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.paste-header h3, .file-header h3 { + margin: 0; + color: var(--text-primary); + font-size: 1.2rem; +} + +.paste-actions, .file-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.copy-button, .raw-button, .download-button, .home-button { + padding: 8px 16px; + border: none; + border-radius: 4px; + text-decoration: none; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + gap: 5px; +} + +.copy-button { + background: #4CAF50; + color: white; +} + +.copy-button:hover { + background: #45a049; + transform: translateY(-1px); +} + +.raw-button { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.raw-button:hover { + background: var(--text-secondary); + color: var(--bg-primary); +} + +.download-button { + background: #2196F3; + color: white; +} + +.download-button:hover { + background: #1976D2; + transform: translateY(-1px); +} + +.home-button { + background: var(--text-secondary); + color: var(--bg-primary); +} + +.home-button:hover { + background: var(--text-primary); + transform: translateY(-1px); +} + +.paste-content { + padding: 20px; + max-height: 600px; + overflow-y: auto; +} + +.paste-content pre { + margin: 0; + padding: 0; + background: none; + border: none; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 14px; + line-height: 1.5; + color: var(--text-primary); + white-space: pre-wrap; + word-wrap: break-word; +} + +.file-info { + padding: 15px 20px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +.file-info p { + margin: 5px 0; + color: var(--text-secondary); + font-size: 14px; +} + +.file-preview { + padding: 20px; +} + +.file-preview img { + max-width: 100%; + height: auto; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.file-preview pre { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 15px; + margin: 0; + max-height: 400px; + overflow-y: auto; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 14px; + line-height: 1.5; +} + +/* Enhanced responsive design for viewing pages */ +@media (max-width: 768px) { + .paste-header, .file-header { + flex-direction: column; + align-items: stretch; + text-align: center; + gap: 15px; + } + + .paste-actions, .file-actions { + justify-content: center; + flex-wrap: wrap; + gap: 10px; + } + + .paste-actions button, .file-actions button { + min-width: 140px; + padding: 12px 16px; + font-size: 15px; + } + + .paste-content, .file-preview { + padding: 20px 15px; + border-radius: 8px; + margin: 15px 0; + } + + .paste-content pre { + font-size: 14px; + line-height: 1.4; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .file-preview img { + max-width: 100%; + height: auto; + border-radius: 8px; + } + + .file-info { + margin: 15px 0; + padding: 15px; + background: var(--bg-accent); + border-radius: 8px; + } + + .file-info p { + margin: 8px 0; + font-size: 15px; + } +} + +/* Touch-friendly improvements */ +@media (hover: none) and (pointer: coarse) { + /* This targets touch devices */ + + button, .toggle-button, .theme-toggle { + min-height: 44px; /* iOS recommended touch target */ + min-width: 44px; + } + + /* Remove hover effects on touch devices */ + button:hover, .toggle-button:hover, .theme-toggle:hover { + transform: none; + } + + /* Better active states for touch */ + button:active, .toggle-button:active { + transform: scale(0.95); + opacity: 0.8; + } + + /* Improve scrollable areas */ + .paste-content, #pasteContent, pre { + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; + } +} + +/* Landscape phone optimizations */ +@media only screen and (max-width: 896px) and (orientation: landscape) { + .container { + margin: 15px auto; + padding: 20px 25px; + } + + h1 { + font-size: 2em; + margin-bottom: 15px; + } + + .toggle-container { + flex-direction: row; + justify-content: center; + margin-bottom: 20px; + } + + .toggle-button { + width: auto; + margin: 0 5px; + } +} + +/* Context Menu Styles */ +.context-menu { + position: fixed; + background: white; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 1000; + padding: 8px 0; + min-width: 180px; +} + +[data-theme="dark"] .context-menu { + background: var(--bg-secondary); + border-color: #555; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.context-menu-item { + display: block; + width: 100%; + padding: 12px 16px; + border: none; + background: none; + text-align: left; + cursor: pointer; + font-size: 16px; + color: var(--text-primary); + transition: background-color 0.2s ease; +} + +.context-menu-item:hover { + background-color: #f0f0f0; +} + +[data-theme="dark"] .context-menu-item:hover { + background-color: #333; +} + +.context-menu-item:active { + background-color: #e0e0e0; +} + +[data-theme="dark"] .context-menu-item:active { + background-color: #444; +} + +/* Expiry Section Styles */ +.expiry-section { + margin: 20px 0; + padding: 15px; + background-color: var(--bg-secondary); + border-radius: 8px; + border-left: 4px solid #2196F3; +} + +.expiry-label { + display: block; + font-weight: 600; + margin-bottom: 8px; + color: var(--text-primary); + font-size: 14px; +} + +.expiry-select, .custom-expiry { + width: 100%; + max-width: 300px; + padding: 8px 12px; + border: 2px solid #ddd; + border-radius: 6px; + font-size: 14px; + background-color: white; + color: var(--text-primary); + transition: border-color 0.3s ease; +} + +.expiry-select:focus, .custom-expiry:focus { + outline: none; + border-color: #2196F3; +} + +[data-theme="dark"] .expiry-select, +[data-theme="dark"] .custom-expiry { + background-color: var(--bg-primary); + border-color: #555; + color: var(--text-primary); +} + +[data-theme="dark"] .expiry-select:focus, +[data-theme="dark"] .custom-expiry:focus { + border-color: #2196F3; +} + +.expiry-hint { + margin: 8px 0 0 0; + font-size: 12px; + color: var(--text-secondary); + font-style: italic; +} + +.custom-expiry { + margin-top: 10px; + transition: opacity 0.3s ease; +} + +/* Mobile expiry section adjustments */ +@media only screen and (max-width: 768px) { + .expiry-section { + margin: 15px 0; + padding: 12px; + } + + .expiry-select, .custom-expiry { + max-width: 100%; + font-size: 16px; /* Prevent zoom on iOS */ + } +} + +/* Mobile/Desktop specific visibility */ +.mobile-only { + display: none; +} + +.desktop-only { + display: block; +} + +/* Show mobile-only content on mobile devices */ +@media only screen and (max-width: 768px) { + .mobile-only { + display: block; + } + + .desktop-only { + display: none; + } +} + +/* URL Shortener Styles */ +.url-input-container { + display: flex; + flex-direction: column; + gap: 20px; + margin-bottom: 25px; +} + +.url-label { + font-size: 18px; + font-weight: bold; + color: var(--text-primary); + margin-bottom: 8px; +} + +.url-input { + width: 100%; + padding: 15px; + font-size: 16px; + border: 2px solid var(--border-color); + border-radius: 8px; + background-color: var(--bg-secondary); + color: var(--text-primary); + transition: all 0.3s ease; + box-sizing: border-box; +} + +.url-input:focus { + outline: none; + border-color: #388e3c; + box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.2); +} + +.url-options { + display: flex; + flex-wrap: wrap; + gap: 20px; + margin-bottom: 20px; +} + +.option-group { + flex: 1; + min-width: 200px; +} + +.option-label { + display: block; + font-weight: bold; + color: var(--text-primary); + margin-bottom: 5px; + font-size: 14px; +} + +.custom-code-input { + width: 100%; + padding: 10px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 6px; + background-color: var(--bg-secondary); + color: var(--text-primary); + transition: all 0.3s ease; + box-sizing: border-box; +} + +.custom-code-input:focus { + outline: none; + border-color: var(--border-color); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2); +} + +.url-expiry-select { + width: 100%; + padding: 10px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 6px; + background-color: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; +} + +.option-hint { + font-size: 12px; + color: #666; + margin-top: 3px; + font-style: italic; +} + +/* URL Preview Container */ +.url-preview-container { + background-color: var(--bg-accent); + border: 2px solid var(--border-color); + border-radius: 12px; + padding: 20px; + margin-top: 20px; + animation: slideDown 0.3s ease-out; +} + +.url-preview h3 { + color: var(--text-primary); + margin-bottom: 15px; + font-size: 18px; +} + +.short-url-display { + display: flex; + gap: 10px; + margin-bottom: 15px; + align-items: center; +} + +.short-url-input { + flex: 1; + padding: 12px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 6px; + background-color: var(--bg-secondary); + color: var(--text-primary); + font-family: 'Courier New', monospace; + font-weight: bold; +} + +.copy-button { + padding: 12px 16px; + font-size: 16px; + background-color: var(--border-color); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.3s ease; + white-space: nowrap; +} + +.copy-button:hover { + background-color: #388e3c; + transform: translateY(-2px); +} + +.url-details { + font-size: 14px; + color: var(--text-primary); + line-height: 1.6; +} + +.url-details p { + margin: 8px 0; +} + +.url-details strong { + color: var(--text-secondary); +} + +/* Responsive adjustments for URL shortener */ +@media only screen and (max-width: 768px) { + .url-options { + flex-direction: column; + gap: 15px; + } + + .option-group { + min-width: unset; + } + + .short-url-display { + flex-direction: column; + gap: 10px; + } + + .copy-button { + align-self: stretch; + text-align: center; + } +} + +/* Animation for URL preview */ +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/storage.py b/src/storage.py new file mode 100644 index 0000000..fd43d8e --- /dev/null +++ b/src/storage.py @@ -0,0 +1,345 @@ +""" +Storage abstraction layer for Sharey +Supports multiple storage backends: local filesystem and Backblaze B2 +""" +import json +import os +import shutil +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional, BinaryIO +from b2sdk.v2 import InMemoryAccountInfo, B2Api + + +class StorageBackend(ABC): + """Abstract base class for storage backends""" + + @abstractmethod + def upload_file(self, file_content: bytes, file_path: str, content_type: str = None, metadata: Optional[dict] = None) -> bool: + """Upload a file to storage with optional metadata""" + pass + + @abstractmethod + def download_file(self, file_path: str) -> bytes: + """Download a file from storage""" + pass + + @abstractmethod + def delete_file(self, file_path: str) -> bool: + """Delete a file from storage""" + pass + + @abstractmethod + def file_exists(self, file_path: str) -> bool: + """Check if a file exists in storage""" + pass + + @abstractmethod + def get_file_size(self, file_path: str) -> Optional[int]: + """Get file size in bytes""" + pass + + @abstractmethod + def list_files(self, prefix: str = "") -> list: + """List files with optional prefix""" + pass + + @abstractmethod + def get_metadata(self, file_path: str) -> Optional[dict]: + """Get file metadata""" + pass + + +class LocalStorageBackend(StorageBackend): + """Local filesystem storage backend""" + + def __init__(self, base_path: str = "storage"): + self.base_path = Path(base_path) + self.base_path.mkdir(exist_ok=True) + print(f"✅ Local storage initialized at: {self.base_path.absolute()}") + + def _get_full_path(self, file_path: str) -> Path: + """Get full filesystem path for a file""" + return self.base_path / file_path + + def upload_file(self, file_content: bytes, file_path: str, content_type: str = None, metadata: Optional[dict] = None) -> bool: + """Upload a file to local storage with optional metadata""" + try: + full_path = self._get_full_path(file_path) + full_path.parent.mkdir(parents=True, exist_ok=True) + + # Write the main file + with open(full_path, 'wb') as f: + f.write(file_content) + + # Write metadata to a .meta file if provided + if metadata: + import json + meta_path = full_path.with_suffix(full_path.suffix + '.meta') + with open(meta_path, 'w') as f: + json.dump(metadata, f) + print(f"📄 Metadata saved for: {file_path}") + + print(f"✅ Local upload successful: {file_path}") + return True + except Exception as e: + print(f"❌ Local upload failed for {file_path}: {e}") + return False + + def download_file(self, file_path: str) -> bytes: + """Download a file from local storage""" + try: + full_path = self._get_full_path(file_path) + with open(full_path, 'rb') as f: + return f.read() + except Exception as e: + print(f"❌ Local download failed for {file_path}: {e}") + raise FileNotFoundError(f"File not found: {file_path}") + + def delete_file(self, file_path: str) -> bool: + """Delete a file from local storage""" + try: + full_path = self._get_full_path(file_path) + if full_path.exists(): + full_path.unlink() + print(f"✅ Local file deleted: {file_path}") + return True + return False + except Exception as e: + print(f"❌ Local delete failed for {file_path}: {e}") + return False + + def file_exists(self, file_path: str) -> bool: + """Check if a file exists in local storage""" + return self._get_full_path(file_path).exists() + + def get_file_size(self, file_path: str) -> Optional[int]: + """Get file size in bytes""" + try: + full_path = self._get_full_path(file_path) + if full_path.exists(): + return full_path.stat().st_size + return None + except Exception: + return None + + def list_files(self, prefix: str = "") -> list: + """List files with optional prefix""" + try: + if prefix: + search_path = self.base_path / prefix + if search_path.is_dir(): + return [str(p.relative_to(self.base_path)) for p in search_path.rglob("*") if p.is_file()] + else: + # Search for files matching prefix pattern + parent = search_path.parent + if parent.exists(): + pattern = search_path.name + "*" + return [str(p.relative_to(self.base_path)) for p in parent.glob(pattern) if p.is_file()] + else: + return [str(p.relative_to(self.base_path)) for p in self.base_path.rglob("*") if p.is_file()] + return [] + except Exception as e: + print(f"❌ Local list failed: {e}") + return [] + + def get_metadata(self, file_path: str) -> Optional[dict]: + """Get file metadata from local storage""" + try: + meta_path = self._get_full_path(file_path + '.meta') + if meta_path.exists(): + with open(meta_path, 'r') as f: + metadata = json.load(f) + return metadata + return None + except Exception as e: + print(f"❌ Local metadata read failed for {file_path}: {e}") + return None + + +class B2StorageBackend(StorageBackend): + """Backblaze B2 cloud storage backend""" + + def __init__(self, key_id: str, key: str, bucket_name: str): + self.key_id = key_id + self.key = key + self.bucket_name = bucket_name + + try: + info = InMemoryAccountInfo() + self.b2_api = B2Api(info) + self.b2_api.authorize_account("production", key_id, key) + self.bucket = self.b2_api.get_bucket_by_name(bucket_name) + print(f"✅ B2 storage initialized with bucket: {bucket_name}") + except Exception as e: + print(f"❌ Failed to initialize B2 storage: {e}") + raise + + def upload_file(self, file_content: bytes, file_path: str, content_type: str = None, metadata: Optional[dict] = None) -> bool: + """Upload a file to B2 storage with optional metadata""" + try: + # Prepare file info (metadata) for B2 + file_info = {} + if metadata: + # B2 metadata keys must be strings and values must be strings + for key, value in metadata.items(): + file_info[str(key)] = str(value) + + self.bucket.upload_bytes( + data_bytes=file_content, + file_name=file_path, + content_type=content_type or 'application/octet-stream', + file_info=file_info + ) + print(f"✅ B2 upload successful: {file_path}") + if metadata: + print(f"📄 B2 metadata saved for: {file_path}") + return True + except Exception as e: + print(f"❌ B2 upload failed for {file_path}: {e}") + return False + + def download_file(self, file_path: str) -> bytes: + """Download a file from B2 storage""" + try: + download_response = self.bucket.download_file_by_name(file_path) + return download_response.response.content + except Exception as e: + print(f"❌ B2 download failed for {file_path}: {e}") + raise FileNotFoundError(f"File not found: {file_path}") + + def delete_file(self, file_path: str) -> bool: + """Delete a file from B2 storage""" + try: + file_info = self.bucket.get_file_info_by_name(file_path) + self.bucket.delete_file_version(file_info.id_, file_info.file_name) + print(f"✅ B2 file deleted: {file_path}") + return True + except Exception as e: + print(f"❌ B2 delete failed for {file_path}: {e}") + return False + + def file_exists(self, file_path: str) -> bool: + """Check if a file exists in B2 storage""" + try: + self.bucket.get_file_info_by_name(file_path) + return True + except Exception: + return False + + def get_file_size(self, file_path: str) -> Optional[int]: + """Get file size in bytes""" + try: + file_info = self.bucket.get_file_info_by_name(file_path) + return file_info.size + except Exception: + return None + + def list_files(self, prefix: str = "") -> list: + """List files with optional prefix""" + try: + file_list = [] + # Use the basic ls() method without parameters first + for file_version, folder in self.bucket.ls(): + if prefix == "" or file_version.file_name.startswith(prefix): + file_list.append(file_version.file_name) + return file_list + except Exception as e: + print(f"❌ B2 list failed: {e}") + return [] + + def get_metadata(self, file_path: str) -> Optional[dict]: + """Get file metadata from B2 storage""" + try: + # Get file info from B2 + file_info = self.bucket.get_file_info_by_name(file_path) + + # B2 stores metadata in file_info attribute + metadata = {} + if hasattr(file_info, 'file_info') and file_info.file_info: + metadata.update(file_info.file_info) + + return metadata if metadata else None + + except Exception as e: + print(f"❌ B2 metadata read failed for {file_path}: {e}") + return None + + +class StorageManager: + """Storage manager that handles different backends""" + + def __init__(self, config): + self.config = config + self.backend = self._initialize_backend() + + def _initialize_backend(self) -> StorageBackend: + """Initialize the appropriate storage backend based on configuration""" + storage_config = self.config.get('storage', {}) + backend_type = storage_config.get('backend', 'b2').lower() + + if backend_type == 'local': + storage_path = storage_config.get('local_path', 'storage') + return LocalStorageBackend(storage_path) + + elif backend_type == 'b2': + # Validate B2 configuration + if not self.config.validate_b2_config(): + raise ValueError("Invalid B2 configuration") + + b2_config = self.config.get_b2_config() + return B2StorageBackend( + key_id=b2_config['key_id'], + key=b2_config['key'], + bucket_name=b2_config['bucket_name'] + ) + + else: + raise ValueError(f"Unknown storage backend: {backend_type}") + + def upload_file(self, file_content: bytes, file_path: str, content_type: str = None, metadata: Optional[dict] = None) -> bool: + """Upload a file using the configured backend with optional metadata""" + return self.backend.upload_file(file_content, file_path, content_type, metadata) + + def download_file(self, file_path: str) -> bytes: + """Download a file using the configured backend""" + return self.backend.download_file(file_path) + + def delete_file(self, file_path: str) -> bool: + """Delete a file using the configured backend""" + return self.backend.delete_file(file_path) + + def file_exists(self, file_path: str) -> bool: + """Check if a file exists using the configured backend""" + return self.backend.file_exists(file_path) + + def get_file_size(self, file_path: str) -> Optional[int]: + """Get file size using the configured backend""" + return self.backend.get_file_size(file_path) + + def list_files(self, prefix: str = "") -> list: + """List files using the configured backend""" + return self.backend.list_files(prefix) + + def get_metadata(self, file_path: str) -> Optional[dict]: + """Get file metadata using the configured backend""" + return self.backend.get_metadata(file_path) + + def get_backend_type(self) -> str: + """Get the current backend type""" + storage_config = self.config.get('storage', {}) + return storage_config.get('backend', 'b2').lower() + + def get_backend_info(self) -> dict: + """Get information about the current backend""" + backend_type = self.get_backend_type() + info = {'type': backend_type} + + if backend_type == 'local': + storage_config = self.config.get('storage', {}) + info['path'] = storage_config.get('local_path', 'storage') + elif backend_type == 'b2': + b2_config = self.config.get_b2_config() + info['bucket'] = b2_config.get('bucket_name', 'unknown') + + return info diff --git a/src/templates/404.html b/src/templates/404.html new file mode 100644 index 0000000..9f7f21a --- /dev/null +++ b/src/templates/404.html @@ -0,0 +1,112 @@ + + + + + + {{ title or "404 - Not Found" }} - Sharey + + + + +
+
+

📁 Sharey

+ +
+ +
+
+
+

{{ title or "Content Not Found" }}

+

{{ message or "The requested content could not be found." }}

+ +
+ 🏠 Go Home + +
+ +
+

What happened?

+
    +
  • The file or paste might have been deleted
  • +
  • The URL might be typed incorrectly
  • +
  • The content might have expired
  • +
+
+
+
+
+ + + + + diff --git a/src/templates/admin.html b/src/templates/admin.html new file mode 100644 index 0000000..848714d --- /dev/null +++ b/src/templates/admin.html @@ -0,0 +1,278 @@ + + + + + + Sharey Admin Panel + + + + + + + + + + + + + 🚪 Logout + +
+
+
+

🛠️ Sharey Admin Panel

+

Manage your Sharey instance

+
+ + +
+

� Service Status

+
+ {% if maintenance_enabled %} +
+ + Maintenance Mode +
+ {% else %} +
+ + Online +
+ {% endif %} +
+
+ + +
+

🔧 Maintenance Mode

+

Current Status: + {% if maintenance_enabled %} + 🟡 ENABLED + {% else %} + 🟢 DISABLED + {% endif %} +

+ +
+ + + + + + + {% if maintenance_enabled %} + + {% else %} + + {% endif %} +
+
+ + +
+

⚙️ Configuration

+
+ + + + + + + +
+
+ + +
+

⚡ Quick Actions

+ 📈 Detailed Statistics + 🩺 Health Check + 🏠 View Site + +
+
+
+ + + + diff --git a/src/templates/admin_login.html b/src/templates/admin_login.html new file mode 100644 index 0000000..de72bc7 --- /dev/null +++ b/src/templates/admin_login.html @@ -0,0 +1,196 @@ + + + + + + Share <!-- Theme toggle button --> + <button id="themeToggle" class="theme-toggle" title="Switch to dark mode">🌙</button>Admin Login + + + + + + + + + + + +
+ +
+ + + + diff --git a/src/templates/index.html b/src/templates/index.html new file mode 100644 index 0000000..dafb867 --- /dev/null +++ b/src/templates/index.html @@ -0,0 +1,288 @@ + + + + + + + + + Sharey + + + + + + + + + + + +
+

Sharey~

+ + +
+ + + + + + +
+ + + +
+
+ + +
+
+
📁
+

Drop files here, click to select, or press Ctrl+V to paste from clipboard

+ + +
+ + + + + + + +
+
+

Ready to upload

+
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + diff --git a/src/templates/index_backup.html b/src/templates/index_backup.html new file mode 100644 index 0000000..09866fc --- /dev/null +++ b/src/templates/index_backup.html @@ -0,0 +1,102 @@ + + +< + ad> + + + Sharey + + + + + + + + + + + +
+

Sharey~

+ + +
+ + + +
+ + +
+
+

Drag & Drop Files Here or Click to Select

+ +
+ + +

0%

+ +
+ + + +
+ + + + + + +
+ + + + + + diff --git a/src/templates/maintenance.html b/src/templates/maintenance.html new file mode 100644 index 0000000..994a30e --- /dev/null +++ b/src/templates/maintenance.html @@ -0,0 +1,127 @@ + + + + + 🌙h=device-width, initial-scale=1.0"> + Sharey - Maintenance + + + + + + + + + + + +
+
+
🔧
+

Under Maintenance

+

{{ message }}

+ + {% if estimated_return %} +
+ Estimated return: {{ estimated_return }} +
+ {% endif %} + + 🔄 Check Again +
+
+ + + + diff --git a/src/templates/view_file.html b/src/templates/view_file.html new file mode 100644 index 0000000..2c25576 --- /dev/null +++ b/src/templates/view_file.html @@ -0,0 +1,137 @@ + + + + + + + + + File - Sharey + + + + + + + + + +
+

📁 File View

+ +
+
+

{{ filename }}

+ +
+ +
+

Size: {{ file_size }}

+

Type: {{ file_type }}

+
+ + {% if is_viewable %} +
+ {% if file_type.startswith('image/') %} + {{ filename }} + {% elif file_type.startswith('text/') or file_type == 'application/json' %} +
{{ file_content | e }}
+ {% else %} +

Preview not available for this file type.

+ {% endif %} +
+ {% else %} +
+

📄 This file cannot be previewed. Click download to view it.

+
+ {% endif %} +
+
+ + + + diff --git a/src/templates/view_paste.html b/src/templates/view_paste.html new file mode 100644 index 0000000..6866670 --- /dev/null +++ b/src/templates/view_paste.html @@ -0,0 +1,134 @@ + + + + + + + + + Paste - Sharey + + + + + + + + + +
+
+
+

Paste Content

+
+ + 📄 Raw + 🏠 Home +
+
+ +
+
{{ content | e }}
+
+
+
+ + + + diff --git a/start_production.sh b/start_production.sh new file mode 100644 index 0000000..64fedc8 --- /dev/null +++ b/start_production.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Production startup script for Sharey with Gunicorn +# Usage: ./start_production.sh + +set -e + +echo "🚀 Starting Sharey in Production Mode with Gunicorn" +echo "==================================================" + +# Check if we're in the right directory +if [ ! -f "wsgi.py" ]; then + echo "❌ Error: wsgi.py not found. Please run this script from the Sharey root directory." + exit 1 +fi + +# Check if virtual environment exists +if [ ! -d ".venv" ]; then + echo "❌ Virtual environment not found. Please run setup first." + exit 1 +fi + +# Activate virtual environment +echo "🔌 Activating virtual environment..." +source .venv/bin/activate + +# Check if config exists +if [ ! -f "config.json" ]; then + echo "⚠️ Warning: config.json not found. Creating from example..." + cp config.json.example config.json + echo "✏️ Please edit config.json with your settings and run again." + exit 1 +fi + +# Create logs directory +mkdir -p logs + +# Validate configuration +echo "🔧 Validating configuration..." +python src/config_util.py validate || { + echo "❌ Configuration validation failed. Please fix your config.json" + exit 1 +} + +# Test storage backend +echo "🧪 Testing storage backend..." +python test_storage.py || { + echo "❌ Storage test failed. Please check your configuration." + exit 1 +} + +echo "✅ All checks passed!" +echo "" +echo "🌟 Starting Gunicorn server..." +echo "🌐 Server will be available at: http://0.0.0.0:8866" +echo "📁 Working directory: $(pwd)" +echo "📊 Workers: $(python -c 'import multiprocessing; print(multiprocessing.cpu_count() * 2 + 1)')" +echo "📝 Logs: logs/gunicorn_access.log, logs/gunicorn_error.log" +echo "" +echo "Press Ctrl+C to stop" +echo "==================================================" + +# Start Gunicorn +exec gunicorn --config gunicorn.conf.py wsgi:application diff --git a/storage/files/GAo01R.png b/storage/files/GAo01R.png new file mode 100644 index 0000000..d2a6a2a Binary files /dev/null and b/storage/files/GAo01R.png differ diff --git a/storage/files/GAo01R.png.meta b/storage/files/GAo01R.png.meta new file mode 100644 index 0000000..7adc98b --- /dev/null +++ b/storage/files/GAo01R.png.meta @@ -0,0 +1 @@ +{"uploaded_at": "2025-09-08T19:42:27.845206", "original_name": "Screenshot from 2025-09-08 18-31-10.png", "content_type": "image/png", "expires_at": "2025-09-08T18:43:00.000Z"} \ No newline at end of file diff --git a/storage/files/SDlrhr.png b/storage/files/SDlrhr.png new file mode 100644 index 0000000..c63985d Binary files /dev/null and b/storage/files/SDlrhr.png differ diff --git a/storage/files/SDlrhr.png.meta b/storage/files/SDlrhr.png.meta new file mode 100644 index 0000000..594b222 --- /dev/null +++ b/storage/files/SDlrhr.png.meta @@ -0,0 +1 @@ +{"uploaded_at": "2025-09-08T19:42:11.115834", "original_name": "Screenshot from 2025-09-08 18-47-41.png", "content_type": "image/png"} \ No newline at end of file diff --git a/test_expiry.txt b/test_expiry.txt new file mode 100644 index 0000000..cf8f20d --- /dev/null +++ b/test_expiry.txt @@ -0,0 +1,3 @@ +This is a test file for expiry testing. +Created: 2025-09-08T19:35:21 +Content: Test expiry functionality diff --git a/test_storage.py b/test_storage.py new file mode 100644 index 0000000..75b339a --- /dev/null +++ b/test_storage.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +""" +Test script for storage backends +""" +import sys +import os +from pathlib import Path + +# Add src directory to Python path +sys.path.insert(0, str(Path(__file__).parent / "src")) + +from config import config +from storage import StorageManager + +def test_storage(): + """Test the storage backend""" + print("🧪 Testing storage backend...") + + try: + # Initialize storage manager + storage = StorageManager(config) + backend_info = storage.get_backend_info() + + print(f"✅ Storage backend initialized: {backend_info['type']}") + + if backend_info['type'] == 'b2': + print(f" → B2 bucket: {backend_info.get('bucket', 'unknown')}") + elif backend_info['type'] == 'local': + print(f" → Local path: {backend_info.get('path', 'unknown')}") + + # Test file upload + test_content = b"Hello, this is a test file for storage backend testing!" + test_file_path = "test/test_file.txt" + + print(f"\n📤 Testing file upload...") + success = storage.upload_file(test_content, test_file_path, "text/plain") + + if success: + print(f"✅ Upload successful: {test_file_path}") + + # Test file download + print(f"📥 Testing file download...") + downloaded_content = storage.download_file(test_file_path) + + if downloaded_content == test_content: + print(f"✅ Download successful and content matches") + + # Test file exists + print(f"🔍 Testing file exists...") + exists = storage.file_exists(test_file_path) + print(f"✅ File exists: {exists}") + + # Test file size + print(f"📏 Testing file size...") + size = storage.get_file_size(test_file_path) + print(f"✅ File size: {size} bytes") + + # Test file listing + print(f"📋 Testing file listing...") + files = storage.list_files("test/") + print(f"✅ Files in test/ folder: {len(files)} files") + for file in files: + print(f" → {file}") + + # Test cleanup + print(f"🗑️ Testing file deletion...") + deleted = storage.delete_file(test_file_path) + print(f"✅ File deleted: {deleted}") + + else: + print(f"❌ Downloaded content doesn't match original") + return False + else: + print(f"❌ Upload failed") + return False + + print(f"\n🎉 All storage tests passed!") + return True + + except Exception as e: + print(f"❌ Storage test failed: {e}") + return False + +def main(): + print("🚀 Sharey Storage Backend Test") + print("=" * 40) + + # Show current configuration + print("\n📋 Current Storage Configuration:") + storage_config = config.get_storage_config() + print(f" Backend: {storage_config.get('backend', 'unknown')}") + + if storage_config.get('backend') == 'local': + print(f" Local Path: {storage_config.get('local_path', 'storage')}") + elif storage_config.get('backend') == 'b2': + if config.validate_b2_config(): + b2_config = config.get_b2_config() + print(f" B2 Bucket: {b2_config.get('bucket_name', 'unknown')}") + else: + print(" B2 Configuration: Invalid") + + # Test the storage backend + if test_storage(): + print("\n✅ Storage backend is working correctly!") + sys.exit(0) + else: + print("\n❌ Storage backend test failed!") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..1c41d51 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +""" +WSGI entry point for Sharey +This file is used by Gunicorn and other WSGI servers +""" +import sys +import os +from pathlib import Path + +# Add src directory to Python path +src_path = Path(__file__).parent / "src" +sys.path.insert(0, str(src_path)) + +# Import the Flask application +from app import app + +# Create the WSGI application +application = app + +if __name__ == "__main__": + # This allows running the WSGI file directly for testing + app.run(host="0.0.0.0", port=8866, debug=False)