Adding encryption
This commit is contained in:
96
.github/copilot-instructions.md
vendored
Normal file
96
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Ticky Project - Development Guide
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
Self-hosted TikTok-style infinite video scroller with Python/FastAPI backend and vanilla JavaScript frontend. Runs in Docker Compose.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
- Infinite scroll with lazy loading
|
||||||
|
- Mobile swipe navigation (up/down)
|
||||||
|
- Desktop keyboard and mouse wheel support
|
||||||
|
- Responsive design (mobile/desktop)
|
||||||
|
- Automatic video preloading
|
||||||
|
- REST API for random video selection
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Backend (Python/FastAPI)
|
||||||
|
- **Port**: 3001
|
||||||
|
- **Framework**: FastAPI + Uvicorn
|
||||||
|
- **Key File**: `backend/main.py`
|
||||||
|
- **Features**:
|
||||||
|
- Random video selection from folder
|
||||||
|
- Static file serving for videos
|
||||||
|
- CORS enabled for frontend access
|
||||||
|
|
||||||
|
### Frontend (Vanilla JS)
|
||||||
|
- **Port**: 3000
|
||||||
|
- **File**: `frontend/app.js` with `styles.css`
|
||||||
|
- **Features**:
|
||||||
|
- Swipe gesture detection
|
||||||
|
- Keyboard/mouse wheel navigation
|
||||||
|
- Lazy loading and preloading
|
||||||
|
- Responsive CSS for mobile/desktop
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Add a new backend endpoint
|
||||||
|
1. Open `backend/main.py`
|
||||||
|
2. Add `@app.get()` or `@app.post()` decorator
|
||||||
|
3. Implement endpoint logic
|
||||||
|
4. Restart backend: `docker-compose restart backend`
|
||||||
|
|
||||||
|
### Customize frontend styling
|
||||||
|
1. Edit `frontend/styles.css`
|
||||||
|
2. Modify colors, sizes, animations
|
||||||
|
3. Changes reflect immediately on page refresh
|
||||||
|
|
||||||
|
### Change video directory
|
||||||
|
1. Update `docker-compose.yml` `VIDEOS_DIR` variable
|
||||||
|
2. Or mount different volume path
|
||||||
|
3. Restart containers: `docker-compose restart`
|
||||||
|
|
||||||
|
### Add video metadata
|
||||||
|
1. Modify backend response in `main.py` (add duration, thumbnail URL, etc.)
|
||||||
|
2. Update frontend `renderVideo()` in `app.js` to display new fields
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### View backend logs
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### View frontend console
|
||||||
|
Open browser DevTools (F12) → Console tab
|
||||||
|
|
||||||
|
### Check running containers
|
||||||
|
```bash
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rebuild images
|
||||||
|
```bash
|
||||||
|
docker-compose down && docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- Videos should be optimized (compressed MP4s recommended)
|
||||||
|
- Use landscape orientation videos for best desktop experience
|
||||||
|
- Preloading limited to next video to save bandwidth
|
||||||
|
- Only current ± 1 videos rendered in DOM
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
- Chrome/Edge: Full support
|
||||||
|
- Firefox: Full support
|
||||||
|
- Safari: Full support (iOS 12.2+)
|
||||||
|
- Mobile browsers: Optimized for touch
|
||||||
|
|
||||||
|
## Future Enhancement Ideas
|
||||||
|
- Video filtering/search
|
||||||
|
- User ratings or favorites
|
||||||
|
- Thumbnail previews
|
||||||
|
- Video duration display
|
||||||
|
- Playlist management
|
||||||
|
- Authentication
|
||||||
|
- CDN integration
|
||||||
343
backend/main.py
343
backend/main.py
@@ -1,10 +1,19 @@
|
|||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import FastAPI
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
import bcrypt
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, Form, Header
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from typing import List
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@@ -17,15 +26,133 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ============ Models ============
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
pin: str
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
token: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
class VideoMetadata(BaseModel):
|
||||||
|
filename: str
|
||||||
|
salt: str # hex encoded
|
||||||
|
iv: str # hex encoded
|
||||||
|
uploaded_at: str
|
||||||
|
|
||||||
|
|
||||||
VIDEOS_DIR = Path(os.getenv("VIDEOS_DIR", "/videos"))
|
VIDEOS_DIR = Path(os.getenv("VIDEOS_DIR", "/videos"))
|
||||||
|
ENCRYPTED_DIR = Path(os.getenv("ENCRYPTED_DIR", "/videos/encrypted"))
|
||||||
|
METADATA_DIR = Path(os.getenv("METADATA_DIR", "/videos/metadata"))
|
||||||
|
MARKER_FILE = Path(os.getenv("METADATA_DIR", "/videos/metadata")) / ".pinlock"
|
||||||
VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".avi", ".mkv"}
|
VIDEO_EXTENSIONS = {".mp4", ".webm", ".ogg", ".mov", ".avi", ".mkv"}
|
||||||
|
|
||||||
# Mount videos directory
|
# Create necessary directories
|
||||||
|
ENCRYPTED_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
METADATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Mount videos directory for unencrypted videos (optional)
|
||||||
if VIDEOS_DIR.exists():
|
if VIDEOS_DIR.exists():
|
||||||
app.mount("/videos", StaticFiles(directory=VIDEOS_DIR), name="videos")
|
app.mount("/videos", StaticFiles(directory=VIDEOS_DIR), name="videos")
|
||||||
|
|
||||||
|
# ============ Authentication ============
|
||||||
|
|
||||||
def get_video_files() -> List[str]:
|
def hash_pin(pin: str) -> str:
|
||||||
|
"""Hash PIN with bcrypt."""
|
||||||
|
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
def verify_pin(pin: str, hashed: str) -> bool:
|
||||||
|
"""Verify PIN against hash."""
|
||||||
|
return bcrypt.checkpw(pin.encode(), hashed.encode())
|
||||||
|
|
||||||
|
def create_token(pin: str) -> str:
|
||||||
|
"""Create a simple auth token (in production, use JWT)."""
|
||||||
|
import base64
|
||||||
|
import secrets
|
||||||
|
token = base64.b64encode(
|
||||||
|
f"{pin}:{secrets.token_hex(16)}:{datetime.utcnow().isoformat()}".encode()
|
||||||
|
).decode()
|
||||||
|
return token
|
||||||
|
|
||||||
|
def verify_token(token: str) -> bool:
|
||||||
|
"""Verify token is valid format."""
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
decoded = base64.b64decode(token.encode()).decode()
|
||||||
|
parts = decoded.split(":")
|
||||||
|
if len(parts) < 3:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ============ Encryption Functions ============
|
||||||
|
|
||||||
|
def derive_key_from_password(password: str, salt: bytes) -> bytes:
|
||||||
|
"""
|
||||||
|
Derive encryption key from password using PBKDF2-like key derivation.
|
||||||
|
Same password + same salt = same key.
|
||||||
|
Key is never stored - only exists in memory during crypto operations.
|
||||||
|
"""
|
||||||
|
# Use hashlib's pbkdf2_hmac for key derivation (no external dependency needed)
|
||||||
|
key = hashlib.pbkdf2_hmac(
|
||||||
|
'sha256',
|
||||||
|
password.encode(),
|
||||||
|
salt,
|
||||||
|
iterations=480000 # OWASP recommendation
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
def encrypt_file(file_bytes: bytes, password: str) -> Tuple[bytes, str, str]:
|
||||||
|
"""
|
||||||
|
Encrypt file with password-derived key.
|
||||||
|
Returns: (encrypted_data, salt_hex, iv_hex)
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
iv = secrets.token_bytes(16)
|
||||||
|
key = derive_key_from_password(password, salt)
|
||||||
|
|
||||||
|
# Add PKCS7 padding
|
||||||
|
padding_length = 16 - (len(file_bytes) % 16)
|
||||||
|
padded_data = file_bytes + bytes([padding_length] * padding_length)
|
||||||
|
|
||||||
|
# Encrypt with AES-256-CBC
|
||||||
|
cipher = Cipher(
|
||||||
|
algorithms.AES(key),
|
||||||
|
modes.CBC(iv),
|
||||||
|
)
|
||||||
|
encryptor = cipher.encryptor()
|
||||||
|
ciphertext = encryptor.update(padded_data) + encryptor.finalize()
|
||||||
|
|
||||||
|
return ciphertext, salt.hex(), iv.hex()
|
||||||
|
|
||||||
|
def decrypt_file(ciphertext: bytes, password: str, salt_hex: str, iv_hex: str) -> bytes:
|
||||||
|
"""
|
||||||
|
Decrypt file with password-derived key.
|
||||||
|
Only works with correct password - no backdoor.
|
||||||
|
"""
|
||||||
|
salt = bytes.fromhex(salt_hex)
|
||||||
|
iv = bytes.fromhex(iv_hex)
|
||||||
|
key = derive_key_from_password(password, salt)
|
||||||
|
|
||||||
|
cipher = Cipher(
|
||||||
|
algorithms.AES(key),
|
||||||
|
modes.CBC(iv),
|
||||||
|
)
|
||||||
|
decryptor = cipher.decryptor()
|
||||||
|
padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize()
|
||||||
|
|
||||||
|
# Remove PKCS7 padding
|
||||||
|
padding_length = padded_plaintext[-1]
|
||||||
|
plaintext = padded_plaintext[:-padding_length]
|
||||||
|
|
||||||
|
return plaintext
|
||||||
|
|
||||||
|
|
||||||
|
def get_video_files() -> list[str]:
|
||||||
"""Get all video files from the videos directory."""
|
"""Get all video files from the videos directory."""
|
||||||
if not VIDEOS_DIR.exists():
|
if not VIDEOS_DIR.exists():
|
||||||
return []
|
return []
|
||||||
@@ -36,16 +163,220 @@ def get_video_files() -> List[str]:
|
|||||||
]
|
]
|
||||||
return videos
|
return videos
|
||||||
|
|
||||||
|
# ============ API Endpoints ============
|
||||||
|
|
||||||
|
@app.post("/api/login")
|
||||||
|
def login(request: LoginRequest):
|
||||||
|
"""
|
||||||
|
Authenticate user with PIN.
|
||||||
|
First login: Create marker file encrypted with PIN
|
||||||
|
Subsequent logins: Verify PIN by decrypting marker file
|
||||||
|
"""
|
||||||
|
pin = request.pin
|
||||||
|
|
||||||
|
# Validate PIN format (6 digits)
|
||||||
|
if not pin.isdigit() or len(pin) != 6:
|
||||||
|
raise HTTPException(status_code=400, detail="PIN must be 6 digits")
|
||||||
|
|
||||||
|
if MARKER_FILE.exists():
|
||||||
|
# Existing system - verify PIN by decrypting marker file
|
||||||
|
try:
|
||||||
|
with open(MARKER_FILE, "rb") as f:
|
||||||
|
marker_data = json.load(f)
|
||||||
|
|
||||||
|
# Try to decrypt the marker with the provided PIN
|
||||||
|
decrypted = decrypt_file(
|
||||||
|
bytes.fromhex(marker_data["data"]),
|
||||||
|
pin,
|
||||||
|
marker_data["salt"],
|
||||||
|
marker_data["iv"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# If we get here, PIN is correct
|
||||||
|
token = create_token(pin)
|
||||||
|
return LoginResponse(
|
||||||
|
token=token,
|
||||||
|
message="Login successful"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid PIN")
|
||||||
|
else:
|
||||||
|
# First login - create marker file with this PIN
|
||||||
|
marker_content = b"PIN_MARKER"
|
||||||
|
encrypted_data, salt_hex, iv_hex = encrypt_file(marker_content, pin)
|
||||||
|
|
||||||
|
marker_data = {
|
||||||
|
"data": encrypted_data.hex(),
|
||||||
|
"salt": salt_hex,
|
||||||
|
"iv": iv_hex,
|
||||||
|
"created_at": datetime.utcnow().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(MARKER_FILE, "w") as f:
|
||||||
|
json.dump(marker_data, f)
|
||||||
|
|
||||||
|
token = create_token(pin)
|
||||||
|
return LoginResponse(
|
||||||
|
token=token,
|
||||||
|
message="PIN initialized successfully"
|
||||||
|
)
|
||||||
|
|
||||||
|
def verify_auth(authorization: Optional[str] = Header(None)) -> str:
|
||||||
|
"""Dependency to verify auth token and extract PIN."""
|
||||||
|
if not authorization:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||||
|
|
||||||
|
if not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||||
|
|
||||||
|
token = authorization.replace("Bearer ", "")
|
||||||
|
if not verify_token(token):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
# Extract PIN from token
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
decoded = base64.b64decode(token.encode()).decode()
|
||||||
|
pin = decoded.split(":")[0]
|
||||||
|
return pin
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
|
||||||
|
@app.post("/api/upload")
|
||||||
|
async def upload_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
token: str = Depends(verify_auth)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Upload and encrypt a file.
|
||||||
|
File is encrypted with the authenticated user's PIN - no key stored on server.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pin = token # token is actually the PIN extracted by verify_auth
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=400, detail="Empty file")
|
||||||
|
|
||||||
|
# Encrypt with password-derived key
|
||||||
|
encrypted_data, salt_hex, iv_hex = encrypt_file(content, pin)
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
import secrets
|
||||||
|
encrypted_filename = f"{secrets.token_hex(8)}.enc"
|
||||||
|
encrypted_path = ENCRYPTED_DIR / encrypted_filename
|
||||||
|
|
||||||
|
# Save encrypted file
|
||||||
|
with open(encrypted_path, "wb") as f:
|
||||||
|
f.write(encrypted_data)
|
||||||
|
|
||||||
|
# Save metadata (salt and IV are non-secret, needed for decryption)
|
||||||
|
metadata = VideoMetadata(
|
||||||
|
filename=file.filename or "unknown",
|
||||||
|
salt=salt_hex,
|
||||||
|
iv=iv_hex,
|
||||||
|
uploaded_at=datetime.utcnow().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata_path = METADATA_DIR / f"{encrypted_filename}.json"
|
||||||
|
with open(metadata_path, "w") as f:
|
||||||
|
json.dump(metadata.dict(), f)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"encrypted_id": encrypted_filename,
|
||||||
|
"original_filename": file.filename,
|
||||||
|
"message": "File encrypted and stored securely"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Upload error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
||||||
|
|
||||||
|
@app.get("/api/encrypted-videos")
|
||||||
|
def list_encrypted_videos(token: str = Depends(verify_auth)):
|
||||||
|
"""List all encrypted videos (metadata only - no decryption)."""
|
||||||
|
try:
|
||||||
|
videos = []
|
||||||
|
for metadata_file in METADATA_DIR.glob("*.json"):
|
||||||
|
with open(metadata_file, "r") as f:
|
||||||
|
metadata = json.load(f)
|
||||||
|
encrypted_id = metadata_file.stem.replace(".enc", "")
|
||||||
|
videos.append({
|
||||||
|
"id": encrypted_id,
|
||||||
|
"filename": metadata["filename"],
|
||||||
|
"uploaded_at": metadata["uploaded_at"]
|
||||||
|
})
|
||||||
|
|
||||||
|
return sorted(videos, key=lambda x: x["uploaded_at"], reverse=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"List error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to list videos")
|
||||||
|
|
||||||
|
@app.get("/api/decrypt-video/{encrypted_id}")
|
||||||
|
async def decrypt_video(
|
||||||
|
encrypted_id: str,
|
||||||
|
token: str = Depends(verify_auth)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Decrypt and stream video file.
|
||||||
|
Only works with correct PIN (extracted from token) - key is derived, never stored.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pin = token # PIN extracted from token by verify_auth
|
||||||
|
|
||||||
|
encrypted_path = ENCRYPTED_DIR / f"{encrypted_id}.enc"
|
||||||
|
metadata_path = METADATA_DIR / f"{encrypted_id}.enc.json"
|
||||||
|
|
||||||
|
if not encrypted_path.exists() or not metadata_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Video not found")
|
||||||
|
|
||||||
|
# Load metadata
|
||||||
|
with open(metadata_path, "r") as f:
|
||||||
|
metadata = json.load(f)
|
||||||
|
|
||||||
|
# Read encrypted data
|
||||||
|
with open(encrypted_path, "rb") as f:
|
||||||
|
ciphertext = f.read()
|
||||||
|
|
||||||
|
# Decrypt with PIN-derived key
|
||||||
|
plaintext = decrypt_file(
|
||||||
|
ciphertext,
|
||||||
|
pin,
|
||||||
|
metadata["salt"],
|
||||||
|
metadata["iv"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stream the decrypted file
|
||||||
|
def iterfile():
|
||||||
|
yield plaintext
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iterfile(),
|
||||||
|
media_type="video/mp4",
|
||||||
|
headers={"Content-Disposition": f"inline; filename={metadata['filename']}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Decryption error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="Decryption failed")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/videos")
|
@app.get("/api/videos")
|
||||||
def get_videos(limit: int = 10):
|
def get_videos(limit: int = 10):
|
||||||
"""Get a list of random videos."""
|
"""Get a list of random videos (public endpoint for unencrypted videos)."""
|
||||||
try:
|
try:
|
||||||
videos = get_video_files()
|
videos = get_video_files()
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,7 @@ fastapi==0.104.1
|
|||||||
uvicorn==0.24.0
|
uvicorn==0.24.0
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
cryptography==41.0.7
|
||||||
|
bcrypt==4.1.1
|
||||||
|
python-jose==3.3.0
|
||||||
|
pydantic==2.5.0
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ services:
|
|||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: ticky-backend
|
container_name: ticky-backend
|
||||||
ports:
|
ports:
|
||||||
- "3222:3001"
|
- "3001:3001"
|
||||||
environment:
|
environment:
|
||||||
- VIDEOS_DIR=/videos
|
- VIDEOS_DIR=/videos
|
||||||
|
- ENCRYPTED_DIR=/videos/encrypted
|
||||||
|
- METADATA_DIR=/videos/metadata
|
||||||
volumes:
|
volumes:
|
||||||
- ./videos:/videos:ro
|
- ./videos:/videos
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
container_name: ticky-frontend
|
container_name: ticky-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3111:3000"
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
365
frontend/app.js
365
frontend/app.js
@@ -1,10 +1,62 @@
|
|||||||
// Add global error handler
|
// Global error handler
|
||||||
window.addEventListener('error', (event) => {
|
window.addEventListener('error', (event) => {
|
||||||
console.error('[Ticky Error]', event.message, 'at', event.filename + ':' + event.lineno);
|
console.error('[Ticky Error]', event.message, 'at', event.filename + ':' + event.lineno);
|
||||||
});
|
});
|
||||||
|
|
||||||
class VideoScroller {
|
// Authentication Manager
|
||||||
|
class AuthManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.token = localStorage.getItem('ticky_token');
|
||||||
|
this.pin = sessionStorage.getItem('ticky_pin');
|
||||||
|
const protocol = window.location.protocol;
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
this.apiUrl = `${protocol}//${hostname}:3001`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(pin) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.apiUrl}/api/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pin })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Invalid PIN');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
this.token = data.token;
|
||||||
|
this.pin = pin;
|
||||||
|
localStorage.setItem('ticky_token', this.token);
|
||||||
|
sessionStorage.setItem('ticky_pin', this.pin);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.token = null;
|
||||||
|
this.pin = null;
|
||||||
|
localStorage.removeItem('ticky_token');
|
||||||
|
sessionStorage.removeItem('ticky_pin');
|
||||||
|
}
|
||||||
|
|
||||||
|
isAuthenticated() {
|
||||||
|
return !!this.token && !!this.pin;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthHeader() {
|
||||||
|
return `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypted Video Scroller
|
||||||
|
class EncryptedVideoScroller {
|
||||||
|
constructor(auth) {
|
||||||
|
this.auth = auth;
|
||||||
this.currentIndex = 0;
|
this.currentIndex = 0;
|
||||||
this.videos = [];
|
this.videos = [];
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
@@ -13,13 +65,7 @@ class VideoScroller {
|
|||||||
this.touchStartY = 0;
|
this.touchStartY = 0;
|
||||||
this.touchEndY = 0;
|
this.touchEndY = 0;
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
this.lastLoadTime = 0;
|
this.decryptedBlobs = {};
|
||||||
this.minLoadInterval = 500;
|
|
||||||
|
|
||||||
// Get API URL from current location
|
|
||||||
const protocol = window.location.protocol;
|
|
||||||
const hostname = window.location.hostname;
|
|
||||||
this.apiUrl = `${protocol}//${hostname}:3222`;
|
|
||||||
|
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
@@ -31,19 +77,28 @@ class VideoScroller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
// Touch events for swipe - prevent default to avoid page scrolling
|
// Touch events
|
||||||
document.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: false });
|
document.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: false });
|
||||||
document.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
|
document.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
|
||||||
document.addEventListener('touchend', (e) => this.handleTouchEnd(e), { passive: false });
|
document.addEventListener('touchend', (e) => this.handleTouchEnd(e), { passive: false });
|
||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard
|
||||||
document.addEventListener('keydown', (e) => this.handleKeydown(e));
|
document.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||||
|
|
||||||
// Infinite scroll - load more when nearing the end
|
|
||||||
this.container.addEventListener('scroll', () => this.handleScroll());
|
|
||||||
|
|
||||||
// Mouse wheel
|
// Mouse wheel
|
||||||
document.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
|
document.addEventListener('wheel', (e) => this.handleWheel(e), { passive: false });
|
||||||
|
|
||||||
|
// Toggle header on tap (but not on buttons)
|
||||||
|
this.container.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.download-btn') && !e.target.closest('.video-title')) {
|
||||||
|
this.toggleHeader();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleHeader() {
|
||||||
|
const header = document.querySelector('.app-header');
|
||||||
|
header.classList.toggle('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTouchStart(e) {
|
handleTouchStart(e) {
|
||||||
@@ -95,14 +150,18 @@ class VideoScroller {
|
|||||||
async loadVideos() {
|
async loadVideos() {
|
||||||
try {
|
try {
|
||||||
this.showLoading();
|
this.showLoading();
|
||||||
const response = await fetch(`${this.apiUrl}/api/videos?limit=10`);
|
const response = await fetch(`${this.auth.apiUrl}/api/encrypted-videos`, {
|
||||||
const newVideos = await response.json();
|
headers: {
|
||||||
|
'Authorization': this.auth.getAuthHeader()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (Array.isArray(newVideos)) {
|
if (!response.ok) {
|
||||||
this.videos.push(...newVideos);
|
throw new Error('Failed to load videos');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastLoadTime = Date.now();
|
const newVideos = await response.json();
|
||||||
|
this.videos = newVideos;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading videos:', error);
|
console.error('Error loading videos:', error);
|
||||||
this.showLoadingError();
|
this.showLoadingError();
|
||||||
@@ -137,7 +196,6 @@ class VideoScroller {
|
|||||||
item.style.minHeight = '100vh';
|
item.style.minHeight = '100vh';
|
||||||
|
|
||||||
const videoEl = document.createElement('video');
|
const videoEl = document.createElement('video');
|
||||||
videoEl.src = `${this.apiUrl}${video.url}`;
|
|
||||||
videoEl.preload = 'auto';
|
videoEl.preload = 'auto';
|
||||||
videoEl.controls = false;
|
videoEl.controls = false;
|
||||||
videoEl.loop = true;
|
videoEl.loop = true;
|
||||||
@@ -152,19 +210,118 @@ class VideoScroller {
|
|||||||
|
|
||||||
const info = document.createElement('div');
|
const info = document.createElement('div');
|
||||||
info.className = 'video-info';
|
info.className = 'video-info';
|
||||||
info.innerHTML = `<div class="video-title">${this.escapeHtml(video.title)}</div>`;
|
info.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<div class="video-title">${this.escapeHtml(video.filename)}</div>
|
||||||
|
<div class="video-description">Swipe up for more</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const downloadBtn = document.createElement('button');
|
||||||
|
downloadBtn.className = 'download-btn';
|
||||||
|
downloadBtn.title = 'Download video';
|
||||||
|
downloadBtn.innerHTML = '⬇';
|
||||||
|
downloadBtn.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.downloadVideo(video);
|
||||||
|
};
|
||||||
|
info.appendChild(downloadBtn);
|
||||||
|
|
||||||
item.appendChild(videoEl);
|
item.appendChild(videoEl);
|
||||||
item.appendChild(info);
|
item.appendChild(info);
|
||||||
|
|
||||||
// Lazy load: only play current video
|
// Load encrypted video on demand
|
||||||
if (index === this.currentIndex) {
|
if (index === this.currentIndex) {
|
||||||
videoEl.play().catch(e => console.log('Play error:', e));
|
this.decryptAndPlayVideo(video, videoEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.container.appendChild(item);
|
this.container.appendChild(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async decryptAndPlayVideo(video, videoEl) {
|
||||||
|
try {
|
||||||
|
if (this.decryptedBlobs[video.id]) {
|
||||||
|
videoEl.src = this.decryptedBlobs[video.id];
|
||||||
|
videoEl.play().catch(e => console.log('Play error:', e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showLoading();
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.auth.apiUrl}/api/decrypt-video/${video.id}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.auth.getAuthHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to decrypt video');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
this.decryptedBlobs[video.id] = blobUrl;
|
||||||
|
|
||||||
|
videoEl.src = blobUrl;
|
||||||
|
videoEl.play().catch(e => console.log('Play error:', e));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Decryption error:', error);
|
||||||
|
this.showLoadingError();
|
||||||
|
} finally {
|
||||||
|
this.hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadVideo(video) {
|
||||||
|
try {
|
||||||
|
// Check if already decrypted
|
||||||
|
let blobUrl = this.decryptedBlobs[video.id];
|
||||||
|
|
||||||
|
if (!blobUrl) {
|
||||||
|
// Decrypt the video for download
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.auth.apiUrl}/api/decrypt-video/${video.id}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.auth.getAuthHeader()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to download video');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
blobUrl = URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary download link
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = blobUrl;
|
||||||
|
|
||||||
|
// Use original filename or generate one
|
||||||
|
const filename = video.filename || `video_${video.id}.mp4`;
|
||||||
|
link.download = filename;
|
||||||
|
|
||||||
|
// Trigger download
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
// Clean up the object URL if it wasn't already cached
|
||||||
|
if (!this.decryptedBlobs[video.id]) {
|
||||||
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download error:', error);
|
||||||
|
alert('Failed to download video: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateActiveVideo() {
|
updateActiveVideo() {
|
||||||
const items = this.container.querySelectorAll('.video-item');
|
const items = this.container.querySelectorAll('.video-item');
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
@@ -206,14 +363,6 @@ class VideoScroller {
|
|||||||
this.currentIndex++;
|
this.currentIndex++;
|
||||||
this.renderVideos();
|
this.renderVideos();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load more videos when near the end
|
|
||||||
const now = Date.now();
|
|
||||||
if (this.currentIndex > this.videos.length - 3 &&
|
|
||||||
now - this.lastLoadTime > this.minLoadInterval &&
|
|
||||||
!this.isLoading) {
|
|
||||||
await this.loadVideos();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prevVideo() {
|
prevVideo() {
|
||||||
@@ -223,10 +372,6 @@ class VideoScroller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleScroll() {
|
|
||||||
// Could be used for horizontal scroll on desktop
|
|
||||||
}
|
|
||||||
|
|
||||||
showLoading() {
|
showLoading() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.loadingEl.classList.add('show');
|
this.loadingEl.classList.add('show');
|
||||||
@@ -239,7 +384,7 @@ class VideoScroller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showLoadingError() {
|
showLoadingError() {
|
||||||
this.loadingEl.innerHTML = 'Failed to load videos';
|
this.loadingEl.innerHTML = 'Failed to load';
|
||||||
this.loadingEl.classList.add('show');
|
this.loadingEl.classList.add('show');
|
||||||
setTimeout(() => this.loadingEl.classList.remove('show'), 3000);
|
setTimeout(() => this.loadingEl.classList.remove('show'), 3000);
|
||||||
}
|
}
|
||||||
@@ -251,7 +396,153 @@ class VideoScroller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App Controller
|
||||||
|
class TickyApp {
|
||||||
|
constructor() {
|
||||||
|
this.auth = new AuthManager();
|
||||||
|
this.scroller = null;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkFirstLogin() {
|
||||||
|
try {
|
||||||
|
// Try to access a protected endpoint without auth to see if marker file exists
|
||||||
|
const response = await fetch(`${this.auth.apiUrl}/api/health`);
|
||||||
|
return !response.ok;
|
||||||
|
} catch {
|
||||||
|
return true; // Assume first login if we can't check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this.auth.isAuthenticated()) {
|
||||||
|
this.showApp();
|
||||||
|
} else {
|
||||||
|
this.showLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showLogin() {
|
||||||
|
const loginScreen = document.getElementById('loginScreen');
|
||||||
|
const mainApp = document.getElementById('mainApp');
|
||||||
|
loginScreen.classList.add('active');
|
||||||
|
mainApp.classList.remove('active');
|
||||||
|
|
||||||
|
const loginForm = document.getElementById('loginForm');
|
||||||
|
const pinInput = document.getElementById('pinInput');
|
||||||
|
const loginError = document.getElementById('loginError');
|
||||||
|
const setupMode = document.getElementById('setupMode');
|
||||||
|
|
||||||
|
// Check if this is first login by attempting to fetch marker file status
|
||||||
|
this.checkFirstLogin().then(isFirstLogin => {
|
||||||
|
if (isFirstLogin) {
|
||||||
|
setupMode.style.display = 'block';
|
||||||
|
pinInput.placeholder = 'Create 6-digit PIN';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loginForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const pin = pinInput.value;
|
||||||
|
|
||||||
|
if (!pin.match(/^\d{6}$/)) {
|
||||||
|
loginError.textContent = 'PIN must be 6 digits';
|
||||||
|
loginError.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.auth.login(pin);
|
||||||
|
this.showApp();
|
||||||
|
} catch (error) {
|
||||||
|
loginError.textContent = 'Invalid PIN';
|
||||||
|
loginError.style.display = 'block';
|
||||||
|
pinInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showApp() {
|
||||||
|
const loginScreen = document.getElementById('loginScreen');
|
||||||
|
const mainApp = document.getElementById('mainApp');
|
||||||
|
loginScreen.classList.remove('active');
|
||||||
|
mainApp.classList.add('active');
|
||||||
|
|
||||||
|
if (!this.scroller) {
|
||||||
|
this.scroller = new EncryptedVideoScroller(this.auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setupAppControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupAppControls() {
|
||||||
|
const logoutBtn = document.getElementById('logoutBtn');
|
||||||
|
const uploadBtn = document.getElementById('uploadBtn');
|
||||||
|
const uploadModal = document.getElementById('uploadModal');
|
||||||
|
const closeUploadBtn = document.getElementById('closeUploadBtn');
|
||||||
|
const uploadForm = document.getElementById('uploadForm');
|
||||||
|
const uploadError = document.getElementById('uploadError');
|
||||||
|
|
||||||
|
logoutBtn.addEventListener('click', () => {
|
||||||
|
this.auth.logout();
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadBtn.addEventListener('click', () => {
|
||||||
|
uploadModal.classList.add('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
closeUploadBtn.addEventListener('click', () => {
|
||||||
|
uploadModal.classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === uploadModal) {
|
||||||
|
uploadModal.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
|
||||||
|
uploadError.textContent = '';
|
||||||
|
uploadError.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.auth.apiUrl}/api/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.auth.getAuthHeader()
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadForm.reset();
|
||||||
|
uploadModal.classList.remove('show');
|
||||||
|
|
||||||
|
// Reload videos
|
||||||
|
await this.scroller.loadVideos();
|
||||||
|
this.scroller.renderVideos();
|
||||||
|
|
||||||
|
alert('Video uploaded and encrypted successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
uploadError.textContent = 'Upload failed: ' + error.message;
|
||||||
|
uploadError.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
new VideoScroller();
|
new TickyApp();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no, maximum-scale=1.0">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Ticky">
|
<meta name="apple-mobile-web-app-title" content="Ticky">
|
||||||
@@ -10,12 +10,59 @@
|
|||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<!-- Login Screen -->
|
||||||
|
<div id="loginScreen" class="login-screen active">
|
||||||
|
<div class="login-container">
|
||||||
|
<h1>Frankie's Den</h1>
|
||||||
|
<div id="setupMode" style="display: none;">
|
||||||
|
<p class="setup-text">First time setup - Create a 6-digit PIN</p>
|
||||||
|
</div>
|
||||||
|
<form id="loginForm">
|
||||||
|
<input type="password" id="pinInput" placeholder="Enter PIN" maxlength="6" inputmode="numeric" required>
|
||||||
|
<button type="submit">Unlock</button>
|
||||||
|
</form>
|
||||||
|
<div id="loginError" class="error-message"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main App -->
|
||||||
|
<div id="mainApp" class="main-app">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="app-header">
|
||||||
|
<h2>Private Videos</h2>
|
||||||
|
<div class="header-buttons">
|
||||||
|
<button id="uploadBtn" class="btn-small">+ Upload</button>
|
||||||
|
<button id="logoutBtn" class="btn-small">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video Viewer -->
|
||||||
|
<div class="video-viewer-container">
|
||||||
<div class="video-container" id="videoContainer">
|
<div class="video-container" id="videoContainer">
|
||||||
<!-- Videos will be inserted here -->
|
<!-- Videos will be inserted here -->
|
||||||
</div>
|
</div>
|
||||||
<div class="loading" id="loading">Loading...</div>
|
<div class="loading" id="loading">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Modal -->
|
||||||
|
<div id="uploadModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Upload Encrypted Video</h3>
|
||||||
|
<button class="close-btn" id="closeUploadBtn">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="uploadForm">
|
||||||
|
<input type="file" id="fileInput" accept="video/*" required>
|
||||||
|
<button type="submit">Encrypt & Upload</button>
|
||||||
|
</form>
|
||||||
|
<div id="uploadError" class="error-message"></div>
|
||||||
|
<div id="uploadProgress" class="progress-bar" style="display: none;">
|
||||||
|
<div class="progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ body {
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 100vh;
|
max-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -109,18 +109,50 @@ video::-webkit-media-controls {
|
|||||||
|
|
||||||
.video-info {
|
.video-info {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 20px;
|
top: 20px;
|
||||||
left: 20px;
|
right: 20px;
|
||||||
color: white;
|
color: white;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
|
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
padding-right: max(20px, env(safe-area-inset-right));
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-title {
|
.video-title {
|
||||||
font-size: clamp(14px, 4vw, 24px);
|
font-size: clamp(14px, 4vw, 24px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
pointer-events: all;
|
||||||
|
transition: all 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-controls {
|
.video-controls {
|
||||||
@@ -242,3 +274,357 @@ video::-webkit-media-controls {
|
|||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== LOGIN SCREEN ===== */
|
||||||
|
.login-screen {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-screen.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container h1 {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container p {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loginForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pinInput {
|
||||||
|
padding: 15px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 10px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#pinInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00f5ff;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
box-shadow: 0 0 20px rgba(0, 245, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#loginForm button {
|
||||||
|
padding: 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #ff006e, #00f5ff);
|
||||||
|
color: white;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loginForm button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 25px rgba(255, 0, 110, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#loginForm button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
display: none;
|
||||||
|
color: #ff4444;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(255, 68, 68, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MAIN APP ===== */
|
||||||
|
.main-app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-app.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
padding: 15px 20px;
|
||||||
|
padding-top: max(15px, env(safe-area-inset-top));
|
||||||
|
padding-left: max(20px, env(safe-area-inset-left));
|
||||||
|
padding-right: max(20px, env(safe-area-inset-right));
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
z-index: 50;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header.hidden {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header h2 {
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-viewer-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MODAL ===== */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: rgba(20, 20, 30, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 30px;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#uploadForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#uploadForm input[type="file"],
|
||||||
|
#uploadForm input[type="password"] {
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#uploadForm input[type="file"]::file-selector-button {
|
||||||
|
background: linear-gradient(135deg, #ff006e, #00f5ff);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#uploadForm input[type="file"]::file-selector-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#uploadForm input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #00f5ff;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 0 15px rgba(0, 245, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#uploadForm button {
|
||||||
|
padding: 12px;
|
||||||
|
background: linear-gradient(135deg, #ff006e, #00f5ff);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#uploadForm button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(255, 0, 110, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#uploadForm button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #ff006e, #00f5ff);
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#loginError,
|
||||||
|
#uploadError {
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(255, 107, 107, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== RESPONSIVE ===== */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.app-header {
|
||||||
|
padding: 12px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container h1 {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user