Adding encryption

This commit is contained in:
2026-02-01 05:30:05 -05:00
parent bb35db384f
commit a7aedd3b64
7 changed files with 1214 additions and 57 deletions

96
.github/copilot-instructions.md vendored Normal file
View 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

View File

@@ -1,10 +1,19 @@
import os
import random
import json
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.staticfiles import StaticFiles
from typing import List
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
app = FastAPI()
@@ -17,15 +26,133 @@ app.add_middleware(
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"))
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"}
# 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():
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."""
if not VIDEOS_DIR.exists():
return []
@@ -36,16 +163,220 @@ def get_video_files() -> List[str]:
]
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")
def health_check():
"""Health check endpoint."""
return {"status": "ok"}
@app.get("/api/videos")
def get_videos(limit: int = 10):
"""Get a list of random videos."""
"""Get a list of random videos (public endpoint for unencrypted videos)."""
try:
videos = get_video_files()

View File

@@ -2,3 +2,7 @@ fastapi==0.104.1
uvicorn==0.24.0
python-multipart==0.0.6
python-dotenv==1.0.0
cryptography==41.0.7
bcrypt==4.1.1
python-jose==3.3.0
pydantic==2.5.0

View File

@@ -5,18 +5,20 @@ services:
build: ./backend
container_name: ticky-backend
ports:
- "3222:3001"
- "3001:3001"
environment:
- VIDEOS_DIR=/videos
- ENCRYPTED_DIR=/videos/encrypted
- METADATA_DIR=/videos/metadata
volumes:
- ./videos:/videos:ro
- ./videos:/videos
restart: unless-stopped
frontend:
build: ./frontend
container_name: ticky-frontend
ports:
- "3111:3000"
- "3000:3000"
depends_on:
- backend
environment:

View File

@@ -1,10 +1,62 @@
// Add global error handler
// Global error handler
window.addEventListener('error', (event) => {
console.error('[Ticky Error]', event.message, 'at', event.filename + ':' + event.lineno);
});
class VideoScroller {
// Authentication Manager
class AuthManager {
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.videos = [];
this.isLoading = false;
@@ -13,13 +65,7 @@ class VideoScroller {
this.touchStartY = 0;
this.touchEndY = 0;
this.isDragging = false;
this.lastLoadTime = 0;
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.decryptedBlobs = {};
this.init();
}
@@ -31,19 +77,28 @@ class VideoScroller {
}
setupEventListeners() {
// Touch events for swipe - prevent default to avoid page scrolling
// Touch events
document.addEventListener('touchstart', (e) => this.handleTouchStart(e), { passive: false });
document.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false });
document.addEventListener('touchend', (e) => this.handleTouchEnd(e), { passive: false });
// Keyboard navigation
// Keyboard
document.addEventListener('keydown', (e) => this.handleKeydown(e));
// Infinite scroll - load more when nearing the end
this.container.addEventListener('scroll', () => this.handleScroll());
// Mouse wheel
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) {
@@ -95,14 +150,18 @@ class VideoScroller {
async loadVideos() {
try {
this.showLoading();
const response = await fetch(`${this.apiUrl}/api/videos?limit=10`);
const newVideos = await response.json();
const response = await fetch(`${this.auth.apiUrl}/api/encrypted-videos`, {
headers: {
'Authorization': this.auth.getAuthHeader()
}
});
if (Array.isArray(newVideos)) {
this.videos.push(...newVideos);
if (!response.ok) {
throw new Error('Failed to load videos');
}
this.lastLoadTime = Date.now();
const newVideos = await response.json();
this.videos = newVideos;
} catch (error) {
console.error('Error loading videos:', error);
this.showLoadingError();
@@ -137,7 +196,6 @@ class VideoScroller {
item.style.minHeight = '100vh';
const videoEl = document.createElement('video');
videoEl.src = `${this.apiUrl}${video.url}`;
videoEl.preload = 'auto';
videoEl.controls = false;
videoEl.loop = true;
@@ -152,19 +210,118 @@ class VideoScroller {
const info = document.createElement('div');
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(info);
// Lazy load: only play current video
// Load encrypted video on demand
if (index === this.currentIndex) {
videoEl.play().catch(e => console.log('Play error:', e));
this.decryptAndPlayVideo(video, videoEl);
}
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() {
const items = this.container.querySelectorAll('.video-item');
items.forEach(item => {
@@ -206,14 +363,6 @@ class VideoScroller {
this.currentIndex++;
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() {
@@ -223,10 +372,6 @@ class VideoScroller {
}
}
handleScroll() {
// Could be used for horizontal scroll on desktop
}
showLoading() {
this.isLoading = true;
this.loadingEl.classList.add('show');
@@ -239,7 +384,7 @@ class VideoScroller {
}
showLoadingError() {
this.loadingEl.innerHTML = 'Failed to load videos';
this.loadingEl.innerHTML = 'Failed to load';
this.loadingEl.classList.add('show');
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
document.addEventListener('DOMContentLoaded', () => {
new VideoScroller();
new TickyApp();
});

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<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-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Ticky">
@@ -10,12 +10,59 @@
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<div class="video-container" id="videoContainer">
<!-- Videos will be inserted here -->
<!-- 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 class="loading" id="loading">Loading...</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">
<!-- Videos will be inserted here -->
</div>
<div class="loading" id="loading">Loading...</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">&times;</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>
</body>
</html>

View File

@@ -18,12 +18,12 @@ body {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
position: absolute;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
min-height: 100vh;
max-height: 100vh;
}
.container {
@@ -109,18 +109,50 @@ video::-webkit-media-controls {
.video-info {
position: absolute;
bottom: 20px;
left: 20px;
top: 20px;
right: 20px;
color: white;
z-index: 10;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
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 {
font-size: clamp(14px, 4vw, 24px);
font-weight: 600;
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 {
@@ -242,3 +274,357 @@ video::-webkit-media-controls {
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;
}
}