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

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();
if (Array.isArray(newVideos)) {
this.videos.push(...newVideos);
const response = await fetch(`${this.auth.apiUrl}/api/encrypted-videos`, {
headers: {
'Authorization': this.auth.getAuthHeader()
}
});
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();
});