vor ähnlichkeitsprüfung

This commit is contained in:
2025-12-21 14:21:55 +01:00
parent ffcfce2b31
commit fde5ab91c8
14 changed files with 721 additions and 50 deletions

View File

@@ -46,6 +46,14 @@ docker-compose up -d
docker-compose logs -f 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 Backend läuft nun auf `http://localhost:3000`
Das Web-Interface ist erreichbar unter `http://localhost:8080` Das Web-Interface ist erreichbar unter `http://localhost:8080`

View File

@@ -47,6 +47,11 @@ const AUTOMATION_WORKER_INTERVAL_MS = 30000;
const AUTOMATION_MAX_STEPS = 3; const AUTOMATION_MAX_STEPS = 3;
const AUTOMATION_MAX_EMAIL_TO_LENGTH = 320; const AUTOMATION_MAX_EMAIL_TO_LENGTH = 320;
const AUTOMATION_MAX_EMAIL_SUBJECT_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 = { const SPORTS_SCORING_DEFAULTS = {
enabled: 1, enabled: 1,
threshold: 5, threshold: 5,
@@ -175,6 +180,9 @@ app.use((req, res, next) => {
next(); next();
}); });
// Simple session-based authentication (enabled when AUTH_USERNAME/PASSWORD are set)
app.use(authGuard);
// Assign per-browser profile scopes via cookies // Assign per-browser profile scopes via cookies
app.use(ensureProfileScope); 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(` const postsMissingHash = db.prepare(`
SELECT id, post_text SELECT id, post_text
FROM posts FROM posts
@@ -346,6 +368,104 @@ function isSecureRequest(req) {
return false; 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) { function buildScopeCookieValue(scopeId, req) {
const secure = isSecureRequest(req); const secure = isSecureRequest(req);
const attributes = [ const attributes = [
@@ -388,6 +508,66 @@ function ensureProfileScope(req, res, next) {
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) { function getScopedProfileNumber(scopeId) {
if (!scopeId) { if (!scopeId) {
return null; return null;
@@ -908,6 +1088,9 @@ function extractFacebookContentKey(normalizedUrl) {
} }
const storyFbid = params.get('story_fbid'); const storyFbid = params.get('story_fbid');
if (lowerPath === '/permalink.php' && storyFbid) {
return `story:${storyFbid}`;
}
if (storyFbid) { if (storyFbid) {
const ownerId = params.get('id') || params.get('gid') || params.get('group_id') || params.get('page_id') || ''; const ownerId = params.get('id') || params.get('gid') || params.get('group_id') || params.get('page_id') || '';
return `story:${ownerId}:${storyFbid}`; return `story:${ownerId}:${storyFbid}`;
@@ -943,7 +1126,7 @@ function extractFacebookContentKey(normalizedUrl) {
return `story:${ownerId}:${storyFbid}`; return `story:${ownerId}:${storyFbid}`;
} }
if ((lowerPath === '/permalink.php' || lowerPath === '/story.php') && storyFbid) { if (lowerPath === '/story.php' && storyFbid) {
const ownerId = params.get('id') || ''; const ownerId = params.get('id') || '';
return `story:${ownerId}:${storyFbid}`; return `story:${ownerId}:${storyFbid}`;
} }
@@ -3204,6 +3387,8 @@ const selectPostByAlternateUrlStmt = db.prepare(`
`); `);
const selectPostIdByPrimaryUrlStmt = db.prepare('SELECT id FROM posts WHERE url = ?'); const selectPostIdByPrimaryUrlStmt = db.prepare('SELECT id FROM posts WHERE url = ?');
const selectPostIdByAlternateUrlStmt = db.prepare('SELECT post_id FROM post_urls 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(` const selectAlternateUrlsForPostStmt = db.prepare(`
SELECT url SELECT url
FROM post_urls FROM post_urls
@@ -3267,6 +3452,14 @@ function findPostIdByUrl(normalizedUrl) {
return alternateRow.post_id; return alternateRow.post_id;
} }
const contentKey = extractFacebookContentKey(normalizedUrl);
if (contentKey) {
const contentRow = selectPostIdByContentKeyStmt.get(contentKey);
if (contentRow && contentRow.id) {
return contentRow.id;
}
}
return null; return null;
} }
@@ -3285,6 +3478,14 @@ function findPostByUrl(normalizedUrl) {
return alternate; return alternate;
} }
const contentKey = extractFacebookContentKey(normalizedUrl);
if (contentKey) {
const contentMatch = selectPostByContentKeyStmt.get(contentKey);
if (contentMatch) {
return contentMatch;
}
}
return null; return null;
} }

View File

@@ -12,6 +12,8 @@ services:
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
- AUTH_USERNAME=admin
- AUTH_PASSWORD=RQWhrqowg3rihr
labels: labels:
- com.centurylinklabs.watchtower.enable=false - com.centurylinklabs.watchtower.enable=false
restart: unless-stopped restart: unless-stopped

View File

@@ -643,23 +643,23 @@ function getPostUrl(postElement, postNum = '?') {
if (mainPostLink) { if (mainPostLink) {
console.log('[FB Tracker] Post #' + postNum + ' - Found main post URL:', mainPostLink, postElement); 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 // Fallback to first candidate
if (allCandidates.length > 0) { if (allCandidates.length > 0) {
console.log('[FB Tracker] Post #' + postNum + ' - Using first candidate URL:', allCandidates[0], postElement); 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); const fallbackCandidate = extractPostUrlCandidate(window.location.href);
if (fallbackCandidate) { if (fallbackCandidate) {
console.log('[FB Tracker] Post #' + postNum + ' - Using fallback URL:', fallbackCandidate, postElement); 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); console.log('[FB Tracker] Post #' + postNum + ' - No valid URL found for:', postElement);
return { url: '', allCandidates: [] }; return { url: '', allCandidates: [], mainUrl: '' };
} }
function expandPhotoUrlHostVariants(url) { function expandPhotoUrlHostVariants(url) {
@@ -2792,6 +2792,34 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options =
const selectElement = container.querySelector(`#${selectId}`); const selectElement = container.querySelector(`#${selectId}`);
const deadlineInput = container.querySelector(`#${deadlineId}`); const deadlineInput = container.querySelector(`#${deadlineId}`);
selectElement.value = '2'; 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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2365766b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M10 13a5 5 0 0 1 0-7l2-2a5 5 0 0 1 7 7l-1 1'/><path d='M14 11a5 5 0 0 1 0 7l-2 2a5 5 0 0 1-7-7l1-1'/></svg>");
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) { if (deadlineInput) {
// Try to extract deadline from post text first // Try to extract deadline from post text first

View File

@@ -7,6 +7,7 @@ COPY settings.html /usr/share/nginx/html/
COPY bookmarks.html /usr/share/nginx/html/ COPY bookmarks.html /usr/share/nginx/html/
COPY daily-bookmarks.html /usr/share/nginx/html/ COPY daily-bookmarks.html /usr/share/nginx/html/
COPY automation.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 style.css /usr/share/nginx/html/
COPY dashboard.css /usr/share/nginx/html/ COPY dashboard.css /usr/share/nginx/html/
COPY settings.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 settings.js /usr/share/nginx/html/
COPY daily-bookmarks.js /usr/share/nginx/html/ COPY daily-bookmarks.js /usr/share/nginx/html/
COPY automation.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 vendor /usr/share/nginx/html/vendor/
COPY assets /usr/share/nginx/html/assets/ COPY assets /usr/share/nginx/html/assets/

View File

@@ -1,4 +1,5 @@
const API_URL = 'https://fb.srv.medeba-media.de/api'; const API_URL = 'https://fb.srv.medeba-media.de/api';
const LOGIN_PAGE = 'login.html';
let initialViewParam = null; let initialViewParam = null;
try { try {
@@ -38,6 +39,30 @@ const PROFILE_NAMES = {
5: 'Profil 5' 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 = {}) { function apiFetch(url, options = {}) {
const config = { const config = {
...options, ...options,
@@ -48,7 +73,36 @@ function apiFetch(url, options = {}) {
config.headers = { ...options.headers }; 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() { function sortPostsByCreatedAt() {
@@ -4544,16 +4598,27 @@ window.addEventListener('resize', () => {
}); });
// Initialize // Initialize
initializeBookmarks(); async function bootstrapApp() {
loadAutoRefreshSettings(); const authenticated = await ensureAuthenticated();
initializeFocusParams(); if (!authenticated) {
initializeTabFromUrl(); return;
updateMergeControlsUI(); }
loadSortMode();
resetManualPostForm(); markAppReady();
loadProfile(); bindLogoutButton();
startProfilePolling(); initializeBookmarks();
fetchPosts(); loadAutoRefreshSettings();
checkAutoCheck(); initializeFocusParams();
startUpdatesStream(); initializeTabFromUrl();
applyAutoRefreshSettings(); updateMergeControlsUI();
loadSortMode();
resetManualPostForm();
loadProfile();
startProfilePolling();
fetchPosts();
checkAutoCheck();
startUpdatesStream();
applyAutoRefreshSettings();
}
bootstrapApp();

View File

@@ -96,6 +96,18 @@
let sse = null; let sse = null;
let relativeTimer = 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) { function toDateTimeLocal(value) {
if (!value) return ''; if (!value) return '';
const date = value instanceof Date ? value : new Date(value); const date = value instanceof Date ? value : new Date(value);
@@ -120,6 +132,9 @@
...(options.headers || {}) ...(options.headers || {})
}; };
const response = await fetch(`${API_URL}${path}`, opts); const response = await fetch(`${API_URL}${path}`, opts);
if (handleUnauthorized(response)) {
throw new Error('Nicht angemeldet');
}
if (!response.ok) { if (!response.ok) {
let message = 'Unbekannter Fehler'; let message = 'Unbekannter Fehler';
try { try {
@@ -1335,4 +1350,9 @@
activate, activate,
deactivate: cleanup deactivate: cleanup
}; };
const automationSection = document.querySelector('[data-view="automation"]');
if (automationSection && automationSection.classList.contains('app-view--active')) {
activate();
}
})(); })();

View File

@@ -9,6 +9,19 @@
const DEFAULT_BULK_COUNT = 5; const DEFAULT_BULK_COUNT = 5;
const DEFAULT_SORT = { column: 'last_completed_at', direction: 'desc' }; const DEFAULT_SORT = { column: 'last_completed_at', direction: 'desc' };
const AUTO_OPEN_DELAY_MS = 1500; 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 = { const state = {
dayKey: formatDayKey(new Date()), dayKey: formatDayKey(new Date()),
@@ -436,6 +449,10 @@
} }
}); });
if (handleUnauthorized(response)) {
throw new Error('Nicht angemeldet');
}
if (!response.ok) { if (!response.ok) {
const message = `Fehler: HTTP ${response.status}`; const message = `Fehler: HTTP ${response.status}`;
throw new Error(message); throw new Error(message);
@@ -1479,4 +1496,9 @@
activate, activate,
deactivate: cleanup deactivate: cleanup
}; };
const dailySection = document.querySelector('[data-view="daily-bookmarks"]');
if (dailySection && dailySection.classList.contains('app-view--active')) {
activate();
}
})(); })();

View File

@@ -1,5 +1,6 @@
(() => { (() => {
const API_URL = 'https://fb.srv.medeba-media.de/api'; const API_URL = 'https://fb.srv.medeba-media.de/api';
const LOGIN_PAGE = 'login.html';
let posts = []; let posts = [];
let filteredPosts = []; let filteredPosts = [];
@@ -9,6 +10,18 @@ let currentProfileFilter = 'all';
const DAY_IN_MS = 24 * 60 * 60 * 1000; const DAY_IN_MS = 24 * 60 * 60 * 1000;
const HOUR_IN_MS = 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) { function startOfDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()); return new Date(date.getFullYear(), date.getMonth(), date.getDate());
} }

View File

@@ -7,13 +7,89 @@
<link rel="icon" type="image/png" sizes="32x32" href="assets/app-icon-64.png"> <link rel="icon" type="image/png" sizes="32x32" href="assets/app-icon-64.png">
<link rel="icon" type="image/png" sizes="192x192" href="assets/app-icon-192.png"> <link rel="icon" type="image/png" sizes="192x192" href="assets/app-icon-192.png">
<link rel="apple-touch-icon" href="assets/app-icon-192.png"> <link rel="apple-touch-icon" href="assets/app-icon-192.png">
<link rel="stylesheet" href="style.css"> <style>
<link rel="stylesheet" href="dashboard.css"> body.auth-pending {
<link rel="stylesheet" href="settings.css"> visibility: hidden;
<link rel="stylesheet" href="automation.css"> overflow: hidden;
<link id="dailyBookmarksCss" rel="stylesheet" href="daily-bookmarks.css" disabled> }
</style>
<script>
(function gateAssets() {
const API_URL = 'https://fb.srv.medeba-media.de/api';
const LOGIN_PAGE = 'login.html';
const cssFiles = [
{ href: 'style.css' },
{ href: 'dashboard.css' },
{ href: 'settings.css' },
{ href: 'automation.css' },
{ href: 'daily-bookmarks.css', id: 'dailyBookmarksCss', disabled: true }
];
const jsFiles = [
'app.js',
'dashboard.js',
'settings.js',
'vendor/list.min.js',
'automation.js',
'daily-bookmarks.js'
];
function redirectToLogin() {
try {
const redirect = encodeURIComponent(window.location.href);
window.location.replace(`${LOGIN_PAGE}?redirect=${redirect}`);
} catch (_error) {
window.location.replace(LOGIN_PAGE);
}
}
function loadStyles() {
cssFiles.forEach(({ href, id, disabled }) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
if (id) link.id = id;
if (disabled) link.disabled = true;
document.head.appendChild(link);
});
}
function loadScriptsSequentially(list, index = 0) {
return new Promise((resolve, reject) => {
if (index >= list.length) {
resolve();
return;
}
const script = document.createElement('script');
script.src = list[index];
script.onload = () => loadScriptsSequentially(list, index + 1).then(resolve).catch(reject);
script.onerror = reject;
document.body.appendChild(script);
});
}
async function bootstrap() {
try {
const res = await fetch(`${API_URL}/session`, { credentials: 'include' });
if (res.status === 401) {
redirectToLogin();
return;
}
loadStyles();
await loadScriptsSequentially(jsFiles);
} catch (_error) {
redirectToLogin();
}
}
if (document.readyState === 'complete' || document.readyState === 'interactive') {
bootstrap();
} else {
document.addEventListener('DOMContentLoaded', bootstrap);
}
})();
</script>
</head> </head>
<body> <body class="auth-pending">
<div class="shell"> <div class="shell">
<header class="site-header site-header--global"> <header class="site-header site-header--global">
<div class="container"> <div class="container">
@@ -28,6 +104,7 @@
<a class="site-nav__btn" data-view-target="bookmarks" href="bookmarks.html">🔖 Bookmarks</a> <a class="site-nav__btn" data-view-target="bookmarks" href="bookmarks.html">🔖 Bookmarks</a>
<a class="site-nav__btn" data-view-target="automation" href="index.html?view=automation">⚡️ Automationen</a> <a class="site-nav__btn" data-view-target="automation" href="index.html?view=automation">⚡️ Automationen</a>
<a class="site-nav__btn" data-view-target="daily-bookmarks" href="index.html?view=daily-bookmarks">✅ Daily Bookmarks</a> <a class="site-nav__btn" data-view-target="daily-bookmarks" href="index.html?view=daily-bookmarks">✅ Daily Bookmarks</a>
<button class="site-nav__btn site-nav__btn--icon" id="logoutBtn" type="button" aria-label="Abmelden">🚪</button>
</div> </div>
</div> </div>
</div> </div>
@@ -1305,12 +1382,6 @@
</main> </main>
</div> </div>
<script src="app.js"></script>
<script src="dashboard.js"></script>
<script src="settings.js"></script>
<script src="vendor/list.min.js"></script>
<script src="automation.js"></script>
<script src="daily-bookmarks.js"></script>
<script> <script>
(function() { (function() {
const buttons = Array.from(document.querySelectorAll('[data-view-target]')); const buttons = Array.from(document.querySelectorAll('[data-view-target]'));
@@ -1320,11 +1391,25 @@
const initialParam = params.get('view'); const initialParam = params.get('view');
const defaultView = viewMap[initialParam] ? initialParam : 'posts'; const defaultView = viewMap[initialParam] ? initialParam : 'posts';
const VIEW_CHANGE_EVENT = 'app:view-change'; const VIEW_CHANGE_EVENT = 'app:view-change';
const BASE_TITLE = 'Post Tracker';
const VIEW_TITLES = {
posts: 'Beiträge',
dashboard: 'Dashboard',
settings: 'Einstellungen',
bookmarks: 'Bookmarks',
automation: 'Automationen',
'daily-bookmarks': 'Daily Bookmarks'
};
function dispatchViewChange(view) { function dispatchViewChange(view) {
window.dispatchEvent(new CustomEvent(VIEW_CHANGE_EVENT, { detail: { view } })); window.dispatchEvent(new CustomEvent(VIEW_CHANGE_EVENT, { detail: { view } }));
} }
function updateDocumentTitle(view) {
const title = VIEW_TITLES[view];
document.title = title ? `${title} ${BASE_TITLE}` : BASE_TITLE;
}
function setView(view, options = { updateHistory: true }) { function setView(view, options = { updateHistory: true }) {
if (!viewMap[view]) { if (!viewMap[view]) {
view = 'posts'; view = 'posts';
@@ -1357,6 +1442,7 @@
window.history.pushState({ view }, '', newUrl); window.history.pushState({ view }, '', newUrl);
} }
updateDocumentTitle(view);
dispatchViewChange(view); dispatchViewChange(view);
} }

111
web/login.html Normal file
View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login Post Tracker</title>
<link rel="icon" type="image/png" sizes="32x32" href="assets/app-icon-64.png">
<link rel="icon" type="image/png" sizes="192x192" href="assets/app-icon-192.png">
<link rel="apple-touch-icon" href="assets/app-icon-192.png">
<style>
:root {
color-scheme: light;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #0f172a;
}
.login-card {
background: #fff;
padding: 32px;
border-radius: 14px;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.15);
width: min(420px, 90vw);
box-sizing: border-box;
}
.login-card h1 {
margin: 0 0 8px;
font-size: 24px;
}
.login-card p {
margin: 0 0 24px;
color: #475569;
line-height: 1.4;
}
label {
display: block;
margin-bottom: 6px;
font-weight: 600;
}
input {
width: 100%;
padding: 12px;
border: 1px solid #cbd5e1;
border-radius: 10px;
box-sizing: border-box;
font-size: 16px;
transition: border-color 0.2s, box-shadow 0.2s;
}
input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
outline: none;
}
.field {
margin-bottom: 18px;
}
button {
width: 100%;
padding: 12px 14px;
background: #2563eb;
color: #fff;
font-weight: 700;
font-size: 16px;
border: none;
border-radius: 10px;
cursor: pointer;
transition: transform 0.08s ease, box-shadow 0.2s ease, background 0.2s ease;
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.25);
}
button:hover {
background: #1d4ed8;
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
box-shadow: 0 6px 20px rgba(37, 99, 235, 0.18);
}
.status {
margin-top: 14px;
min-height: 22px;
color: #b91c1c;
font-weight: 600;
}
</style>
</head>
<body>
<div class="login-card">
<h1>📋 Post Tracker Login</h1>
<p>Bitte melde dich an, um das Dashboard zu öffnen.</p>
<form id="loginForm" novalidate>
<div class="field">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username" autocomplete="username" required>
</div>
<div class="field">
<label for="password">Passwort</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
</div>
<button type="submit">Anmelden</button>
<div id="status" class="status" role="status" aria-live="polite"></div>
</form>
</div>
<script src="login.js"></script>
</body>
</html>

89
web/login.js Normal file
View File

@@ -0,0 +1,89 @@
const API_URL = 'https://fb.srv.medeba-media.de/api';
function getRedirectTarget() {
try {
const params = new URLSearchParams(window.location.search);
const redirect = params.get('redirect');
if (redirect) {
return decodeURIComponent(redirect);
}
} catch (error) {
console.warn('Konnte Redirect-Parameter nicht lesen:', error);
}
return 'index.html';
}
function updateStatus(message, isError = false) {
const statusEl = document.getElementById('status');
if (!statusEl) {
return;
}
statusEl.textContent = message || '';
statusEl.style.color = isError ? '#b91c1c' : '#15803d';
}
async function checkExistingSession() {
try {
const response = await fetch(`${API_URL}/session`, { credentials: 'include' });
if (response.ok) {
const data = await response.json();
if (data && data.authenticated) {
window.location.href = getRedirectTarget();
return true;
}
}
} catch (error) {
console.warn('Konnte Session nicht prüfen:', error);
}
return false;
}
async function handleLogin(event) {
event.preventDefault();
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const username = usernameInput ? usernameInput.value.trim() : '';
const password = passwordInput ? passwordInput.value : '';
if (!username || !password) {
updateStatus('Bitte Benutzername und Passwort eingeben.', true);
return;
}
updateStatus('Anmeldung läuft…', false);
try {
const response = await fetch(`${API_URL}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
const message = payload && payload.error ? payload.error : 'Anmeldung fehlgeschlagen';
updateStatus(message, true);
return;
}
updateStatus('Erfolgreich angemeldet. Weiterleitung…', false);
window.location.href = getRedirectTarget();
} catch (error) {
console.error('Login fehlgeschlagen:', error);
updateStatus('Netzwerkfehler bitte erneut versuchen.', true);
}
}
document.addEventListener('DOMContentLoaded', async () => {
const alreadyLoggedIn = await checkExistingSession();
if (alreadyLoggedIn) {
return;
}
const form = document.getElementById('loginForm');
if (form) {
form.addEventListener('submit', handleLogin);
}
});

View File

@@ -1,5 +1,6 @@
(() => { (() => {
const API_URL = 'https://fb.srv.medeba-media.de/api'; const API_URL = 'https://fb.srv.medeba-media.de/api';
const LOGIN_PAGE = 'login.html';
const PROVIDER_MODELS = { const PROVIDER_MODELS = {
gemini: [ gemini: [
@@ -72,8 +73,25 @@ let moderationSettings = {
sports_auto_hide_enabled: false sports_auto_hide_enabled: false
}; };
function handleUnauthorized(response) {
if (response && response.status === 401) {
if (typeof redirectToLogin === 'function') {
redirectToLogin();
} else {
window.location.href = LOGIN_PAGE;
}
return true;
}
return false;
}
function apiFetch(url, options = {}) { function apiFetch(url, options = {}) {
return fetch(url, {...options, credentials: 'include'}); return fetch(url, {...options, credentials: 'include'}).then((response) => {
if (handleUnauthorized(response)) {
throw new Error('Nicht angemeldet');
}
return response;
});
} }
function showToast(message, type = 'info') { function showToast(message, type = 'info') {

View File

@@ -122,6 +122,12 @@ header {
text-decoration: none; text-decoration: none;
} }
.site-nav__btn--icon {
padding: 10px 12px;
font-size: 18px;
line-height: 1;
}
.site-nav__btn::after { .site-nav__btn::after {
content: ""; content: "";
position: absolute; position: absolute;