diff --git a/backend/server.js b/backend/server.js index 7b55857..e5c6bd8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -31,6 +31,9 @@ const DEFAULT_PROFILE_NAMES = { 4: 'Profil 4', 5: 'Profil 5' }; +const DEFAULT_ACTIVE_PROFILES = Object.freeze( + Array.from({ length: MAX_PROFILES }, (_unused, index) => index + 1) +); const PROFILE_SCOPE_COOKIE = 'fb_tracker_scope'; const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days const FACEBOOK_TRACKING_PARAM_PREFIXES = ['__cft__', '__tn__', '__eep__', 'mibextid']; @@ -687,17 +690,27 @@ function setScopedProfileNumber(scopeId, profileNumber) { `).run(scopeId, profileNumber); } -function clampTargetCount(value) { +function normalizeProfileLimit(value) { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed) || parsed < 1) { + return MAX_PROFILES; + } + return Math.min(MAX_PROFILES, parsed); +} + +function clampTargetCount(value, maxProfiles = MAX_PROFILES) { + const maxAllowed = normalizeProfileLimit(maxProfiles); const parsed = parseInt(value, 10); if (Number.isNaN(parsed)) { return 1; } - return Math.min(MAX_PROFILES, Math.max(1, parsed)); + return Math.min(maxAllowed, Math.max(1, parsed)); } -function validateTargetCount(value) { +function validateTargetCount(value, maxProfiles = MAX_PROFILES) { + const maxAllowed = normalizeProfileLimit(maxProfiles); const parsed = parseInt(value, 10); - if (Number.isNaN(parsed) || parsed < 1 || parsed > MAX_PROFILES) { + if (Number.isNaN(parsed) || parsed < 1 || parsed > maxAllowed) { return null; } return parsed; @@ -711,6 +724,120 @@ function sanitizeProfileNumber(value) { return parsed; } +function normalizeProfileNumberList(raw) { + let source = raw; + if (typeof source === 'string') { + const trimmed = source.trim(); + if (!trimmed) { + return []; + } + try { + source = JSON.parse(trimmed); + } catch (error) { + source = trimmed.split(','); + } + } + + if (!Array.isArray(source)) { + return []; + } + + const seen = new Set(); + const profiles = []; + source.forEach((value) => { + const profileNumber = sanitizeProfileNumber(value); + if (!profileNumber || seen.has(profileNumber)) { + return; + } + seen.add(profileNumber); + profiles.push(profileNumber); + }); + + return profiles.sort((a, b) => a - b); +} + +function getDefaultActiveProfiles() { + return [...DEFAULT_ACTIVE_PROFILES]; +} + +function loadProfileSettings() { + let settings = db.prepare('SELECT * FROM profile_settings WHERE id = 1').get(); + let activeProfiles = settings ? normalizeProfileNumberList(settings.active_profiles) : []; + + if (!activeProfiles.length) { + activeProfiles = getDefaultActiveProfiles(); + const serialized = JSON.stringify(activeProfiles); + const existing = db.prepare('SELECT id FROM profile_settings WHERE id = 1').get(); + if (existing) { + db.prepare(` + UPDATE profile_settings + SET active_profiles = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `).run(serialized); + } else { + db.prepare(` + INSERT INTO profile_settings (id, active_profiles, updated_at) + VALUES (1, ?, CURRENT_TIMESTAMP) + `).run(serialized); + } + settings = db.prepare('SELECT * FROM profile_settings WHERE id = 1').get(); + } + + const inactiveProfiles = getDefaultActiveProfiles().filter(profileNumber => !activeProfiles.includes(profileNumber)); + + return { + updated_at: settings && settings.updated_at ? settings.updated_at : null, + active_profiles: activeProfiles, + inactive_profiles: inactiveProfiles, + max_target_count: activeProfiles.length + }; +} + +function saveProfileSettings(input = {}) { + const activeProfiles = normalizeProfileNumberList(input.active_profiles); + if (!activeProfiles.length) { + throw new Error('Mindestens ein Profil muss aktiv bleiben.'); + } + + const serialized = JSON.stringify(activeProfiles); + const existing = db.prepare('SELECT id FROM profile_settings WHERE id = 1').get(); + if (existing) { + db.prepare(` + UPDATE profile_settings + SET active_profiles = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 + `).run(serialized); + } else { + db.prepare(` + INSERT INTO profile_settings (id, active_profiles, updated_at) + VALUES (1, ?, CURRENT_TIMESTAMP) + `).run(serialized); + } + + return loadProfileSettings(); +} + +function getActiveProfiles(settings = null) { + const source = settings && Array.isArray(settings.active_profiles) + ? settings.active_profiles + : loadProfileSettings().active_profiles; + const activeProfiles = normalizeProfileNumberList(source); + return activeProfiles.length ? activeProfiles : getDefaultActiveProfiles(); +} + +function isActiveProfileNumber(profileNumber, settings = null) { + const sanitized = sanitizeProfileNumber(profileNumber); + if (!sanitized) { + return false; + } + return getActiveProfiles(settings).includes(sanitized); +} + +function getSelectableScopedProfileNumber(scopeId, settings = null) { + const profileNumber = getScopedProfileNumber(scopeId); + return isActiveProfileNumber(profileNumber, settings) ? profileNumber : null; +} + function applyProfileVariantTemplates(text, profileNumber) { if (typeof text !== 'string' || !text) { return text || ''; @@ -1950,8 +2077,10 @@ function extractFacebookContentKey(normalizedUrl) { } function getRequiredProfiles(targetCount) { - const count = clampTargetCount(targetCount); - return Array.from({ length: count }, (_, index) => index + 1); + const profileSettings = loadProfileSettings(); + const activeProfiles = getActiveProfiles(profileSettings); + const count = clampTargetCount(targetCount, activeProfiles.length); + return activeProfiles.slice(0, count); } function buildProfileStatuses(requiredProfiles, checks) { @@ -2004,11 +2133,14 @@ function buildCompletedProfileSet(rows) { return completedSet; } -function countUniqueProfileChecks(checks) { +function countUniqueProfileChecks(checks, allowedProfiles = null) { + const allowedSet = Array.isArray(allowedProfiles) && allowedProfiles.length + ? new Set(allowedProfiles) + : null; const uniqueProfiles = new Set(); checks.forEach((check) => { const profileNumber = sanitizeProfileNumber(check.profile_number); - if (profileNumber) { + if (profileNumber && (!allowedSet || allowedSet.has(profileNumber))) { uniqueProfiles.add(profileNumber); } }); @@ -2039,7 +2171,7 @@ function recalcCheckedCount(postId) { const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ?').all(postId); const requiredProfiles = getRequiredProfiles(post.target_count); const { statuses } = buildProfileStatuses(requiredProfiles, checks); - const checkedCount = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks)); + const checkedCount = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks, requiredProfiles)); const updates = []; const params = []; @@ -2170,6 +2302,14 @@ db.exec(` ); `); +db.exec(` + CREATE TABLE IF NOT EXISTS profile_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + active_profiles TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); +`); + db.exec(` CREATE TABLE IF NOT EXISTS ai_auto_comment_rate_limit_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -2678,6 +2818,7 @@ ensureColumn('posts', 'is_successful', 'is_successful INTEGER DEFAULT 0'); ensureColumn('ai_settings', 'active_credential_id', 'active_credential_id INTEGER'); ensureColumn('ai_settings', 'prompt_prefix', 'prompt_prefix TEXT'); ensureColumn('ai_settings', 'enabled', 'enabled INTEGER DEFAULT 0'); +ensureColumn('profile_settings', 'active_profiles', 'active_profiles TEXT'); ensureColumn('ai_auto_comment_rate_limit_settings', 'enabled', 'enabled INTEGER NOT NULL DEFAULT 1'); ensureColumn('ai_auto_comment_rate_limit_settings', 'requests_per_minute', 'requests_per_minute INTEGER NOT NULL DEFAULT 2'); ensureColumn('ai_auto_comment_rate_limit_settings', 'requests_per_hour', 'requests_per_hour INTEGER NOT NULL DEFAULT 20'); @@ -4905,7 +5046,7 @@ function mapPostRow(post) { const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ? ORDER BY checked_at ASC').all(post.id); const requiredProfiles = getRequiredProfiles(post.target_count); const { statuses, completedChecks } = buildProfileStatuses(requiredProfiles, checks); - const checkedCount = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks)); + const checkedCount = Math.min(requiredProfiles.length, countUniqueProfileChecks(checks, requiredProfiles)); const screenshotFile = post.screenshot_path ? path.join(screenshotDir, post.screenshot_path) : null; const screenshotPath = screenshotFile && fs.existsSync(screenshotFile) ? `/api/posts/${post.id}/screenshot` @@ -6146,10 +6287,34 @@ app.delete('/api/search-posts', (req, res) => { } }); +app.get('/api/profile-settings', (req, res) => { + try { + const settings = loadProfileSettings(); + res.json(settings); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.put('/api/profile-settings', (req, res) => { + try { + const { active_profiles } = req.body || {}; + if (!Array.isArray(active_profiles)) { + return res.status(400).json({ error: 'active_profiles must be an array' }); + } + + const saved = saveProfileSettings({ active_profiles }); + res.json(saved); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + app.get('/api/profile-state', (req, res) => { try { const scopeId = req.profileScope; - const profileNumber = getScopedProfileNumber(scopeId); + const profileSettings = loadProfileSettings(); + const profileNumber = getSelectableScopedProfileNumber(scopeId, profileSettings); res.json({ profile_number: profileNumber || null }); } catch (error) { res.status(500).json({ error: error.message }); @@ -6163,13 +6328,13 @@ app.post('/api/profile-state', (req, res) => { return res.status(400).json({ error: 'profile_number is required' }); } - const parsed = parseInt(profile_number, 10); - if (Number.isNaN(parsed) || parsed < 1 || parsed > 5) { - return res.status(400).json({ error: 'profile_number must be between 1 and 5' }); + const profileSettings = loadProfileSettings(); + const sanitized = sanitizeProfileNumber(profile_number); + if (!sanitized || !isActiveProfileNumber(sanitized, profileSettings)) { + return res.status(400).json({ error: 'profile_number must reference an active profile' }); } const scopeId = req.profileScope; - const sanitized = sanitizeProfileNumber(parsed) || 1; setScopedProfileNumber(scopeId, sanitized); res.json({ profile_number: sanitized }); @@ -6277,7 +6442,11 @@ app.post('/api/posts', (req, res) => { post_text } = req.body; - const validatedTargetCount = validateTargetCount(typeof target_count === 'undefined' ? 1 : target_count); + const activeProfiles = getActiveProfiles(); + const validatedTargetCount = validateTargetCount( + typeof target_count === 'undefined' ? 1 : target_count, + activeProfiles.length + ); const alternateUrlsInput = Array.isArray(req.body.alternate_urls) ? req.body.alternate_urls : []; const normalizedUrl = normalizeFacebookPostUrl(url); @@ -6287,7 +6456,7 @@ app.post('/api/posts', (req, res) => { } if (!validatedTargetCount) { - return res.status(400).json({ error: 'target_count must be between 1 and 5' }); + return res.status(400).json({ error: `target_count must be between 1 and ${activeProfiles.length}` }); } const id = uuidv4(); @@ -6417,11 +6586,12 @@ app.put('/api/posts/:postId', (req, res) => { const params = []; let normalizedUrlForCleanup = null; let updatedContentKey = null; + const activeProfiles = getActiveProfiles(); if (typeof target_count !== 'undefined') { - const validatedTargetCount = validateTargetCount(target_count); + const validatedTargetCount = validateTargetCount(target_count, activeProfiles.length); if (!validatedTargetCount) { - return res.status(400).json({ error: 'target_count must be between 1 and 5' }); + return res.status(400).json({ error: `target_count must be between 1 and ${activeProfiles.length}` }); } updates.push('target_count = ?'); params.push(validatedTargetCount); @@ -6546,6 +6716,7 @@ app.post('/api/posts/:postId/check', (req, res) => { } } + const profileSettings = loadProfileSettings(); const requiredProfiles = getRequiredProfiles(post.target_count); let didChange = false; if (post.target_count !== requiredProfiles.length) { @@ -6554,11 +6725,18 @@ app.post('/api/posts/:postId/check', (req, res) => { didChange = true; } - let profileValue = sanitizeProfileNumber(profile_number) || getScopedProfileNumber(req.profileScope); + let profileValue = sanitizeProfileNumber(profile_number); + if (!profileValue) { + profileValue = getSelectableScopedProfileNumber(req.profileScope, profileSettings); + } if (!profileValue) { return res.status(400).json({ error: 'Profil muss zuerst ausgewählt werden.' }); } + if (!requiredProfiles.includes(profileValue)) { + return res.status(409).json({ error: 'Dieses Profil ist aktuell deaktiviert oder für diesen Beitrag nicht erforderlich.' }); + } + const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(postId); const completedSet = buildCompletedProfileSet(completedRows); const shouldEnforceOrder = shouldEnforceProfileOrder({ @@ -6696,6 +6874,7 @@ app.post('/api/check-by-url', (req, res) => { } } + const profileSettings = loadProfileSettings(); const requiredProfiles = getRequiredProfiles(post.target_count); let didChange = false; if (post.target_count !== requiredProfiles.length) { @@ -6704,11 +6883,18 @@ app.post('/api/check-by-url', (req, res) => { didChange = true; } - let profileValue = sanitizeProfileNumber(profile_number) || getScopedProfileNumber(req.profileScope); + let profileValue = sanitizeProfileNumber(profile_number); + if (!profileValue) { + profileValue = getSelectableScopedProfileNumber(req.profileScope, profileSettings); + } if (!profileValue) { return res.status(400).json({ error: 'Profil muss zuerst ausgewählt werden.' }); } + if (!requiredProfiles.includes(profileValue)) { + return res.status(409).json({ error: 'Dieses Profil ist aktuell deaktiviert oder für diesen Beitrag nicht erforderlich.' }); + } + const completedRows = db.prepare('SELECT profile_number FROM checks WHERE post_id = ?').all(post.id); const completedSet = buildCompletedProfileSet(completedRows); const shouldEnforceOrder = shouldEnforceProfileOrder({ @@ -6790,7 +6976,7 @@ app.post('/api/posts/:postId/profile-status', (req, res) => { } if (!requiredProfiles.includes(profileValue)) { - return res.status(409).json({ error: 'Dieses Profil ist für diesen Beitrag nicht erforderlich.' }); + return res.status(409).json({ error: 'Dieses Profil ist aktuell deaktiviert oder für diesen Beitrag nicht erforderlich.' }); } const normalizedStatus = status === 'done' ? 'done' : 'pending'; @@ -6967,9 +7153,10 @@ app.post('/api/posts/merge', (req, res) => { return primaryPost.deadline_at || secondaryPost.deadline_at || null; })(); + const activeProfileLimit = getActiveProfiles().length; const mergedTargetCount = Math.max( - validateTargetCount(primaryPost.target_count) || 1, - validateTargetCount(secondaryPost.target_count) || 1 + clampTargetCount(primaryPost.target_count, activeProfileLimit), + clampTargetCount(secondaryPost.target_count, activeProfileLimit) ); const mergedPostText = (primaryPost.post_text && primaryPost.post_text.trim()) diff --git a/web/app.js b/web/app.js index b636f96..ccb061e 100644 --- a/web/app.js +++ b/web/app.js @@ -31,6 +31,7 @@ let updatesStreamHealthy = false; let updatesShouldResyncOnConnect = false; const MAX_PROFILES = 5; +const DEFAULT_ACTIVE_PROFILES = Array.from({ length: MAX_PROFILES }, (_unused, index) => index + 1); const PROFILE_NAMES = { 1: 'Profil 1', 2: 'Profil 2', @@ -38,11 +39,42 @@ const PROFILE_NAMES = { 4: 'Profil 4', 5: 'Profil 5' }; +let activeProfileNumbers = [...DEFAULT_ACTIVE_PROFILES]; function isValidProfileNumber(value) { return Number.isInteger(value) && value >= 1 && value <= MAX_PROFILES; } +function normalizeActiveProfileNumbers(values) { + if (!Array.isArray(values)) { + return [...DEFAULT_ACTIVE_PROFILES]; + } + + const seen = new Set(); + const normalized = values + .map((value) => parseInt(value, 10)) + .filter((value) => isValidProfileNumber(value) && !seen.has(value) && seen.add(value)) + .sort((a, b) => a - b); + + return normalized.length ? normalized : [...DEFAULT_ACTIVE_PROFILES]; +} + +function getActiveProfileNumbers() { + return activeProfileNumbers.length ? [...activeProfileNumbers] : [...DEFAULT_ACTIVE_PROFILES]; +} + +function isSelectableProfileNumber(value) { + return isValidProfileNumber(value) && getActiveProfileNumbers().includes(value); +} + +function getActiveTargetCountLimit() { + return Math.max(1, getActiveProfileNumbers().length); +} + +function getTargetCountErrorMessage() { + return `Die Anzahl der benötigten Profile muss zwischen 1 und ${getActiveTargetCountLimit()} liegen.`; +} + function redirectToLogin() { try { const redirect = encodeURIComponent(window.location.href); @@ -2142,11 +2174,12 @@ function normalizeRequiredProfiles(post) { } const parsedTarget = parseInt(post.target_count, 10); + const activeProfiles = getActiveProfileNumbers(); const count = Number.isNaN(parsedTarget) ? 1 - : Math.min(MAX_PROFILES, Math.max(1, parsedTarget)); + : Math.min(activeProfiles.length, Math.max(1, parsedTarget)); - return Array.from({ length: count }, (_, index) => index + 1); + return activeProfiles.slice(0, count); } function getTabButtons() { @@ -3510,7 +3543,7 @@ function applyAutoRefreshSettings() { autoRefreshTimer = null; } - if (!isValidProfileNumber(currentProfile)) { + if (!isSelectableProfileNumber(currentProfile)) { return; } @@ -3534,7 +3567,7 @@ function applyAutoRefreshSettings() { if (document.hidden) { return; } - if (!isValidProfileNumber(currentProfile)) { + if (!isSelectableProfileNumber(currentProfile)) { return; } fetchPosts({ showLoader: false }); @@ -3782,6 +3815,90 @@ function applyScreenshotModalSize() { }); } +function renderProfileSelectorOptions() { + if (!profileSelectElement) { + return; + } + + const activeProfiles = getActiveProfileNumbers(); + profileSelectElement.innerHTML = ` + + ${activeProfiles.map((profileNumber) => ` + + `).join('')} + `; + + if (isSelectableProfileNumber(currentProfile)) { + profileSelectElement.value = String(currentProfile); + } +} + +function renderManualTargetOptions(selectedValue = 1) { + if (!manualPostTargetSelect) { + return; + } + + const maxCount = getActiveTargetCountLimit(); + const normalizedSelected = Math.max(1, Math.min(maxCount, parseInt(selectedValue, 10) || 1)); + manualPostTargetSelect.innerHTML = Array.from({ length: maxCount }, (_unused, index) => { + const value = index + 1; + return ``; + }).join(''); + manualPostTargetSelect.value = String(normalizedSelected); +} + +function clearSelectedProfile(message = 'Bitte zuerst ein Profil auswählen.') { + if (profileSelectElement) { + profileSelectElement.value = ''; + } + currentProfile = null; + try { + localStorage.removeItem('profileNumber'); + } catch (error) { + console.warn('Konnte gespeichertes Profil nicht entfernen:', error); + } + pendingOpenCooldownMap = loadPendingOpenCooldownMap(null); + renderPosts(); + applyAutoRefreshSettings(); + showError(message); +} + +function applyProfileSettings(settings, { rerender = true, refreshPosts = false } = {}) { + const nextActiveProfiles = normalizeActiveProfileNumbers(settings && settings.active_profiles); + activeProfileNumbers = nextActiveProfiles; + renderProfileSelectorOptions(); + renderManualTargetOptions(manualPostTargetSelect ? manualPostTargetSelect.value : 1); + + if (currentProfile !== null && !isSelectableProfileNumber(currentProfile)) { + clearSelectedProfile('Das gespeicherte Profil ist deaktiviert. Bitte wähle ein aktives Profil.'); + } else if (profileSelectElement && isSelectableProfileNumber(currentProfile)) { + profileSelectElement.value = String(currentProfile); + } + + if (rerender && currentProfile !== null) { + renderPosts(); + } + + if (refreshPosts) { + fetchPosts({ showLoader: false }); + } +} + +async function loadProfileSettings() { + try { + const response = await apiFetch(`${API_URL}/profile-settings`); + if (!response.ok) { + return null; + } + const data = await response.json(); + applyProfileSettings(data, { rerender: false }); + return data; + } catch (error) { + console.warn('Profil-Einstellungen konnten nicht geladen werden:', error); + return null; + } +} + async function fetchProfileState() { try { const response = await apiFetch(`${API_URL}/profile-state`); @@ -3815,16 +3932,16 @@ async function pushProfileState(profileNumber) { } function ensureProfileSelected() { - if (isValidProfileNumber(currentProfile)) { + if (isSelectableProfileNumber(currentProfile)) { hideError(); return true; } - showError('Bitte zuerst ein Profil auswählen.'); + showError('Bitte zuerst ein aktives Profil auswählen.'); return false; } function applyProfileNumber(profileNumber, { fromBackend = false } = {}) { - if (!isValidProfileNumber(profileNumber)) { + if (!isSelectableProfileNumber(profileNumber)) { return; } @@ -3863,20 +3980,16 @@ function applyProfileNumber(profileNumber, { fromBackend = false } = {}) { // Load profile from localStorage function loadProfile() { fetchProfileState().then((backendProfile) => { - if (isValidProfileNumber(backendProfile)) { + if (isSelectableProfileNumber(backendProfile)) { applyProfileNumber(backendProfile, { fromBackend: true }); } else { const saved = localStorage.getItem('profileNumber'); const parsed = saved ? parseInt(saved, 10) : NaN; - if (isValidProfileNumber(parsed)) { + if (isSelectableProfileNumber(parsed)) { applyProfileNumber(parsed, { fromBackend: true }); return; } - if (profileSelectElement) { - profileSelectElement.value = ''; - } - currentProfile = null; - showError('Bitte zuerst ein Profil auswählen.'); + clearSelectedProfile('Bitte zuerst ein aktives Profil auswählen.'); } }); } @@ -3893,8 +4006,12 @@ function startProfilePolling() { profilePollTimer = setInterval(async () => { const backendProfile = await fetchProfileState(); - if (backendProfile && backendProfile !== currentProfile) { + if (isSelectableProfileNumber(backendProfile) && backendProfile !== currentProfile) { applyProfileNumber(backendProfile, { fromBackend: true }); + return; + } + if (backendProfile === null && currentProfile !== null && !isSelectableProfileNumber(currentProfile)) { + clearSelectedProfile('Das gespeicherte Profil ist deaktiviert. Bitte wähle ein aktives Profil.'); } }, 5000); } @@ -3903,8 +4020,8 @@ function startProfilePolling() { if (profileSelectElement) { profileSelectElement.addEventListener('change', (e) => { const parsed = parseInt(e.target.value, 10); - if (!isValidProfileNumber(parsed)) { - showError('Bitte zuerst ein Profil auswählen.'); + if (!isSelectableProfileNumber(parsed)) { + showError('Bitte zuerst ein aktives Profil auswählen.'); return; } saveProfile(parsed); @@ -4984,7 +5101,7 @@ function createPostCard(post, status, meta = {}) {