diff --git a/README.md b/README.md index e855fd3..7e19a4a 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ docker-compose up -d docker-compose logs -f ``` +**Login-Schutz aktivieren:** Setze Benutzername/Passwort in `docker-compose.yml` via `AUTH_USERNAME` und `AUTH_PASSWORD`. Beispiel (bereits eingetragen): +``` + environment: + - AUTH_USERNAME=admin + - AUTH_PASSWORD=changeme +``` +Denke daran, eigene Werte zu hinterlegen. + Das Backend läuft nun auf `http://localhost:3000` Das Web-Interface ist erreichbar unter `http://localhost:8080` diff --git a/backend/server.js b/backend/server.js index dc607d3..37a603c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -47,6 +47,11 @@ const AUTOMATION_WORKER_INTERVAL_MS = 30000; const AUTOMATION_MAX_STEPS = 3; const AUTOMATION_MAX_EMAIL_TO_LENGTH = 320; const AUTOMATION_MAX_EMAIL_SUBJECT_LENGTH = 320; +const AUTH_USERNAME = (process.env.AUTH_USERNAME || '').trim(); +const AUTH_PASSWORD = (process.env.AUTH_PASSWORD || '').trim(); +const AUTH_ENABLED = Boolean(AUTH_USERNAME && AUTH_PASSWORD); +const AUTH_SESSION_COOKIE = 'fb_auth_token'; +const AUTH_SESSION_MAX_AGE = 60 * 60 * 24 * 365 * 10; // ~10 Jahre "quasi dauerhaft" const SPORTS_SCORING_DEFAULTS = { enabled: 1, threshold: 5, @@ -175,6 +180,9 @@ app.use((req, res, next) => { next(); }); +// Simple session-based authentication (enabled when AUTH_USERNAME/PASSWORD are set) +app.use(authGuard); + // Assign per-browser profile scopes via cookies app.use(ensureProfileScope); @@ -297,6 +305,20 @@ for (const entry of postsMissingKey) { } } +const postsPermalinks = db.prepare(` + SELECT id, url, content_key + FROM posts + WHERE url LIKE '%/permalink.php%' +`).all(); + +for (const entry of postsPermalinks) { + const normalizedUrl = normalizeFacebookPostUrl(entry.url); + const key = extractFacebookContentKey(normalizedUrl); + if (key && key !== entry.content_key) { + updateContentKeyStmt.run(key, entry.id); + } +} + const postsMissingHash = db.prepare(` SELECT id, post_text FROM posts @@ -346,6 +368,104 @@ function isSecureRequest(req) { return false; } +const authSessions = new Map(); + +function buildAuthCookieValue(token, req) { + const secure = isSecureRequest(req); + const attributes = [ + `${AUTH_SESSION_COOKIE}=${encodeURIComponent(token)}`, + 'Path=/', + `Max-Age=${AUTH_SESSION_MAX_AGE}`, + 'HttpOnly' + ]; + + if (secure) { + attributes.push('Secure', 'SameSite=None'); + } else { + attributes.push('SameSite=Lax'); + } + + return attributes.join('; '); +} + +function clearAuthCookie(res, req) { + const secure = isSecureRequest(req); + const attributes = [ + `${AUTH_SESSION_COOKIE}=`, + 'Path=/', + 'Max-Age=0', + 'HttpOnly' + ]; + + if (secure) { + attributes.push('Secure', 'SameSite=None'); + } else { + attributes.push('SameSite=Lax'); + } + + const existing = res.getHeader('Set-Cookie'); + const value = attributes.join('; '); + if (!existing) { + res.setHeader('Set-Cookie', value); + } else if (Array.isArray(existing)) { + res.setHeader('Set-Cookie', [...existing, value]); + } else { + res.setHeader('Set-Cookie', [existing, value]); + } +} + +function createSession(username) { + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = Date.now() + AUTH_SESSION_MAX_AGE * 1000; + authSessions.set(token, { username, expiresAt }); + return { token, expiresAt }; +} + +function getSessionFromRequest(req) { + const cookies = parseCookies(req.headers.cookie); + const token = cookies[AUTH_SESSION_COOKIE]; + if (!token) { + return null; + } + + const session = authSessions.get(token); + if (!session) { + return null; + } + + if (session.expiresAt <= Date.now()) { + authSessions.delete(token); + return null; + } + + // Sliding expiration + session.expiresAt = Date.now() + AUTH_SESSION_MAX_AGE * 1000; + authSessions.set(token, session); + return { token, ...session }; +} + +function authGuard(req, res, next) { + if (!AUTH_ENABLED || req.method === 'OPTIONS') { + next(); + return; + } + + const publicPaths = ['/api/login', '/api/session', '/health']; + if (publicPaths.includes(req.path)) { + next(); + return; + } + + const session = getSessionFromRequest(req); + if (!session) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + req.authUser = session.username; + next(); +} + function buildScopeCookieValue(scopeId, req) { const secure = isSecureRequest(req); const attributes = [ @@ -388,6 +508,66 @@ function ensureProfileScope(req, res, next) { next(); } +function appendCookieHeader(res, value) { + const existing = res.getHeader('Set-Cookie'); + if (!existing) { + res.setHeader('Set-Cookie', value); + } else if (Array.isArray(existing)) { + res.setHeader('Set-Cookie', [...existing, value]); + } else { + res.setHeader('Set-Cookie', [existing, value]); + } +} + +app.post('/api/login', (req, res) => { + try { + if (!AUTH_ENABLED) { + return res.status(400).json({ error: 'Authentication is not configured' }); + } + + const { username, password } = req.body || {}; + if (username !== AUTH_USERNAME || password !== AUTH_PASSWORD) { + clearAuthCookie(res, req); + return res.status(401).json({ error: 'Ungültige Zugangsdaten' }); + } + + const session = createSession(username); + appendCookieHeader(res, buildAuthCookieValue(session.token, req)); + + res.json({ authenticated: true, username }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.post('/api/logout', (req, res) => { + try { + const session = getSessionFromRequest(req); + if (session) { + authSessions.delete(session.token); + } + clearAuthCookie(res, req); + res.json({ success: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.get('/api/session', (req, res) => { + try { + if (!AUTH_ENABLED) { + return res.json({ authenticated: true, auth_required: false }); + } + const session = getSessionFromRequest(req); + if (!session) { + return res.status(401).json({ authenticated: false, auth_required: true }); + } + res.json({ authenticated: true, username: session.username, auth_required: true }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + function getScopedProfileNumber(scopeId) { if (!scopeId) { return null; @@ -908,6 +1088,9 @@ function extractFacebookContentKey(normalizedUrl) { } const storyFbid = params.get('story_fbid'); + if (lowerPath === '/permalink.php' && storyFbid) { + return `story:${storyFbid}`; + } if (storyFbid) { const ownerId = params.get('id') || params.get('gid') || params.get('group_id') || params.get('page_id') || ''; return `story:${ownerId}:${storyFbid}`; @@ -943,7 +1126,7 @@ function extractFacebookContentKey(normalizedUrl) { return `story:${ownerId}:${storyFbid}`; } - if ((lowerPath === '/permalink.php' || lowerPath === '/story.php') && storyFbid) { + if (lowerPath === '/story.php' && storyFbid) { const ownerId = params.get('id') || ''; return `story:${ownerId}:${storyFbid}`; } @@ -3204,6 +3387,8 @@ const selectPostByAlternateUrlStmt = db.prepare(` `); const selectPostIdByPrimaryUrlStmt = db.prepare('SELECT id FROM posts WHERE url = ?'); const selectPostIdByAlternateUrlStmt = db.prepare('SELECT post_id FROM post_urls WHERE url = ?'); +const selectPostByContentKeyStmt = db.prepare('SELECT * FROM posts WHERE content_key = ? LIMIT 1'); +const selectPostIdByContentKeyStmt = db.prepare('SELECT id FROM posts WHERE content_key = ? LIMIT 1'); const selectAlternateUrlsForPostStmt = db.prepare(` SELECT url FROM post_urls @@ -3267,6 +3452,14 @@ function findPostIdByUrl(normalizedUrl) { return alternateRow.post_id; } + const contentKey = extractFacebookContentKey(normalizedUrl); + if (contentKey) { + const contentRow = selectPostIdByContentKeyStmt.get(contentKey); + if (contentRow && contentRow.id) { + return contentRow.id; + } + } + return null; } @@ -3285,6 +3478,14 @@ function findPostByUrl(normalizedUrl) { return alternate; } + const contentKey = extractFacebookContentKey(normalizedUrl); + if (contentKey) { + const contentMatch = selectPostByContentKeyStmt.get(contentKey); + if (contentMatch) { + return contentMatch; + } + } + return null; } diff --git a/docker-compose.yml b/docker-compose.yml index 00ae598..9cbdc88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,23 @@ -version: '3.8' - -services: - backend: - build: ./backend - container_name: fb-tracker-backend - ports: - - "3001:3000" - volumes: - - ./backend/server.js:/app/server.js:ro - - /opt/docker/posttracker/data:/app/data - environment: - - NODE_ENV=production - - PORT=3000 - labels: - - com.centurylinklabs.watchtower.enable=false - restart: unless-stopped - +version: '3.8' + +services: + backend: + build: ./backend + container_name: fb-tracker-backend + ports: + - "3001:3000" + volumes: + - ./backend/server.js:/app/server.js:ro + - /opt/docker/posttracker/data:/app/data + environment: + - NODE_ENV=production + - PORT=3000 + - AUTH_USERNAME=admin + - AUTH_PASSWORD=RQWhrqowg3rihr + labels: + - com.centurylinklabs.watchtower.enable=false + restart: unless-stopped + web: build: ./web container_name: fb-tracker-web diff --git a/extension/content.js b/extension/content.js index 8cdbe61..c1dfa63 100644 --- a/extension/content.js +++ b/extension/content.js @@ -643,23 +643,23 @@ function getPostUrl(postElement, postNum = '?') { if (mainPostLink) { console.log('[FB Tracker] Post #' + postNum + ' - Found main post URL:', mainPostLink, postElement); - return { url: mainPostLink, allCandidates }; + return { url: mainPostLink, allCandidates, mainUrl: mainPostLink }; } // Fallback to first candidate if (allCandidates.length > 0) { console.log('[FB Tracker] Post #' + postNum + ' - Using first candidate URL:', allCandidates[0], postElement); - return { url: allCandidates[0], allCandidates }; + return { url: allCandidates[0], allCandidates, mainUrl: '' }; } const fallbackCandidate = extractPostUrlCandidate(window.location.href); if (fallbackCandidate) { console.log('[FB Tracker] Post #' + postNum + ' - Using fallback URL:', fallbackCandidate, postElement); - return { url: fallbackCandidate, allCandidates: [fallbackCandidate] }; + return { url: fallbackCandidate, allCandidates: [fallbackCandidate], mainUrl: '' }; } console.log('[FB Tracker] Post #' + postNum + ' - No valid URL found for:', postElement); - return { url: '', allCandidates: [] }; + return { url: '', allCandidates: [], mainUrl: '' }; } function expandPhotoUrlHostVariants(url) { @@ -2792,6 +2792,34 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = const selectElement = container.querySelector(`#${selectId}`); const deadlineInput = container.querySelector(`#${deadlineId}`); selectElement.value = '2'; + const mainLinkUrl = postUrlData.mainUrl; + + if (mainLinkUrl) { + const mainLinkButton = document.createElement('button'); + mainLinkButton.className = 'fb-tracker-mainlink-btn'; + mainLinkButton.type = 'button'; + mainLinkButton.title = 'Main-Link öffnen'; + mainLinkButton.setAttribute('aria-label', 'Main-Link öffnen'); + mainLinkButton.style.cssText = ` + width: 28px; + height: 28px; + padding: 0; + border-radius: 6px; + border: 1px solid #ccd0d5; + background-color: #ffffff; + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: center; + background-size: 16px 16px; + cursor: pointer; + `; + addButton.insertAdjacentElement('afterend', mainLinkButton); + mainLinkButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + window.open(mainLinkUrl, '_blank', 'noopener'); + }); + } if (deadlineInput) { // Try to extract deadline from post text first diff --git a/web/Dockerfile b/web/Dockerfile index 7c869cb..fbabbef 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -7,6 +7,7 @@ COPY settings.html /usr/share/nginx/html/ COPY bookmarks.html /usr/share/nginx/html/ COPY daily-bookmarks.html /usr/share/nginx/html/ COPY automation.html /usr/share/nginx/html/ +COPY login.html /usr/share/nginx/html/ COPY style.css /usr/share/nginx/html/ COPY dashboard.css /usr/share/nginx/html/ COPY settings.css /usr/share/nginx/html/ @@ -17,6 +18,7 @@ COPY dashboard.js /usr/share/nginx/html/ COPY settings.js /usr/share/nginx/html/ COPY daily-bookmarks.js /usr/share/nginx/html/ COPY automation.js /usr/share/nginx/html/ +COPY login.js /usr/share/nginx/html/ COPY vendor /usr/share/nginx/html/vendor/ COPY assets /usr/share/nginx/html/assets/ diff --git a/web/app.js b/web/app.js index e969f18..48b8d39 100644 --- a/web/app.js +++ b/web/app.js @@ -1,4 +1,5 @@ const API_URL = 'https://fb.srv.medeba-media.de/api'; +const LOGIN_PAGE = 'login.html'; let initialViewParam = null; try { @@ -37,6 +38,30 @@ const PROFILE_NAMES = { 4: 'Profil 4', 5: 'Profil 5' }; + +function redirectToLogin() { + try { + const redirect = encodeURIComponent(window.location.href); + window.location.href = `${LOGIN_PAGE}?redirect=${redirect}`; + } catch (_error) { + window.location.href = LOGIN_PAGE; + } +} + +async function ensureAuthenticated() { + try { + const response = await fetch(`${API_URL}/session`, { credentials: 'include' }); + if (response.status === 401) { + redirectToLogin(); + return false; + } + return true; + } catch (error) { + console.warn('Konnte Auth-Status nicht prüfen:', error); + redirectToLogin(); + return false; + } +} function apiFetch(url, options = {}) { const config = { @@ -48,7 +73,36 @@ function apiFetch(url, options = {}) { config.headers = { ...options.headers }; } - return fetch(url, config); + return fetch(url, config).then((response) => { + if (response.status === 401) { + redirectToLogin(); + throw new Error('Nicht angemeldet'); + } + return response; + }); +} + +function markAppReady() { + if (document && document.body) { + document.body.classList.remove('auth-pending'); + } +} + +async function handleLogout() { + try { + await fetch(`${API_URL}/logout`, { method: 'POST', credentials: 'include' }); + } catch (error) { + // ignore network errors on logout + } + redirectToLogin(); +} + +function bindLogoutButton() { + const btn = document.getElementById('logoutBtn'); + if (!btn) { + return; + } + btn.addEventListener('click', handleLogout); } function sortPostsByCreatedAt() { @@ -4544,16 +4598,27 @@ window.addEventListener('resize', () => { }); // Initialize -initializeBookmarks(); -loadAutoRefreshSettings(); -initializeFocusParams(); -initializeTabFromUrl(); -updateMergeControlsUI(); -loadSortMode(); -resetManualPostForm(); -loadProfile(); -startProfilePolling(); -fetchPosts(); -checkAutoCheck(); -startUpdatesStream(); -applyAutoRefreshSettings(); +async function bootstrapApp() { + const authenticated = await ensureAuthenticated(); + if (!authenticated) { + return; + } + + markAppReady(); + bindLogoutButton(); + initializeBookmarks(); + loadAutoRefreshSettings(); + initializeFocusParams(); + initializeTabFromUrl(); + updateMergeControlsUI(); + loadSortMode(); + resetManualPostForm(); + loadProfile(); + startProfilePolling(); + fetchPosts(); + checkAutoCheck(); + startUpdatesStream(); + applyAutoRefreshSettings(); +} + +bootstrapApp(); diff --git a/web/automation.js b/web/automation.js index 950012a..e25a0ff 100644 --- a/web/automation.js +++ b/web/automation.js @@ -96,6 +96,18 @@ let sse = null; let relativeTimer = null; + function handleUnauthorized(response) { + if (response && response.status === 401) { + if (typeof redirectToLogin === 'function') { + redirectToLogin(); + } else { + window.location.href = 'login.html'; + } + return true; + } + return false; + } + function toDateTimeLocal(value) { if (!value) return ''; const date = value instanceof Date ? value : new Date(value); @@ -120,6 +132,9 @@ ...(options.headers || {}) }; const response = await fetch(`${API_URL}${path}`, opts); + if (handleUnauthorized(response)) { + throw new Error('Nicht angemeldet'); + } if (!response.ok) { let message = 'Unbekannter Fehler'; try { @@ -1335,4 +1350,9 @@ activate, deactivate: cleanup }; + + const automationSection = document.querySelector('[data-view="automation"]'); + if (automationSection && automationSection.classList.contains('app-view--active')) { + activate(); + } })(); diff --git a/web/daily-bookmarks.js b/web/daily-bookmarks.js index e8af87f..5fc9132 100644 --- a/web/daily-bookmarks.js +++ b/web/daily-bookmarks.js @@ -9,6 +9,19 @@ const DEFAULT_BULK_COUNT = 5; const DEFAULT_SORT = { column: 'last_completed_at', direction: 'desc' }; const AUTO_OPEN_DELAY_MS = 1500; + const LOGIN_PAGE = 'login.html'; + + function handleUnauthorized(response) { + if (response && response.status === 401) { + if (typeof redirectToLogin === 'function') { + redirectToLogin(); + } else { + window.location.href = LOGIN_PAGE; + } + return true; + } + return false; + } const state = { dayKey: formatDayKey(new Date()), @@ -436,6 +449,10 @@ } }); + if (handleUnauthorized(response)) { + throw new Error('Nicht angemeldet'); + } + if (!response.ok) { const message = `Fehler: HTTP ${response.status}`; throw new Error(message); @@ -1479,4 +1496,9 @@ activate, deactivate: cleanup }; + + const dailySection = document.querySelector('[data-view="daily-bookmarks"]'); + if (dailySection && dailySection.classList.contains('app-view--active')) { + activate(); + } })(); diff --git a/web/dashboard.js b/web/dashboard.js index aa693c1..b15f3dc 100644 --- a/web/dashboard.js +++ b/web/dashboard.js @@ -1,5 +1,6 @@ (() => { const API_URL = 'https://fb.srv.medeba-media.de/api'; +const LOGIN_PAGE = 'login.html'; let posts = []; let filteredPosts = []; @@ -9,6 +10,18 @@ let currentProfileFilter = 'all'; const DAY_IN_MS = 24 * 60 * 60 * 1000; const HOUR_IN_MS = 60 * 60 * 1000; +function handleUnauthorized(response) { + if (response && response.status === 401) { + if (typeof redirectToLogin === 'function') { + redirectToLogin(); + } else { + window.location.href = LOGIN_PAGE; + } + return true; + } + return false; +} + function startOfDay(date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } diff --git a/web/index.html b/web/index.html index 244fbce..1544c07 100644 --- a/web/index.html +++ b/web/index.html @@ -7,13 +7,89 @@ - - - - - + + -
+