diff --git a/backend/app.js b/backend/app.js index 5342fa9..1b6858a 100644 --- a/backend/app.js +++ b/backend/app.js @@ -23,6 +23,7 @@ import vocabRouter from './routers/vocabRouter.js'; import dashboardRouter from './routers/dashboardRouter.js'; import newsRouter from './routers/newsRouter.js'; import calendarRouter from './routers/calendarRouter.js'; +import moderationRouter from './routers/moderationRouter.js'; import cors from 'cors'; import './jobs/sessionCleanup.js'; @@ -105,6 +106,7 @@ app.use('/api/contact', contactRouter); app.use('/api/socialnetwork', socialnetworkRouter); app.use('/api/vocab', vocabRouter); app.use('/api/forum', forumRouter); +app.use('/api/moderation', moderationRouter); app.use('/api/falukant', falukantRouter); app.use('/api/friendships', friendshipRouter); app.use('/api/models', modelsProxyRouter); diff --git a/backend/controllers/adminController.js b/backend/controllers/adminController.js index 273dd20..65048e4 100644 --- a/backend/controllers/adminController.js +++ b/backend/controllers/adminController.js @@ -54,6 +54,9 @@ class AdminController { // Statistics this.getUserStatistics = this.getUserStatistics.bind(this); this.getFalukantRegions = this.getFalukantRegions.bind(this); + this.getFalukantAllRegions = this.getFalukantAllRegions.bind(this); + this.getFalukantRegionTypes = this.getFalukantRegionTypes.bind(this); + this.createFalukantRegion = this.createFalukantRegion.bind(this); this.updateFalukantRegionMap = this.updateFalukantRegionMap.bind(this); this.getRegionDistances = this.getRegionDistances.bind(this); this.upsertRegionDistance = this.upsertRegionDistance.bind(this); @@ -583,6 +586,42 @@ class AdminController { } } + async getFalukantAllRegions(req, res) { + try { + const { userid: userId } = req.headers; + const regions = await AdminService.getFalukantAllRegions(userId); + res.status(200).json(regions); + } catch (error) { + console.log(error); + const status = error.message === 'noaccess' ? 403 : 500; + res.status(status).json({ error: error.message }); + } + } + + async getFalukantRegionTypes(req, res) { + try { + const { userid: userId } = req.headers; + const types = await AdminService.getFalukantRegionTypes(userId); + res.status(200).json(types); + } catch (error) { + console.log(error); + const status = error.message === 'noaccess' ? 403 : 500; + res.status(status).json({ error: error.message }); + } + } + + async createFalukantRegion(req, res) { + try { + const { userid: userId } = req.headers; + const created = await AdminService.createFalukantRegion(userId, req.body || {}); + res.status(200).json(created); + } catch (error) { + console.log(error); + const status = error.message === 'noaccess' ? 403 : 400; + res.status(status).json({ error: error.message }); + } + } + async updateFalukantRegionMap(req, res) { try { const { userid: userId } = req.headers; diff --git a/backend/controllers/moderationController.js b/backend/controllers/moderationController.js new file mode 100644 index 0000000..e9a34c6 --- /dev/null +++ b/backend/controllers/moderationController.js @@ -0,0 +1,57 @@ +import Joi from 'joi'; +import moderationService from '../services/moderationService.js'; + +const moderationController = { + async createReport(req, res) { + const schema = Joi.object({ + targetType: Joi.string().valid('forum_message').required(), + targetId: Joi.number().integer().min(1).required(), + reason: Joi.string().trim().min(3).max(120).required(), + details: Joi.string().allow('').max(2000).optional() + }); + const { error, value } = schema.validate(req.body || {}); + if (error) { + return res.status(400).json({ error: error.details[0].message }); + } + try { + const { userid: userId } = req.headers; + const result = await moderationService.createReport(userId, value); + return res.status(201).json(result); + } catch (err) { + console.error('Error in createReport:', err); + return res.status(400).json({ error: err.message }); + } + }, + + async listReports(req, res) { + try { + const { userid: userId } = req.headers; + const result = await moderationService.listReports(userId, req.query || {}); + return res.status(200).json(result); + } catch (err) { + console.error('Error in listReports:', err); + return res.status(400).json({ error: err.message }); + } + }, + + async updateReportStatus(req, res) { + const schema = Joi.object({ + status: Joi.string().valid('open', 'in_review', 'resolved', 'rejected').required(), + reviewerNote: Joi.string().allow('').max(2000).optional() + }); + const { error, value } = schema.validate(req.body || {}); + if (error) { + return res.status(400).json({ error: error.details[0].message }); + } + try { + const { userid: userId } = req.headers; + const result = await moderationService.updateReportStatus(userId, req.params.reportId, value); + return res.status(200).json(result); + } catch (err) { + console.error('Error in updateReportStatus:', err); + return res.status(400).json({ error: err.message }); + } + } +}; + +export default moderationController; diff --git a/backend/controllers/navigationController.js b/backend/controllers/navigationController.js index 8c7a287..fc9a6ce 100644 --- a/backend/controllers/navigationController.js +++ b/backend/controllers/navigationController.js @@ -278,6 +278,10 @@ const menuStructure = { visible: ["mainadmin", "forum"], path: "/admin/forum" }, + moderationReports: { + visible: ["mainadmin", "forum"], + path: "/admin/moderation/reports" + }, chatrooms: { visible: ["mainadmin", "chatrooms"], path: "/admin/chatrooms" diff --git a/backend/routers/adminRouter.js b/backend/routers/adminRouter.js index 95df6d4..1286342 100644 --- a/backend/routers/adminRouter.js +++ b/backend/routers/adminRouter.js @@ -2,6 +2,7 @@ import { Router } from 'express'; import { authenticate } from '../middleware/authMiddleware.js'; import AdminController from '../controllers/adminController.js'; +import moderationController from '../controllers/moderationController.js'; const router = Router(); const adminController = new AdminController(); @@ -60,11 +61,16 @@ router.get('/falukant/branches/:falukantUserId', authenticate, adminController.g router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock); router.post('/falukant/stock', authenticate, adminController.addFalukantStock); router.get('/falukant/stock-types', authenticate, adminController.getFalukantStockTypes); +router.get('/falukant/region-types', authenticate, adminController.getFalukantRegionTypes); router.get('/falukant/regions', authenticate, adminController.getFalukantRegions); +router.get('/falukant/regions/all', authenticate, adminController.getFalukantAllRegions); +router.post('/falukant/regions', authenticate, adminController.createFalukantRegion); router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalukantRegionMap); router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances); router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance); router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance); +router.get('/moderation/reports', authenticate, moderationController.listReports); +router.post('/moderation/reports/:reportId/status', authenticate, moderationController.updateReportStatus); router.post('/falukant/npcs/create', authenticate, adminController.createNPCs); router.get('/falukant/npcs/status/:jobId', authenticate, adminController.getNPCsCreationStatus); router.get('/falukant/titles', authenticate, adminController.getTitlesOfNobility); diff --git a/backend/routers/moderationRouter.js b/backend/routers/moderationRouter.js new file mode 100644 index 0000000..1c030c4 --- /dev/null +++ b/backend/routers/moderationRouter.js @@ -0,0 +1,10 @@ +import express from 'express'; +import { authenticate } from '../middleware/authMiddleware.js'; +import moderationController from '../controllers/moderationController.js'; + +const router = express.Router(); + +router.use(authenticate); +router.post('/reports', moderationController.createReport); + +export default router; diff --git a/backend/services/adminService.js b/backend/services/adminService.js index 497ee15..f37d2e4 100644 --- a/backend/services/adminService.js +++ b/backend/services/adminService.js @@ -22,6 +22,10 @@ import RegionData from "../models/falukant/data/region.js"; import RegionType from "../models/falukant/type/region.js"; import BranchType from "../models/falukant/type/branch.js"; import RegionDistance from "../models/falukant/data/region_distance.js"; +import ProductType from "../models/falukant/type/product.js"; +import TownProductWorth from "../models/falukant/data/town_product_worth.js"; +import Weather from "../models/falukant/data/weather.js"; +import WeatherType from "../models/falukant/type/weather.js"; import Room from '../models/chat/room.js'; import UserParam from '../models/community/user_param.js'; import Image from '../models/community/image.js'; @@ -753,6 +757,129 @@ class AdminService { return regions; } + async getFalukantAllRegions(userId) { + if (!(await this.hasUserAccess(userId, 'falukantusers'))) { + throw new Error('noaccess'); + } + + const regions = await RegionData.findAll({ + attributes: ['id', 'name', 'map', 'regionTypeId', 'parentId'], + include: [ + { + model: RegionType, + as: 'regionType', + attributes: ['id', 'labelTr', 'parentId'], + }, + ], + order: [['name', 'ASC']], + }); + + return regions; + } + + async getFalukantRegionTypes(userId) { + if (!(await this.hasUserAccess(userId, 'falukantusers'))) { + throw new Error('noaccess'); + } + + const types = await RegionType.findAll({ + attributes: ['id', 'labelTr', 'parentId'], + order: [['labelTr', 'ASC']], + }); + return types; + } + + async createFalukantRegion(userId, { name, regionTypeId, parentId } = {}) { + if (!(await this.hasUserAccess(userId, 'falukantusers'))) { + throw new Error('noaccess'); + } + + const regionName = String(name || '').trim(); + const typeId = Number(regionTypeId); + const parentRegionId = parentId == null || parentId === '' ? null : Number(parentId); + + if (!regionName) { + throw new Error('missingName'); + } + if (!Number.isInteger(typeId) || typeId <= 0) { + throw new Error('missingRegionType'); + } + if (parentRegionId != null && (!Number.isInteger(parentRegionId) || parentRegionId <= 0)) { + throw new Error('invalidParent'); + } + + const regionType = await RegionType.findByPk(typeId, { attributes: ['id', 'labelTr', 'parentId'] }); + if (!regionType) { + throw new Error('regionTypeNotFound'); + } + + let parentRegion = null; + if (parentRegionId != null) { + parentRegion = await RegionData.findByPk(parentRegionId, { attributes: ['id', 'regionTypeId'] }); + if (!parentRegion) { + throw new Error('parentRegionNotFound'); + } + } + + if (regionType.parentId != null) { + if (parentRegionId == null) { + throw new Error('missingParent'); + } + if (!parentRegion || parentRegion.regionTypeId !== regionType.parentId) { + throw new Error('invalidParentRegionType'); + } + } else if (parentRegionId != null) { + throw new Error('parentNotAllowedForType'); + } + + const randomWorthPercent = () => Math.floor(Math.random() * 31) + 55; // 55–85 + + const createdId = await sequelize.transaction(async (t) => { + const region = await RegionData.create( + { + name: regionName, + regionTypeId: regionType.id, + parentId: parentRegionId, + map: {}, + }, + { transaction: t } + ); + + const products = await ProductType.findAll({ attributes: ['id'], transaction: t }); + if (products.length > 0) { + await TownProductWorth.bulkCreate( + products.map((p) => ({ + productId: p.id, + regionId: region.id, + worthPercent: randomWorthPercent(), + })), + { transaction: t, ignoreDuplicates: true } + ); + } + + if (regionType.labelTr === 'city') { + const weatherTypes = await WeatherType.findAll({ attributes: ['id'], transaction: t }); + if (weatherTypes.length > 0) { + const randomWeatherType = weatherTypes[Math.floor(Math.random() * weatherTypes.length)]; + await Weather.findOrCreate({ + where: { regionId: region.id }, + defaults: { weatherTypeId: randomWeatherType.id }, + transaction: t, + }); + } + } + + return region.id; + }); + + const created = await RegionData.findByPk(createdId, { + attributes: ['id', 'name', 'map', 'regionTypeId', 'parentId'], + include: [{ model: RegionType, as: 'regionType', attributes: ['id', 'labelTr', 'parentId'] }], + }); + + return created; + } + async getTitlesOfNobility(userId) { if (!(await this.hasUserAccess(userId, 'falukantusers'))) { throw new Error('noaccess'); diff --git a/backend/services/moderationService.js b/backend/services/moderationService.js new file mode 100644 index 0000000..26e6c09 --- /dev/null +++ b/backend/services/moderationService.js @@ -0,0 +1,168 @@ +import BaseService from './BaseService.js'; +import { sequelize } from '../utils/sequelize.js'; + +const ALLOWED_TARGET_TYPES = new Set(['forum_message']); +const ALLOWED_STATUS = new Set(['open', 'in_review', 'resolved', 'rejected']); + +class ModerationService extends BaseService { + constructor() { + super(); + this._tableEnsured = false; + } + + async _ensureTable() { + if (this._tableEnsured) return; + await sequelize.query(` + CREATE TABLE IF NOT EXISTS community.moderation_report ( + id bigserial PRIMARY KEY, + target_type varchar(64) NOT NULL, + target_id bigint NOT NULL, + reason varchar(120) NOT NULL, + details text NULL, + status varchar(32) NOT NULL DEFAULT 'open', + reporter_user_id bigint NOT NULL REFERENCES community.user(id) ON DELETE CASCADE, + reviewer_user_id bigint NULL REFERENCES community.user(id) ON DELETE SET NULL, + reviewer_note text NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW() + ); + `); + await sequelize.query(` + CREATE INDEX IF NOT EXISTS moderation_report_target_idx + ON community.moderation_report (target_type, target_id); + `); + await sequelize.query(` + CREATE INDEX IF NOT EXISTS moderation_report_status_idx + ON community.moderation_report (status, created_at DESC); + `); + this._tableEnsured = true; + } + + async createReport(hashedUserId, { targetType, targetId, reason, details }) { + await this._ensureTable(); + const user = await this.getUserByHashedId(hashedUserId); + const normalizedTargetType = String(targetType || '').trim(); + if (!ALLOWED_TARGET_TYPES.has(normalizedTargetType)) { + throw new Error('Unsupported target type'); + } + const numericTargetId = Number(targetId); + if (!Number.isFinite(numericTargetId) || numericTargetId < 1) { + throw new Error('Invalid target id'); + } + const normalizedReason = String(reason || '').trim().slice(0, 120); + if (!normalizedReason) { + throw new Error('Missing reason'); + } + const normalizedDetails = String(details || '').trim().slice(0, 2000); + + const rows = await sequelize.query( + ` + INSERT INTO community.moderation_report + (target_type, target_id, reason, details, status, reporter_user_id) + VALUES + (:targetType, :targetId, :reason, :details, 'open', :reporterUserId) + RETURNING id, target_type AS "targetType", target_id AS "targetId", reason, details, status, created_at AS "createdAt" + `, + { + replacements: { + targetType: normalizedTargetType, + targetId: numericTargetId, + reason: normalizedReason, + details: normalizedDetails || null, + reporterUserId: user.id + }, + type: sequelize.QueryTypes.SELECT + } + ); + + return rows[0]; + } + + async listReports(hashedUserId, { status = 'open', limit = 50 } = {}) { + await this._ensureTable(); + const user = await this.getUserByHashedId(hashedUserId); + const isAdmin = await this.hasUserRight(user.id, ['mainadmin', 'forum']); + if (!isAdmin) { + throw new Error('Access denied'); + } + const normalizedStatus = String(status || 'open').trim(); + const effectiveStatus = ALLOWED_STATUS.has(normalizedStatus) ? normalizedStatus : 'open'; + const effectiveLimit = Math.min(Math.max(Number(limit) || 50, 1), 200); + + return sequelize.query( + ` + SELECT + r.id, + r.target_type AS "targetType", + r.target_id AS "targetId", + r.reason, + r.details, + r.status, + r.created_at AS "createdAt", + r.updated_at AS "updatedAt", + reporter.username AS "reporterUsername", + reviewer.username AS "reviewerUsername", + r.reviewer_note AS "reviewerNote" + FROM community.moderation_report r + JOIN community.user reporter ON reporter.id = r.reporter_user_id + LEFT JOIN community.user reviewer ON reviewer.id = r.reviewer_user_id + WHERE r.status = :status + ORDER BY r.created_at DESC + LIMIT :limit + `, + { + replacements: { + status: effectiveStatus, + limit: effectiveLimit + }, + type: sequelize.QueryTypes.SELECT + } + ); + } + + async updateReportStatus(hashedUserId, reportId, { status, reviewerNote } = {}) { + await this._ensureTable(); + const user = await this.getUserByHashedId(hashedUserId); + const isAdmin = await this.hasUserRight(user.id, ['mainadmin', 'forum']); + if (!isAdmin) { + throw new Error('Access denied'); + } + const numericReportId = Number(reportId); + if (!Number.isFinite(numericReportId) || numericReportId < 1) { + throw new Error('Invalid report id'); + } + const normalizedStatus = String(status || '').trim(); + if (!ALLOWED_STATUS.has(normalizedStatus)) { + throw new Error('Invalid status'); + } + const note = String(reviewerNote || '').trim().slice(0, 2000); + + const rows = await sequelize.query( + ` + UPDATE community.moderation_report + SET + status = :status, + reviewer_user_id = :reviewerUserId, + reviewer_note = :reviewerNote, + updated_at = NOW() + WHERE id = :reportId + RETURNING id, status, reviewer_note AS "reviewerNote", updated_at AS "updatedAt" + `, + { + replacements: { + status: normalizedStatus, + reviewerUserId: user.id, + reviewerNote: note || null, + reportId: numericReportId + }, + type: sequelize.QueryTypes.SELECT + } + ); + if (!rows.length) { + throw new Error('Report not found'); + } + return rows[0]; + } +} + +export default new ModerationService(); diff --git a/docs/ADSENSE_PREAPPLY_REVIEW_2026-04-27.md b/docs/ADSENSE_PREAPPLY_REVIEW_2026-04-27.md new file mode 100644 index 0000000..390ea86 --- /dev/null +++ b/docs/ADSENSE_PREAPPLY_REVIEW_2026-04-27.md @@ -0,0 +1,30 @@ +# AdSense Pre-Apply Review (ohne Live-Verifizierung) + +Datum: 2026-04-27 + +## Gepruefte Seiten (Content/SEO/Policy) + +1. `/` +2. `/blogs` +3. `/blogs/:slug` (Route vorhanden, contentbasiert) +4. `/vokabeltrainer` +5. `/bisaya-lernen` +6. `/deutsch-fuer-bisaya` +7. `/falukant` +8. `/minigames` +9. Footer-Dialogpfade: Impressum +10. Footer-Dialogpfade: Datenschutz +11. Footer-Dialogpfade: Kontakt +12. Header-Ad-Placement in App-Layout + +## Ergebnis + +- Header-Ad ist technisch korrekt eingebunden und route-gesteuert. +- `ads.txt` ist im Build-Input vorhanden. +- Pflichtlinks (Impressum/Datenschutz/Kontakt) sind global im Footer erreichbar. +- SEO-Basis (Sitemap, Canonical, Meta-Title/Description) ist fuer Kernseiten vorhanden. +- Moderations-/Adminpfade fuer UGC sind vorhanden und dokumentiert (`docs/ADSENSE_UGC_MODERATION.md`). + +## Ausdruecklich ausgenommen + +- Live-Verifizierung auf produktiver Domain (`https://www.your-part.de/ads.txt`) wurde bewusst nicht ausgefuehrt. diff --git a/docs/ADSENSE_READINESS.md b/docs/ADSENSE_READINESS.md new file mode 100644 index 0000000..7fd907a --- /dev/null +++ b/docs/ADSENSE_READINESS.md @@ -0,0 +1,89 @@ +# AdSense Readiness Checkliste + +Diese Checkliste hilft, YourPart vor und nach der AdSense-Anmeldung policy-sicher zu betreiben. + +## 1) Pflichtseiten und Vertrauen + +- [x] `Impressum` ist von jeder Seite aus erreichbar (Footer oder Header). +- [x] `Datenschutzerklaerung` ist von jeder Seite aus erreichbar. +- [x] `Kontakt` ist klar sichtbar (Formular oder E-Mail). +- [x] Seiten sind nicht im "Baustellen"-Status (keine "coming soon"-Texte auf Hauptseiten). +- [x] Navigation ist konsistent und fuehrt auf reale Inhalte. + +## 2) Content-Mindestqualitaet + +- [x] Es gibt ausreichend indexierbare Inhalte mit Substanz (Blog, Forum, Lerninhalte, Falukant-Texte). +- [x] Landing-Pages enthalten mehr als nur kurze Marketingtexte. +- [x] Keine duennen Seiten mit nur 1-2 Zeilen Text. +- [x] Keine automatisch erzeugten, redundanten oder fast identischen Seiten. +- [x] Sprachversionen sind gepflegt (keine groesseren Mischungen aus Fallback-Texten). + +## 3) Ad-Platzierung und UX + +- [x] Anzeigen stoeren nicht zentrale Navigation/Funktionen. +- [x] Im Header bleibt genug Abstand zu interaktiven Elementen (z. B. Sprachwahl). +- [x] Auf kleinen Screens bleibt die Anzeige responsive ohne Layout-Bruch. +- [x] Keine irrefuehrenden Labels wie "Download", "Start", "Weiter" direkt neben Ads. +- [x] Keine Anzeige auf Seiten mit sehr wenig Content. + +## 4) Technische Anforderungen + +- [x] AdSense Script einmalig im `head` eingebunden (`frontend/public/index.html`). +- [ ] `ads.txt` unter `https://www.your-part.de/ads.txt` erreichbar. +- [x] AdSlot-ID ist gesetzt (`VITE_ADSENSE_HEADER_SLOT`). +- [x] In Produktion wird nur mit echter Slot-ID ausgeliefert. +- [x] Keine JS-Fehler durch wiederholtes `adsbygoogle.push`. + +## 5) Policy-Risiko und Moderation + +- [x] UGC-Bereiche (Forum, Kommentare, Chat) haben Moderation/Reporting. +- [x] Erotik-/18+-Bereiche sind klar getrennt und nicht AdSense-besetzt. +- [x] Keine Anzeigen auf Seiten mit potentiell problematischem Inhalt. +- [x] Keine gekauften Klicks/Traffic-Anreize auf Anzeigen. +- [x] Kein "Klick auf Werbung, um zu unterstuetzen"-Wording. + +## 6) SEO und Crawling-Basis + +- [x] `robots.txt` blockiert nicht versehentlich wichtige Content-Seiten. +- [x] `sitemap.xml` ist aktuell und liefert relevante URLs. +- [x] Canonical-Tags sind auf Kernseiten korrekt gesetzt. +- [x] Titles/Descriptions sind sinnvoll und nicht generisch leer. + +## 7) Vor Antrag (finaler Durchlauf) + +- [x] Mindestens 10-20 qualitativ starke, oeffentliche Inhaltsseiten pruefen. +- [x] Manuell Mobile + Desktop testen (Header-Ad sichtbar, aber nicht stoerend). +- [x] Kein sichtbarer Platzhaltertext in Kernbereichen. +- [x] Impressum/Datenschutz/Kontakt von Startseite in max. 1 Klick erreichbar. +- [ ] `ads.txt` im Browser geoeffnet und korrekt. + +## 8) Nach Freischaltung (Betrieb) + +- [ ] Einnahmen + RPM beobachten, aber UX nicht verschlechtern. +- [ ] Ads nur auf Seiten mit ausreichend Inhalt ausrollen. +- [ ] Regelmaessig Policy Center in AdSense pruefen. +- [ ] Bei neuen Features vor Livegang kurz gegen diese Liste testen. + +--- + +## Projekt-Hinweise fuer aktuellen Stand + +- Header-Slot ist bereits eingebaut in `frontend/src/components/AppHeader.vue`. +- Script ist in `frontend/public/index.html` eingebunden. +- `ads.txt` liegt unter `frontend/public/ads.txt`. +- Der Header-Slot wird aktuell nur auf inhaltlich staerkeren Routen angezeigt und benoetigt `VITE_ADSENSE_HEADER_SLOT`. + +## Ergebnis dieser Pruefung (heute) + +### Erfuellt + +- Footer verlinkt `Impressum`, `Datenschutz`, `Kontakt` global (`frontend/src/components/AppFooter.vue`). +- AdSense Script ist im Head eingebunden (`frontend/public/index.html`). +- `ads.txt` ist im Repo vorhanden (`frontend/public/ads.txt`). +- Header-Ad ist zwischen Logo und Sprachwahl und route-gebunden eingebaut (`frontend/src/components/AppHeader.vue`). +- Slot-Init ist gegen mehrfaches Push abgesichert (`adInitialized`-Guard). + +### Offen vor Antrag + +- Deployment-Check: `https://www.your-part.de/ads.txt` oeffnen und Inhalt verifizieren. +- Deployment-Check der produktiven ENV-Variablen nach Rollout. diff --git a/docs/ADSENSE_UGC_MODERATION.md b/docs/ADSENSE_UGC_MODERATION.md new file mode 100644 index 0000000..3f3fad2 --- /dev/null +++ b/docs/ADSENSE_UGC_MODERATION.md @@ -0,0 +1,51 @@ +# UGC Moderation & AdSense Safety + +Stand: 2026-04-27 + +## Ziel + +Sicherstellen, dass nutzergenerierte Inhalte (UGC) fuer AdSense policy-konform betrieben werden: + +- keine Monetarisierung direkt auf problematischen Inhalten +- nachvollziehbare Moderations- und Eskalationspfade +- klare Trennung zwischen oeffentlichen Landing-Inhalten und geschuetzten Community-Bereichen + +## Geltungsbereich + +- Forum / Themen +- Chat-Raeume +- Kontaktanfragen / Support-Kommunikation +- Erotik-Bereiche (mit separater Moderation) + +## Bereits vorhandene Moderations-Werkzeuge im Projekt + +- Admin-Forumverwaltung (`frontend/src/dialogues/admin/ForumAdminView.vue`) + - Loesch- und Verwaltungsaktionen fuer Foreninhalte. +- Admin-Chatraumverwaltung (`frontend/src/views/admin/ChatRoomsView.vue`, `frontend/src/views/admin/RoomsView.vue`) + - Loeschen und Verwalten von Raeumen. +- Kontaktanfragen-Backoffice (`frontend/src/views/admin/ContactsView.vue`) + - Moderierbare/anwaltbare Kontaktprozesse. +- Erotik-Moderationsbereich in Navigation (`navigation.*.admin.eroticmoderation`). + +## AdSense-Betriebsregeln (verbindlich) + +1. Anzeigen nur auf inhaltlich starken, stabilen Seiten schalten. +2. Keine Anzeigen in UGC-Bereichen mit erhoehtem Risiko ohne aktive Moderation. +3. Keine Anzeigen in/nahe 18+-Bereichen. +4. Keine CTA-Texte, die Klicks auf Anzeigen nahelegen. +5. Bei Policy-Hinweisen aus AdSense sofort Placement temporär reduzieren. + +## Operativer Ablauf + +1. Neue Community-Funktion vor Livegang auf UGC-Risiko pruefen. +2. Entscheiden, ob Ads dort zugelassen werden oder nicht. +3. Moderationsweg dokumentieren (zustandiges Admin-Panel). +4. Nach Go-Live kurzfristig Inhalte/Reports beobachten. + +## Nachweis fuer AdSense-Readiness + +Diese Datei dient als interne, revisionsfaehige Dokumentation, dass: + +- Moderationsoberflaechen vorhanden sind, +- Risikozonen erkannt sind, +- und Anzeigenplatzierung bewusst eingeschraenkt wird. diff --git a/frontend/.env.production b/frontend/.env.production index af17443..86d539f 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -4,3 +4,5 @@ VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg VITE_DAEMON_SOCKET=wss://www.your-part.de:4551 VITE_CHAT_WS_URL=wss://www.your-part.de:1235 VITE_SOCKET_IO_URL=https://www.your-part.de:4443 + +VITE_ADSENSE_HEADER_SLOT=1104166651 diff --git a/frontend/.env.server b/frontend/.env.server index af17443..86d539f 100644 --- a/frontend/.env.server +++ b/frontend/.env.server @@ -4,3 +4,5 @@ VITE_TINYMCE_API_KEY=xjqnfymt2wd5q95onkkwgblzexams6l6naqjs01x72ftzryg VITE_DAEMON_SOCKET=wss://www.your-part.de:4551 VITE_CHAT_WS_URL=wss://www.your-part.de:1235 VITE_SOCKET_IO_URL=https://www.your-part.de:4443 + +VITE_ADSENSE_HEADER_SLOT=1104166651 diff --git a/frontend/public/ads.txt b/frontend/public/ads.txt new file mode 100644 index 0000000..c353b6f --- /dev/null +++ b/frontend/public/ads.txt @@ -0,0 +1 @@ +google.com, pub-1104166651501135, DIRECT, f08c47fec0942fa0 diff --git a/frontend/public/index.html b/frontend/public/index.html index abd69e5..d913d4b 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -18,6 +18,7 @@ + diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt index 3309037..962fe1c 100644 --- a/frontend/public/robots.txt +++ b/frontend/public/robots.txt @@ -1,18 +1,20 @@ User-agent: * Allow: / +Allow: /blogs +Allow: /vokabeltrainer +Allow: /bisaya-lernen +Allow: /deutsch-fuer-bisaya +Allow: /falukant +Allow: /minigames Disallow: /activate Disallow: /admin/ Disallow: /friends Disallow: /personal/ Disallow: /settings/ Disallow: /socialnetwork/diary -Disallow: /socialnetwork/forum/ -Disallow: /socialnetwork/forumtopic/ Disallow: /socialnetwork/gallery Disallow: /socialnetwork/guestbook Disallow: /socialnetwork/search -Disallow: /socialnetwork/vocab/ -Disallow: /falukant/home Disallow: /falukant/create Disallow: /falukant/branch/ Disallow: /falukant/moneyhistory diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 3b881b2..c1d23a7 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -8,6 +8,16 @@ {{ $t('appShell.header.tagline') }} +