diff --git a/build.gradle b/build.gradle index edd5f5d..6614d6d 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'gg.modl.backend' -version = '2.3.0' +version = '2.3.1' description = 'backend' java { diff --git a/mongo-fields-processor/src/main/java/gg/modl/backend/database/mongo/codegen/MongoFieldsProcessor.java b/mongo-fields-processor/src/main/java/gg/modl/backend/database/mongo/codegen/MongoFieldsProcessor.java index 1c52408..5eb5592 100644 --- a/mongo-fields-processor/src/main/java/gg/modl/backend/database/mongo/codegen/MongoFieldsProcessor.java +++ b/mongo-fields-processor/src/main/java/gg/modl/backend/database/mongo/codegen/MongoFieldsProcessor.java @@ -380,7 +380,7 @@ private String annotationStringValue(AnnotationMirror mirror, String propertyNam } for (final Map.Entry entry : - processingEnv.getElementUtils().getElementValuesWithDefaults(mirror).entrySet()) { + elements.getElementValuesWithDefaults(mirror).entrySet()) { if (propertyName.equals(entry.getKey().getSimpleName().toString())) { Object value = entry.getValue().getValue(); diff --git a/scripts/migrations/2026-03-12-consolidate-indexes.mongosh.js b/scripts/migrations/2026-03-12-consolidate-indexes.mongosh.js index 611e211..4966356 100644 --- a/scripts/migrations/2026-03-12-consolidate-indexes.mongosh.js +++ b/scripts/migrations/2026-03-12-consolidate-indexes.mongosh.js @@ -1,112 +1,112 @@ -"use strict"; - -if (typeof db === "undefined" || db === null) { - throw new Error("This script must be run inside mongosh."); -} - -const CONFIG = { - targetDatabases: [], // Leave empty to run on all discovered tenants - globalDbName: "modl", - serversCollName: "servers" -}; - -const INDEX_DEFINITIONS = [ - { - collection: "system_logs", - indexes: [ - { name: "idx_system_logs_timestamp", keys: { timestamp: -1 } }, - { name: "idx_system_logs_level_timestamp", keys: { level: 1, timestamp: -1 } }, - { name: "idx_system_logs_source_timestamp", keys: { source: 1, timestamp: -1 } } - ] - }, - { - collection: "security_events", - indexes: [ - { name: "idx_security_events_timestamp", keys: { timestamp: -1 } }, - { name: "idx_security_events_severity_timestamp", keys: { severity: 1, timestamp: -1 } } - ] - }, - { - collection: "metric_snapshots", - indexes: [ - { name: "idx_metric_snapshots_date", keys: { date: -1 } } - ] - }, - { - collection: "logs", - indexes: [ - { name: "idx_logs_created", keys: { created: -1 } }, - { name: "idx_logs_source_created", keys: { source: 1, created: -1 } } - ] - }, - { - collection: "migrations", - indexes: [ - { name: "idx_migrations_status_startedAt", keys: { status: 1, startedAt: -1 } } - ] - }, - { - collection: "sessions", - indexes: [ - { name: "idx_sessions_email_expiresAt", keys: { email: 1, expiresAt: 1 } } - ] - } -]; - -async function main() { - print(`\n--- Consolidate Indexes Migration ---`); - - try { - const serversDb = db.getSiblingDB(CONFIG.globalDbName); - const servers = serversDb.getCollection(CONFIG.serversCollName).find({ databaseName: { $exists: true, $ne: null } }).toArray(); - - const tenants = CONFIG.targetDatabases.length > 0 - ? servers.filter(s => CONFIG.targetDatabases.includes(s.databaseName)) - : servers; - - print(`Targeting ${tenants.length} tenant databases...\n`); - - let globalCreated = 0; - let globalSkipped = 0; - - for (const tenant of tenants) { - const result = ensureIndexesForTenant(tenant.databaseName); - globalCreated += result.created; - globalSkipped += result.skipped; - print(`[${tenant.databaseName}] Created: ${result.created} | Already existed: ${result.skipped}`); - } - - print(`\n--- GLOBAL SUMMARY ---`); - print(`Total indexes created: ${globalCreated}`); - print(`Total indexes already existed: ${globalSkipped}`); - print(`----------------------\n`); - - } catch (error) { - print(`\n[FATAL ERROR] Script execution failed: ${error.message}`); - } -} - -function ensureIndexesForTenant(databaseName) { - const tenantDb = db.getSiblingDB(databaseName); - let created = 0; - let skipped = 0; - - for (const def of INDEX_DEFINITIONS) { - const coll = tenantDb.getCollection(def.collection); - const existing = coll.getIndexes().map(idx => idx.name); - - for (const idx of def.indexes) { - if (existing.includes(idx.name)) { - skipped++; - continue; - } - - coll.createIndex(idx.keys, { name: idx.name }); - created++; - } - } - - return { created, skipped }; -} - -main(); +"use strict"; + +if (typeof db === "undefined" || db === null) { + throw new Error("This script must be run inside mongosh."); +} + +const CONFIG = { + targetDatabases: [], // Leave empty to run on all discovered tenants + globalDbName: "modl", + serversCollName: "servers" +}; + +const INDEX_DEFINITIONS = [ + { + collection: "system_logs", + indexes: [ + { name: "idx_system_logs_timestamp", keys: { timestamp: -1 } }, + { name: "idx_system_logs_level_timestamp", keys: { level: 1, timestamp: -1 } }, + { name: "idx_system_logs_source_timestamp", keys: { source: 1, timestamp: -1 } } + ] + }, + { + collection: "security_events", + indexes: [ + { name: "idx_security_events_timestamp", keys: { timestamp: -1 } }, + { name: "idx_security_events_severity_timestamp", keys: { severity: 1, timestamp: -1 } } + ] + }, + { + collection: "metric_snapshots", + indexes: [ + { name: "idx_metric_snapshots_date", keys: { date: -1 } } + ] + }, + { + collection: "logs", + indexes: [ + { name: "idx_logs_created", keys: { created: -1 } }, + { name: "idx_logs_source_created", keys: { source: 1, created: -1 } } + ] + }, + { + collection: "migrations", + indexes: [ + { name: "idx_migrations_status_startedAt", keys: { status: 1, startedAt: -1 } } + ] + }, + { + collection: "sessions", + indexes: [ + { name: "idx_sessions_email_expiresAt", keys: { email: 1, expiresAt: 1 } } + ] + } +]; + +async function main() { + print(`\n--- Consolidate Indexes Migration ---`); + + try { + const serversDb = db.getSiblingDB(CONFIG.globalDbName); + const servers = serversDb.getCollection(CONFIG.serversCollName).find({ databaseName: { $exists: true, $ne: null } }).toArray(); + + const tenants = CONFIG.targetDatabases.length > 0 + ? servers.filter(s => CONFIG.targetDatabases.includes(s.databaseName)) + : servers; + + print(`Targeting ${tenants.length} tenant databases...\n`); + + let globalCreated = 0; + let globalSkipped = 0; + + for (const tenant of tenants) { + const result = ensureIndexesForTenant(tenant.databaseName); + globalCreated += result.created; + globalSkipped += result.skipped; + print(`[${tenant.databaseName}] Created: ${result.created} | Already existed: ${result.skipped}`); + } + + print(`\n--- GLOBAL SUMMARY ---`); + print(`Total indexes created: ${globalCreated}`); + print(`Total indexes already existed: ${globalSkipped}`); + print(`----------------------\n`); + + } catch (error) { + print(`\n[FATAL ERROR] Script execution failed: ${error.message}`); + } +} + +function ensureIndexesForTenant(databaseName) { + const tenantDb = db.getSiblingDB(databaseName); + let created = 0; + let skipped = 0; + + for (const def of INDEX_DEFINITIONS) { + const coll = tenantDb.getCollection(def.collection); + const existing = coll.getIndexes().map(idx => idx.name); + + for (const idx of def.indexes) { + if (existing.includes(idx.name)) { + skipped++; + continue; + } + + coll.createIndex(idx.keys, { name: idx.name }); + created++; + } + } + + return { created, skipped }; +} + +main(); diff --git a/src/main/java/gg/modl/backend/BackendApplication.java b/src/main/java/gg/modl/backend/BackendApplication.java index cb98768..769fb3f 100644 --- a/src/main/java/gg/modl/backend/BackendApplication.java +++ b/src/main/java/gg/modl/backend/BackendApplication.java @@ -1,30 +1,30 @@ -package gg.modl.backend; - -import io.github.cdimascio.dotenv.Dotenv; -import io.github.cdimascio.dotenv.DotenvException; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.context.properties.ConfigurationPropertiesScan; - -@SpringBootApplication -@ConfigurationPropertiesScan -public class BackendApplication { - public static void main(String[] args) { - loadDotenvIntoSystemProperties(); - - SpringApplication.run(BackendApplication.class, args); - } - - private static void loadDotenvIntoSystemProperties() { - try { - final Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); - dotenv.entries(Dotenv.Filter.DECLARED_IN_ENV_FILE).forEach(entry -> - System.setProperty(entry.getKey(), entry.getValue()) - ); - } catch (DotenvException e) { - System.err.println("[BackendApplication] Could not read .env file (it may be a " - + "directory or unreadable, e.g. an empty bind-mount). Continuing with " - + "environment/-e variables only. Cause: " + e.getMessage()); - } - } -} +package gg.modl.backend; + +import io.github.cdimascio.dotenv.Dotenv; +import io.github.cdimascio.dotenv.DotenvException; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class BackendApplication { + public static void main(String[] args) { + loadDotenvIntoSystemProperties(); + + SpringApplication.run(BackendApplication.class, args); + } + + private static void loadDotenvIntoSystemProperties() { + try { + final Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + dotenv.entries(Dotenv.Filter.DECLARED_IN_ENV_FILE).forEach(entry -> + System.setProperty(entry.getKey(), entry.getValue()) + ); + } catch (DotenvException e) { + System.err.println("[BackendApplication] Could not read .env file (it may be a " + + "directory or unreadable, e.g. an empty bind-mount). Continuing with " + + "environment/-e variables only. Cause: " + e.getMessage()); + } + } +} diff --git a/src/main/java/gg/modl/backend/Constants.java b/src/main/java/gg/modl/backend/Constants.java index d986843..2173c32 100644 --- a/src/main/java/gg/modl/backend/Constants.java +++ b/src/main/java/gg/modl/backend/Constants.java @@ -1,20 +1,20 @@ -package gg.modl.backend; - -public final class Constants { - public static final String BRAND_NAME = "modl.gg"; - - private Constants() {} - - public static final class Domain { - public static final String API = "api.modl.gg"; - public static final String ADMIN = "admin.modl.gg"; - public static final String HTTPS_ADMIN = "https://" + ADMIN; - private Domain() {} - } - - public static final class Email { - public static final String ADMIN = "admin@modl.gg"; - - private Email() {} - } -} +package gg.modl.backend; + +public final class Constants { + public static final String BRAND_NAME = "modl.gg"; + + private Constants() {} + + public static final class Domain { + public static final String API = "api.modl.gg"; + public static final String ADMIN = "admin.modl.gg"; + public static final String HTTPS_ADMIN = "https://" + ADMIN; + private Domain() {} + } + + public static final class Email { + public static final String ADMIN = "admin@modl.gg"; + + private Email() {} + } +} diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsController.java b/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsController.java index 45d18ce..a4b227a 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsController.java @@ -1,80 +1,72 @@ -package gg.modl.backend.admin.controller; - -import gg.modl.backend.admin.service.AdminAnalyticsService; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.proto.modl.v1.AdminAnalyticsReportResponse; -import gg.modl.proto.modl.v1.ExportAnalyticsRequest; -import gg.modl.proto.modl.v1.GenerateReportRequest; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.ADMIN_ANALYTICS) -@RequiredArgsConstructor -public class AdminAnalyticsController { - private final AdminAnalyticsService adminAnalyticsService; - - @GetMapping("/dashboard") - public ResponseEntity getDashboard(@RequestParam(defaultValue = "30d") String range) { - return ResponseEntity.ok(AdminAnalyticsProtoMapper.toDashboardResponse(adminAnalyticsService.getDashboard(range))); - } - - @GetMapping("/activity") - public ResponseEntity getActivity(@RequestParam(defaultValue = "30d") String range) { - return ResponseEntity.ok(AdminAnalyticsProtoMapper.toActivityResponse(adminAnalyticsService.getActivity(range))); - } - - @GetMapping("/usage") - public ResponseEntity getUsage() { - return ResponseEntity.ok(AdminAnalyticsProtoMapper.toUsageResponse(adminAnalyticsService.getUsage())); - } - - @GetMapping("/historical") - public ResponseEntity getHistorical( - @RequestParam(required = false) String metric, - @RequestParam(defaultValue = "30d") String range) { - Map response = adminAnalyticsService.getHistorical(metric, range); - if (Boolean.FALSE.equals(response.get("success"))) { - throw new ValidationException(String.valueOf(response.get("error"))); - } - return ResponseEntity.ok(AdminAnalyticsProtoMapper.toHistoricalResponse(response)); - } - - @PostMapping("/export") - public ResponseEntity exportAnalytics(@RequestBody ExportAnalyticsRequest request) { - String type = request.hasType() ? request.getType() : "json"; - String range = request.hasRange() ? request.getRange() : "30d"; - - Object result = adminAnalyticsService.exportAnalytics(type, range); - - if ("csv".equals(type)) { - return ResponseEntity.ok() - .header("Content-Type", "text/csv") - .header("Content-Disposition", "attachment; filename=\"modl-analytics-" + range + ".csv\"") - .body(result); - } else if ("json".equals(type)) { - @SuppressWarnings("unchecked") - Map jsonResult = (Map) result; - return ResponseEntity.ok(AdminAnalyticsProtoMapper.toExportResponse(jsonResult)); - } - - throw new ValidationException("Invalid export type"); - } - - @PostMapping("/report") - public ResponseEntity generateReport(@RequestBody GenerateReportRequest request) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(AdminAnalyticsReportResponse.newBuilder() - .setSuccess(false) - .setError("Report generation not implemented") - .build()); - } -} +package gg.modl.backend.admin.controller; + +import gg.modl.backend.admin.service.AdminAnalyticsService; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.proto.modl.v1.AdminAnalyticsReportResponse; +import gg.modl.proto.modl.v1.ExportAnalyticsRequest; +import gg.modl.proto.modl.v1.GenerateReportRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.ADMIN_ANALYTICS) +@RequiredArgsConstructor +public class AdminAnalyticsController { + private final AdminAnalyticsService adminAnalyticsService; + + @GetMapping("/dashboard") + public ResponseEntity getDashboard(@RequestParam(defaultValue = "30d") String range) { + return ResponseEntity.ok(AdminAnalyticsProtoMapper.toDashboardResponse(adminAnalyticsService.getDashboard(range))); + } + + @GetMapping("/activity") + public ResponseEntity getActivity(@RequestParam(defaultValue = "30d") String range) { + return ResponseEntity.ok(AdminAnalyticsProtoMapper.toActivityResponse(adminAnalyticsService.getActivity(range))); + } + + @GetMapping("/usage") + public ResponseEntity getUsage() { + return ResponseEntity.ok(AdminAnalyticsProtoMapper.toUsageResponse(adminAnalyticsService.getUsage())); + } + + @GetMapping("/historical") + public ResponseEntity getHistorical( + @RequestParam(required = false) String metric, + @RequestParam(defaultValue = "30d") String range) { + return ResponseEntity.ok(AdminAnalyticsProtoMapper.toHistoricalResponse(adminAnalyticsService.getHistorical(metric, range))); + } + + @PostMapping("/export") + public ResponseEntity exportAnalytics(@RequestBody ExportAnalyticsRequest request) { + String type = request.hasType() ? request.getType() : "json"; + String range = request.hasRange() ? request.getRange() : "30d"; + + if ("csv".equals(type)) { + return ResponseEntity.ok() + .header("Content-Type", "text/csv") + .header("Content-Disposition", "attachment; filename=\"modl-analytics-" + range + ".csv\"") + .body(adminAnalyticsService.exportCsv(range)); + } + if ("json".equals(type)) { + return ResponseEntity.ok(AdminAnalyticsProtoMapper.toExportResponse(adminAnalyticsService.exportJson(range))); + } + + throw new ValidationException("Invalid export type"); + } + + @PostMapping("/report") + public ResponseEntity generateReport(@RequestBody GenerateReportRequest request) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(AdminAnalyticsReportResponse.newBuilder() + .setSuccess(false) + .setError("Report generation not implemented") + .build()); + } +} diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsProtoMapper.java b/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsProtoMapper.java index 6ea2ad8..d0f1252 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsProtoMapper.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsProtoMapper.java @@ -1,6 +1,12 @@ package gg.modl.backend.admin.controller; -import gg.modl.backend.server.data.Server; +import gg.modl.backend.admin.dto.response.AdminAnalyticsActivity; +import gg.modl.backend.admin.dto.response.AdminAnalyticsDashboard; +import gg.modl.backend.admin.dto.response.AdminAnalyticsExport; +import gg.modl.backend.admin.dto.response.AdminAnalyticsHistorical; +import gg.modl.backend.admin.dto.response.AdminAnalyticsUsage; +import gg.modl.backend.admin.dto.response.AdminNameCount; +import gg.modl.backend.admin.dto.response.AdminRegistrationPoint; import gg.modl.proto.modl.v1.AdminAnalyticsActivityPoint; import gg.modl.proto.modl.v1.AdminAnalyticsActivityResponse; import gg.modl.proto.modl.v1.AdminAnalyticsDashboardData; @@ -15,6 +21,7 @@ import gg.modl.proto.modl.v1.AdminAnalyticsNameValue; import gg.modl.proto.modl.v1.AdminAnalyticsOverview; import gg.modl.proto.modl.v1.AdminAnalyticsPlayerActivity; +import gg.modl.proto.modl.v1.AdminAnalyticsResourceUtilization; import gg.modl.proto.modl.v1.AdminAnalyticsServerActivity; import gg.modl.proto.modl.v1.AdminAnalyticsServerMetrics; import gg.modl.proto.modl.v1.AdminAnalyticsSystemHealth; @@ -22,17 +29,7 @@ import gg.modl.proto.modl.v1.AdminAnalyticsUsageResponse; import gg.modl.proto.modl.v1.AdminAnalyticsUsageStatistics; import gg.modl.proto.modl.v1.AdminAnalyticsUserEngagement; -import gg.modl.proto.modl.v1.AdminAnalyticsResourceUtilization; -import java.util.List; -import java.util.Map; - -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.doubleValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.intValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.list; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.listOfMaps; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.longValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.map; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; final class AdminAnalyticsProtoMapper { @@ -40,50 +37,42 @@ final class AdminAnalyticsProtoMapper { private AdminAnalyticsProtoMapper() { } - static AdminAnalyticsDashboardResponse toDashboardResponse(Map response) { - Map data = map(response.get("data")); - Map overview = map(data.get("overview")); - Map serverMetrics = map(data.get("serverMetrics")); - Map usageStatistics = map(data.get("usageStatistics")); - - AdminAnalyticsDashboardData.Builder builder = AdminAnalyticsDashboardData.newBuilder() - .setOverview(toOverview(overview)) - .setServerMetrics(toServerMetrics(serverMetrics)) - .setUsageStatistics(toUsageStatistics(usageStatistics)) - .setSystemHealth(AdminAnalyticsSystemHealth.newBuilder().build()); + static AdminAnalyticsDashboardResponse toDashboardResponse(AdminAnalyticsDashboard data) { + AdminAnalyticsDashboardData dashboard = AdminAnalyticsDashboardData.newBuilder() + .setOverview(toOverview(data.overview())) + .setServerMetrics(toServerMetrics(data.serverMetrics())) + .setUsageStatistics(toUsageStatistics(data.usageStatistics())) + .setSystemHealth(AdminAnalyticsSystemHealth.newBuilder().build()) + .build(); return AdminAnalyticsDashboardResponse.newBuilder() .setSuccess(true) - .setData(builder.build()) + .setData(dashboard) .build(); } - static AdminAnalyticsActivityResponse toActivityResponse(Map response) { + static AdminAnalyticsActivityResponse toActivityResponse(AdminAnalyticsActivity response) { AdminAnalyticsActivityResponse.Builder builder = AdminAnalyticsActivityResponse.newBuilder() .setSuccess(true) - .setTotalPlayers(longValue(response.get("totalPlayers"))) - .setTotalServers(longValue(response.get("totalServers"))); - listOfMaps(response.get("data")).forEach(point -> builder.addData(AdminAnalyticsActivityPoint.newBuilder() - .setDate(stringValue(point.get("date"))) - .setActiveServers(longValue(point.get("activeServers"))) - .setOnlinePlayers(intValue(point.get("onlinePlayers"))) + .setTotalPlayers(response.totalPlayers()) + .setTotalServers(response.totalServers()); + response.data().forEach(point -> builder.addData(AdminAnalyticsActivityPoint.newBuilder() + .setDate(stringValue(point.date())) + .setActiveServers(point.activeServers()) + .setOnlinePlayers(point.onlinePlayers()) .build())); return builder.build(); } - static AdminAnalyticsUsageResponse toUsageResponse(Map response) { - Map data = map(response.get("data")); - Map userEngagement = map(data.get("userEngagement")); - Map resourceUtilization = map(data.get("resourceUtilization")); - + static AdminAnalyticsUsageResponse toUsageResponse(AdminAnalyticsUsage response) { AdminAnalyticsUsageData usageData = AdminAnalyticsUsageData.newBuilder() .setUserEngagement(AdminAnalyticsUserEngagement.newBuilder() - .setMonthlyActiveServers(longValue(userEngagement.get("monthlyActiveServers"))) + .setMonthlyActiveServers(response.monthlyActiveServers()) .build()) .setResourceUtilization(AdminAnalyticsResourceUtilization.newBuilder() - .setStorage(longValue(resourceUtilization.get("storage"))) - .setStoragePercent(doubleValue(resourceUtilization.get("storagePercent"))) - .setApiCalls(longValue(resourceUtilization.get("apiCalls"))) - .setDatabaseQueries(longValue(resourceUtilization.get("databaseQueries"))) + .setStorage(response.storage()) + .setStoragePercent(response.storagePercent()) + .setApiCalls(response.apiCalls()) + .setDatabaseQueries(response.databaseQueries()) .build()) .build(); return AdminAnalyticsUsageResponse.newBuilder() @@ -92,136 +81,100 @@ static AdminAnalyticsUsageResponse toUsageResponse(Map response) .build(); } - static AdminAnalyticsHistoricalResponse toHistoricalResponse(Map response) { - Map data = map(response.get("data")); + static AdminAnalyticsHistoricalResponse toHistoricalResponse(AdminAnalyticsHistorical response) { AdminAnalyticsHistoricalData.Builder dataBuilder = AdminAnalyticsHistoricalData.newBuilder() - .setMetric(stringValue(data.get("metric"))) - .setRange(stringValue(data.get("range"))); - list(data.get("data")).forEach(entry -> { - Map point = map(entry); - dataBuilder.addData(AdminAnalyticsDateValue.newBuilder() - .setDate(stringValue(readField(entry, point, "date"))) - .setValue(longValue(readField(entry, point, "value"))) - .build()); - }); + .setMetric(stringValue(response.metric())) + .setRange(stringValue(response.range())); + response.data().forEach(point -> dataBuilder.addData(AdminAnalyticsDateValue.newBuilder() + .setDate(stringValue(point.date())) + .setValue(point.value()) + .build())); return AdminAnalyticsHistoricalResponse.newBuilder() .setSuccess(true) .setData(dataBuilder.build()) .build(); } - static AdminAnalyticsExportResponse toExportResponse(Map response) { - Map data = map(response.get("data")); + static AdminAnalyticsExportResponse toExportResponse(AdminAnalyticsExport response) { return AdminAnalyticsExportResponse.newBuilder() - .setExportDate(stringValue(response.get("exportDate"))) - .setRange(stringValue(response.get("range"))) + .setExportDate(stringValue(response.exportDate())) + .setRange(stringValue(response.range())) .setData(AdminAnalyticsExportData.newBuilder() - .setServers(longValue(data.get("servers"))) - .setUsers(longValue(data.get("users"))) - .setTickets(longValue(data.get("tickets"))) + .setServers(response.servers()) + .setUsers(response.users()) + .setTickets(response.tickets()) .build()) .build(); } - private static AdminAnalyticsOverview toOverview(Map overview) { + private static AdminAnalyticsOverview toOverview(AdminAnalyticsDashboard.Overview overview) { return AdminAnalyticsOverview.newBuilder() - .setTotalServers(longValue(overview.get("totalServers"))) - .setActiveServers(longValue(overview.get("activeServers"))) - .setTotalUsers(longValue(overview.get("totalUsers"))) - .setTotalTickets(longValue(overview.get("totalTickets"))) - .setServerGrowthRate(stringValue(overview.get("serverGrowthRate"))) - .setUserGrowthRate(stringValue(overview.get("userGrowthRate"))) - .setAvgPlayersPerServer(stringValue(overview.get("avgPlayersPerServer"))) - .setAvgTicketsPerServer(stringValue(overview.get("avgTicketsPerServer"))) + .setTotalServers(overview.totalServers()) + .setActiveServers(overview.activeServers()) + .setTotalUsers(overview.totalUsers()) + .setTotalTickets(overview.totalTickets()) + .setServerGrowthRate(overview.serverGrowthRate()) + .setUserGrowthRate(overview.userGrowthRate()) + .setAvgPlayersPerServer(overview.avgPlayersPerServer()) + .setAvgTicketsPerServer(overview.avgTicketsPerServer()) .build(); } - private static AdminAnalyticsServerMetrics toServerMetrics(Map serverMetrics) { + private static AdminAnalyticsServerMetrics toServerMetrics(AdminAnalyticsDashboard.ServerMetrics serverMetrics) { AdminAnalyticsServerMetrics.Builder builder = AdminAnalyticsServerMetrics.newBuilder(); - toNameValues(serverMetrics.get("byPlan")).forEach(builder::addByPlan); - toNameValues(serverMetrics.get("byStatus")).forEach(builder::addByStatus); - list(serverMetrics.get("registrationTrend")).forEach(entry -> - builder.addRegistrationTrend(toDateServers(entry))); + serverMetrics.byPlan().forEach(count -> builder.addByPlan(toNameValue(count))); + serverMetrics.byStatus().forEach(count -> builder.addByStatus(toNameValue(count))); + serverMetrics.registrationTrend().forEach(point -> builder.addRegistrationTrend(toDateServers(point))); return builder.build(); } - private static AdminAnalyticsUsageStatistics toUsageStatistics(Map usageStatistics) { + private static AdminAnalyticsUsageStatistics toUsageStatistics(AdminAnalyticsDashboard.UsageStatistics usageStatistics) { AdminAnalyticsUsageStatistics.Builder builder = AdminAnalyticsUsageStatistics.newBuilder() - .setTotalPlayerCount(intValue(usageStatistics.get("totalPlayerCount"))); - list(usageStatistics.get("topServersByUsers")).stream() - .filter(Server.class::isInstance) - .map(Server.class::cast) - .forEach(server -> builder.addTopServersByUsers(AdminServerProtoMapper.toRecord(server))); - listOfMaps(usageStatistics.get("serverActivity")).forEach(activity -> + .setTotalPlayerCount(usageStatistics.totalPlayerCount()); + usageStatistics.topServersByUsers().forEach(server -> + builder.addTopServersByUsers(AdminServerProtoMapper.toRecord(server))); + usageStatistics.serverActivity().forEach(activity -> builder.addServerActivity(AdminAnalyticsServerActivity.newBuilder() - .setDate(stringValue(activity.get("date"))) - .setActiveServers(longValue(activity.get("activeServers"))) + .setDate(stringValue(activity.date())) + .setActiveServers(activity.activeServers()) .build())); - listOfMaps(usageStatistics.get("liveServers")).forEach(live -> - builder.addLiveServers(toLiveServer(live))); - listOfMaps(usageStatistics.get("playerActivity")).forEach(activity -> + usageStatistics.liveServers().forEach(live -> builder.addLiveServers(toLiveServer(live))); + usageStatistics.playerActivity().forEach(activity -> builder.addPlayerActivity(AdminAnalyticsPlayerActivity.newBuilder() - .setDate(stringValue(activity.get("date"))) - .setPlayers(intValue(activity.get("players"))) + .setDate(stringValue(activity.date())) + .setPlayers(activity.players()) .build())); return builder.build(); } - private static AdminAnalyticsLiveServer toLiveServer(Map live) { + private static AdminAnalyticsLiveServer toLiveServer(AdminAnalyticsDashboard.LiveServer live) { AdminAnalyticsLiveServer.Builder builder = AdminAnalyticsLiveServer.newBuilder() - .setServerId(stringValue(live.get("serverId"))) - .setServerName(stringValue(live.get("serverName"))) - .setPlayerCount(intValue(live.get("playerCount"))); - if (live.get("platform") != null) { - builder.setPlatform(stringValue(live.get("platform"))); + .setServerId(stringValue(live.serverId())) + .setServerName(stringValue(live.serverName())) + .setPlayerCount(live.playerCount()); + if (live.platform() != null) { + builder.setPlatform(live.platform()); } - if (live.get("version") != null) { - builder.setVersion(stringValue(live.get("version"))); + if (live.version() != null) { + builder.setVersion(live.version()); } - if (live.get("pluginVersion") != null) { - builder.setPluginVersion(stringValue(live.get("pluginVersion"))); + if (live.pluginVersion() != null) { + builder.setPluginVersion(live.pluginVersion()); } return builder.build(); } - private static List toNameValues(Object source) { - return list(source).stream() - .map(AdminAnalyticsProtoMapper::toNameValue) - .toList(); - } - - private static AdminAnalyticsNameValue toNameValue(Object source) { - if (source instanceof gg.modl.backend.database.mongo.repository.ServerMongoRepository.NameValueResult result) { - return AdminAnalyticsNameValue.newBuilder() - .setName(stringValue(result.name())) - .setValue(result.value()) - .build(); - } - Map map = map(source); + private static AdminAnalyticsNameValue toNameValue(AdminNameCount count) { return AdminAnalyticsNameValue.newBuilder() - .setName(stringValue(map.get("name"))) - .setValue(intValue(map.get("value"))) + .setName(stringValue(count.name())) + .setValue(count.value()) .build(); } - private static AdminAnalyticsDateServers toDateServers(Object source) { - if (source instanceof gg.modl.backend.database.mongo.repository.ServerMongoRepository.DateServersResult result) { - return AdminAnalyticsDateServers.newBuilder() - .setDate(stringValue(result.date())) - .setServers(result.servers()) - .build(); - } - Map map = map(source); + private static AdminAnalyticsDateServers toDateServers(AdminRegistrationPoint point) { return AdminAnalyticsDateServers.newBuilder() - .setDate(stringValue(map.get("date"))) - .setServers(intValue(map.get("servers"))) + .setDate(stringValue(point.date())) + .setServers(point.servers()) .build(); } - - private static Object readField(Object source, Map map, String field) { - if (source instanceof gg.modl.backend.database.mongo.repository.ServerMongoRepository.DateValueResult result) { - return field.equals("date") ? result.date() : result.value(); - } - return map.get(field); - } } diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminAuthController.java b/src/main/java/gg/modl/backend/admin/controller/AdminAuthController.java index b6b5326..630a3ff 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminAuthController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminAuthController.java @@ -1,111 +1,110 @@ -package gg.modl.backend.admin.controller; - -import gg.modl.backend.admin.data.AdminUser; -import gg.modl.backend.admin.service.AdminAuthService; -import gg.modl.backend.auth.AuthService; -import gg.modl.backend.auth.session.AuthSessionData; -import gg.modl.backend.auth.session.SessionService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RESTSecurityRole; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.infrastructure.util.CookieUtil; -import gg.modl.proto.modl.v1.AdminLoginRequest; -import gg.modl.proto.modl.v1.AdminRequestCodeRequest; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.ADMIN_AUTH) -@RequiredArgsConstructor -public class AdminAuthController { - private final AdminAuthService adminAuthService; - private final AuthService authService; - private final SessionService sessionService; - private final CookieUtil cookieUtil; - private static final long SESSION_MAX_AGE = 24 * 60 * 60; // 24 hours - - @PostMapping("/request-code") - public ResponseEntity requestCode(@RequestBody AdminRequestCodeRequest request) throws Exception { - - Optional adminOpt = adminAuthService.findByEmail(request.getEmail()); - if (adminOpt.isPresent()) { - authService.sendAdminLoginCode(request.getEmail()); - } - - return ResponseEntity.ok(AdminAuthProtoMapper.toAuthResponse(true, "If this email is registered, a verification code has been sent")); - } - - @PostMapping("/login") - public ResponseEntity login( - HttpServletRequest request, - HttpServletResponse response, - @RequestBody AdminLoginRequest loginRequest) { - - // Always verify code regardless of user existence to prevent timing-based enumeration - boolean codeValid = authService.verifyAdminCode(loginRequest.getEmail(), loginRequest.getCode()); - Optional adminOpt = adminAuthService.findByEmail(loginRequest.getEmail()); - - if (adminOpt.isEmpty() || !codeValid) { - return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Invalid credentials")); - } - - AdminUser admin = adminOpt.get(); - String clientIp = RequestUtil.getClientIp(request); - adminAuthService.updateLastActivity(admin.getEmail(), clientIp); - - AuthSessionData session = sessionService.createAdminSession(admin.getEmail()); - - response.addCookie(cookieUtil.createSessionCookie(RESTSecurityRole.ADMIN_SESSION_COOKIE, session.getId(), SESSION_MAX_AGE)); - - return ResponseEntity.ok(AdminAuthProtoMapper.toLoginResponse(true, "Login successful", admin)); - } - - @PostMapping("/logout") - public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { - for (String sessionId : adminAuthService.extractSessionIds(request)) { - sessionService.invalidateAdminSession(sessionId); - } - - for (Cookie expiredCookie : cookieUtil.createExpiredSessionCookies(RESTSecurityRole.ADMIN_SESSION_COOKIE)) { - response.addCookie(expiredCookie); - } - - return ResponseEntity.ok(AdminAuthProtoMapper.toAuthResponse(true, "Logout successful")); - } - - @GetMapping("/session") - public ResponseEntity getSession(HttpServletRequest request) { - String sessionId = adminAuthService.extractSessionId(request); - if (sessionId == null) { - return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Optional sessionOpt = sessionService.findAndRefreshAdminSession(sessionId); - if (sessionOpt.isEmpty()) { - return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Session expired")); - } - AuthSessionData session = sessionOpt.get(); - if (adminAuthService.isAdminSessionExpired(session)) { - sessionService.invalidateAdminSession(sessionId); - return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Session expired")); - } - - Optional adminOpt = adminAuthService.findByEmail(session.getEmail()); - if (adminOpt.isEmpty()) { - sessionService.invalidateAdminSession(sessionId); - return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "User not found")); - } - - AdminUser admin = adminOpt.get(); - return ResponseEntity.ok(AdminAuthProtoMapper.toSessionResponse(true, admin)); - } -} +package gg.modl.backend.admin.controller; + +import gg.modl.backend.admin.data.AdminUser; +import gg.modl.backend.admin.service.AdminAuthService; +import gg.modl.backend.auth.AuthService; +import gg.modl.backend.auth.session.AuthSessionData; +import gg.modl.backend.auth.session.SessionService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RESTSecurityRole; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.infrastructure.util.CookieUtil; +import gg.modl.proto.modl.v1.AdminLoginRequest; +import gg.modl.proto.modl.v1.AdminRequestCodeRequest; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.ADMIN_AUTH) +@RequiredArgsConstructor +public class AdminAuthController { + private final AdminAuthService adminAuthService; + private final AuthService authService; + private final SessionService sessionService; + private final CookieUtil cookieUtil; + private static final long SESSION_MAX_AGE = 24 * 60 * 60; + + @PostMapping("/request-code") + public ResponseEntity requestCode(@RequestBody AdminRequestCodeRequest request) throws Exception { + + Optional adminOpt = adminAuthService.findByEmail(request.getEmail()); + if (adminOpt.isPresent()) { + authService.sendAdminLoginCode(request.getEmail()); + } + + return ResponseEntity.ok(AdminAuthProtoMapper.toAuthResponse(true, "If this email is registered, a verification code has been sent")); + } + + @PostMapping("/login") + public ResponseEntity login( + HttpServletRequest request, + HttpServletResponse response, + @RequestBody AdminLoginRequest loginRequest) { + + boolean codeValid = authService.verifyAdminCode(loginRequest.getEmail(), loginRequest.getCode()); + Optional adminOpt = adminAuthService.findByEmail(loginRequest.getEmail()); + + if (adminOpt.isEmpty() || !codeValid) { + return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Invalid credentials")); + } + + AdminUser admin = adminOpt.get(); + String clientIp = RequestUtil.getClientIp(request); + adminAuthService.updateLastActivity(admin.getEmail(), clientIp); + + AuthSessionData session = sessionService.createAdminSession(admin.getEmail()); + + response.addCookie(cookieUtil.createSessionCookie(RESTSecurityRole.ADMIN_SESSION_COOKIE, session.getId(), SESSION_MAX_AGE)); + + return ResponseEntity.ok(AdminAuthProtoMapper.toLoginResponse(true, "Login successful", admin)); + } + + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + for (String sessionId : adminAuthService.extractSessionIds(request)) { + sessionService.invalidateAdminSession(sessionId); + } + + for (Cookie expiredCookie : cookieUtil.createExpiredSessionCookies(RESTSecurityRole.ADMIN_SESSION_COOKIE)) { + response.addCookie(expiredCookie); + } + + return ResponseEntity.ok(AdminAuthProtoMapper.toAuthResponse(true, "Logout successful")); + } + + @GetMapping("/session") + public ResponseEntity getSession(HttpServletRequest request) { + String sessionId = adminAuthService.extractSessionId(request); + if (sessionId == null) { + return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Optional sessionOpt = sessionService.findAndRefreshAdminSession(sessionId); + if (sessionOpt.isEmpty()) { + return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Session expired")); + } + AuthSessionData session = sessionOpt.get(); + if (adminAuthService.isAdminSessionExpired(session)) { + sessionService.invalidateAdminSession(sessionId); + return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Session expired")); + } + + Optional adminOpt = adminAuthService.findByEmail(session.getEmail()); + if (adminOpt.isEmpty()) { + sessionService.invalidateAdminSession(sessionId); + return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "User not found")); + } + + AdminUser admin = adminOpt.get(); + return ResponseEntity.ok(AdminAuthProtoMapper.toSessionResponse(true, admin)); + } +} diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringController.java b/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringController.java index 3dae7ae..a3dba26 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringController.java @@ -1,139 +1,142 @@ -package gg.modl.backend.admin.controller; - -import gg.modl.backend.admin.data.SystemLog; -import gg.modl.backend.admin.service.AdminMonitoringService; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.Date; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.ADMIN_MONITORING) -@RequiredArgsConstructor -@Validated -public class AdminMonitoringController { - private final AdminMonitoringService adminMonitoringService; - - @GetMapping("/dashboard") - public ResponseEntity getDashboard() { - return ResponseEntity.ok(AdminMonitoringProtoMapper.toDashboardResponse(adminMonitoringService.getDashboard())); - } - - @GetMapping("/logs") - public ResponseEntity getLogs( - @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, - @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - @RequestParam(required = false) String level, - @RequestParam(required = false) String source, - @RequestParam(required = false) String serverId, - @RequestParam(required = false) String category, - @RequestParam(required = false) String resolved, - @RequestParam(required = false) String search, - @RequestParam(required = false) String startDate, - @RequestParam(required = false) String endDate, - @RequestParam(defaultValue = "timestamp") String sort, - @RequestParam(defaultValue = "desc") String order) { - return ResponseEntity.ok(AdminMonitoringProtoMapper.toLogsResponse(adminMonitoringService.getLogs( - page, - limit, - level, - source, - serverId, - category, - resolved, - search, - startDate, - endDate, - sort, - order - ))); - } - - @PostMapping("/logs") - public ResponseEntity createLog(@RequestBody gg.modl.proto.modl.v1.CreateSystemLogRequest request) { - SystemLog saved = adminMonitoringService.createLog(AdminMonitoringProtoMapper.fromCreateLog(request)); - return ResponseEntity.status(201).body( - AdminMonitoringProtoMapper.toSystemLogMutationResponse(saved, "Log entry created successfully")); - } - - @GetMapping("/sources") - public ResponseEntity getSources() { - return ResponseEntity.ok(AdminMonitoringProtoMapper.toSourcesResponse(adminMonitoringService.getSources())); - } - - @PutMapping("/logs/{id}/resolve") - public ResponseEntity resolveLog(@PathVariable String id, @RequestBody gg.modl.proto.modl.v1.ResolveLogRequest request) { - SystemLog updated = adminMonitoringService.resolveLog(id, AdminMonitoringProtoMapper.fromResolveLog(request)).orElse(null); - if (updated == null) { - throw new ResourceNotFoundException("Log entry not found"); - } - - return ResponseEntity.ok(AdminMonitoringProtoMapper.toSystemLogMutationResponse(updated, "Log entry marked as resolved")); - } - - @GetMapping("/health") - public ResponseEntity getHealth() { - return ResponseEntity.ok(AdminMonitoringProtoMapper.toHealthResponse(adminMonitoringService.getHealth())); - } - - @PostMapping("/logs/delete") - public ResponseEntity deleteLogs(@RequestBody gg.modl.proto.modl.v1.DeleteLogsRequest request) { - long deletedCount = adminMonitoringService.deleteLogs(request.getLogIdsList()); - return ResponseEntity.ok(AdminMonitoringProtoMapper.toDeleteLogsResponse( - deletedCount, "Successfully deleted " + deletedCount + " log(s)")); - } - - @GetMapping("/logs/export") - public ResponseEntity exportLogs( - @RequestParam(required = false) String level, - @RequestParam(required = false) String source, - @RequestParam(required = false) String category, - @RequestParam(required = false) String resolved, - @RequestParam(required = false) String search, - @RequestParam(required = false) String startDate, - @RequestParam(required = false) String endDate) { - String csv = adminMonitoringService.exportLogs(level, source, category, resolved, search, startDate, endDate); - - return ResponseEntity.ok() - .header("Content-Type", "text/csv") - .header("Content-Disposition", "attachment; filename=\"system-logs-" + new Date().toInstant().toString().split("T")[0] + ".csv\"") - .body(csv); - } - - @PostMapping("/logs/clear-all") - public ResponseEntity clearAllLogs() { - long deletedCount = adminMonitoringService.clearAllLogs(); - return ResponseEntity.ok(AdminMonitoringProtoMapper.toDeleteLogsResponse(deletedCount, "Successfully cleared all logs")); - } - - @GetMapping("/pm2-status") - public ResponseEntity getPm2Status() { - // PM2 integration placeholder - would require native process monitoring - return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2StatusResponse()); - } - - @PostMapping("/pm2/restart") - public ResponseEntity restartPm2() { - return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2RestartResponse("PM2 log streaming restarted")); - } - - @PostMapping("/pm2/toggle") - public ResponseEntity togglePm2(@RequestBody gg.modl.proto.modl.v1.TogglePm2Request request) { - boolean enabled = request.hasEnabledValue() ? request.getEnabledValue() : request.getEnabled(); - return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2ToggleResponse( - enabled, "PM2 log streaming " + (enabled ? "enabled" : "disabled"))); - } -} +package gg.modl.backend.admin.controller; + +import gg.modl.backend.admin.data.SystemLog; +import gg.modl.backend.admin.service.AdminMonitoringService; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.proto.modl.v1.CreateSystemLogRequest; +import gg.modl.proto.modl.v1.DeleteLogsRequest; +import gg.modl.proto.modl.v1.ResolveLogRequest; +import gg.modl.proto.modl.v1.TogglePm2Request; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.ADMIN_MONITORING) +@RequiredArgsConstructor +@Validated +public class AdminMonitoringController { + private final AdminMonitoringService adminMonitoringService; + + @GetMapping("/dashboard") + public ResponseEntity getDashboard() { + return ResponseEntity.ok(AdminMonitoringProtoMapper.toDashboardResponse(adminMonitoringService.getDashboard())); + } + + @GetMapping("/logs") + public ResponseEntity getLogs( + @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, + @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + @RequestParam(required = false) String level, + @RequestParam(required = false) String source, + @RequestParam(required = false) String serverId, + @RequestParam(required = false) String category, + @RequestParam(required = false) String resolved, + @RequestParam(required = false) String search, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(defaultValue = "timestamp") String sort, + @RequestParam(defaultValue = "desc") String order) { + return ResponseEntity.ok(AdminMonitoringProtoMapper.toLogsResponse(adminMonitoringService.getLogs( + page, + limit, + level, + source, + serverId, + category, + resolved, + search, + startDate, + endDate, + sort, + order + ))); + } + + @PostMapping("/logs") + public ResponseEntity createLog(@RequestBody CreateSystemLogRequest request) { + SystemLog saved = adminMonitoringService.createLog(AdminMonitoringProtoMapper.fromCreateLog(request)); + return ResponseEntity.status(201).body( + AdminMonitoringProtoMapper.toSystemLogMutationResponse(saved, "Log entry created successfully")); + } + + @GetMapping("/sources") + public ResponseEntity getSources() { + return ResponseEntity.ok(AdminMonitoringProtoMapper.toSourcesResponse(adminMonitoringService.getSources())); + } + + @PutMapping("/logs/{id}/resolve") + public ResponseEntity resolveLog(@PathVariable String id, @RequestBody ResolveLogRequest request) { + SystemLog updated = adminMonitoringService.resolveLog(id, AdminMonitoringProtoMapper.fromResolveLog(request)).orElse(null); + if (updated == null) { + throw new ResourceNotFoundException("Log entry not found"); + } + + return ResponseEntity.ok(AdminMonitoringProtoMapper.toSystemLogMutationResponse(updated, "Log entry marked as resolved")); + } + + @GetMapping("/health") + public ResponseEntity getHealth() { + return ResponseEntity.ok(AdminMonitoringProtoMapper.toHealthResponse(adminMonitoringService.getHealth())); + } + + @PostMapping("/logs/delete") + public ResponseEntity deleteLogs(@RequestBody DeleteLogsRequest request) { + long deletedCount = adminMonitoringService.deleteLogs(request.getLogIdsList()); + return ResponseEntity.ok(AdminMonitoringProtoMapper.toDeleteLogsResponse( + deletedCount, "Successfully deleted " + deletedCount + " log(s)")); + } + + @GetMapping("/logs/export") + public ResponseEntity exportLogs( + @RequestParam(required = false) String level, + @RequestParam(required = false) String source, + @RequestParam(required = false) String category, + @RequestParam(required = false) String resolved, + @RequestParam(required = false) String search, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate) { + String csv = adminMonitoringService.exportLogs(level, source, category, resolved, search, startDate, endDate); + + return ResponseEntity.ok() + .header("Content-Type", "text/csv") + .header("Content-Disposition", "attachment; filename=\"system-logs-" + new Date().toInstant().toString().split("T")[0] + ".csv\"") + .body(csv); + } + + @PostMapping("/logs/clear-all") + public ResponseEntity clearAllLogs() { + long deletedCount = adminMonitoringService.clearAllLogs(); + return ResponseEntity.ok(AdminMonitoringProtoMapper.toDeleteLogsResponse(deletedCount, "Successfully cleared all logs")); + } + + @GetMapping("/pm2-status") + public ResponseEntity getPm2Status() { + return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2StatusResponse()); + } + + @PostMapping("/pm2/restart") + public ResponseEntity restartPm2() { + return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2RestartResponse("PM2 log streaming restarted")); + } + + @PostMapping("/pm2/toggle") + public ResponseEntity togglePm2(@RequestBody TogglePm2Request request) { + boolean enabled = request.hasEnabledValue() ? request.getEnabledValue() : request.getEnabled(); + return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2ToggleResponse( + enabled, "PM2 log streaming " + (enabled ? "enabled" : "disabled"))); + } +} diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringProtoMapper.java b/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringProtoMapper.java index 2516eba..6a94440 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringProtoMapper.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringProtoMapper.java @@ -3,6 +3,11 @@ import gg.modl.backend.admin.data.SystemLog; import gg.modl.backend.admin.dto.request.CreateSystemLogRequest; import gg.modl.backend.admin.dto.request.ResolveLogRequest; +import gg.modl.backend.admin.dto.response.AdminMonitoringDashboard; +import gg.modl.backend.admin.dto.response.AdminMonitoringHealth; +import gg.modl.backend.admin.dto.response.AdminMonitoringLogs; +import gg.modl.backend.admin.dto.response.AdminMonitoringSources; +import gg.modl.backend.admin.dto.response.AdminPagination; import gg.modl.proto.modl.v1.AdminMonitoringDashboardData; import gg.modl.proto.modl.v1.AdminMonitoringDashboardResponse; import gg.modl.proto.modl.v1.AdminMonitoringDeleteLogsResponse; @@ -30,14 +35,10 @@ import gg.modl.proto.modl.v1.SystemLogResponse; import java.util.Map; -import java.util.function.Consumer; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.doubleValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.list; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.listOfMaps; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.longValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.map; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalString; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.structToMap; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toStruct; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toTimestamp; @@ -48,7 +49,7 @@ private AdminMonitoringProtoMapper() { static CreateSystemLogRequest fromCreateLog(gg.modl.proto.modl.v1.CreateSystemLogRequest request) { Map metadata = request.hasMetadata() - ? gg.modl.backend.infrastructure.proto.ProtoMapperSupport.structToMap(request.getMetadata()) + ? structToMap(request.getMetadata()) : null; return new CreateSystemLogRequest( request.getLevel(), @@ -64,44 +65,40 @@ static ResolveLogRequest fromResolveLog(gg.modl.proto.modl.v1.ResolveLogRequest return new ResolveLogRequest(request.hasResolvedBy() ? request.getResolvedBy() : null); } - static AdminMonitoringDashboardResponse toDashboardResponse(Map response) { - Map data = map(response.get("data")); - Map servers = map(data.get("servers")); - Map logs = map(data.get("logs")); - Map last24h = map(logs.get("last24h")); - Map unresolved = map(logs.get("unresolved")); - Map systemHealth = map(data.get("systemHealth")); + static AdminMonitoringDashboardResponse toDashboardResponse(AdminMonitoringDashboard response) { + AdminMonitoringDashboard.ServerMetrics servers = response.servers(); + AdminMonitoringDashboard.LogMetrics logs = response.logs(); + AdminMonitoringDashboard.SystemHealth systemHealth = response.systemHealth(); AdminMonitoringDashboardData.Builder builder = AdminMonitoringDashboardData.newBuilder() .setServers(AdminMonitoringServerMetrics.newBuilder() - .setTotal(longValue(servers.get("total"))) - .setActive(longValue(servers.get("active"))) - .setPending(longValue(servers.get("pending"))) - .setFailed(longValue(servers.get("failed"))) - .setRecentRegistrations(longValue(servers.get("recentRegistrations"))) - .setConcurrentServers(longValue(servers.get("concurrentServers"))) - .setConcurrentPlayers(longValue(servers.get("concurrentPlayers"))) + .setTotal(servers.total()) + .setActive(servers.active()) + .setPending(servers.pending()) + .setFailed(servers.failed()) + .setRecentRegistrations(servers.recentRegistrations()) + .setConcurrentServers(servers.concurrentServers()) + .setConcurrentPlayers(servers.concurrentPlayers()) .build()) .setLogs(AdminMonitoringLogMetrics.newBuilder() .setLast24H(AdminMonitoringLogWindow.newBuilder() - .setTotal(longValue(last24h.get("total"))) - .setCritical(longValue(last24h.get("critical"))) - .setError(longValue(last24h.get("error"))) - .setWarning(longValue(last24h.get("warning"))) + .setTotal(logs.last24h().total()) + .setCritical(logs.last24h().critical()) + .setError(logs.last24h().error()) + .setWarning(logs.last24h().warning()) .build()) .setUnresolved(AdminMonitoringUnresolvedLogs.newBuilder() - .setCritical(longValue(unresolved.get("critical"))) - .setError(longValue(unresolved.get("error"))) + .setCritical(logs.unresolved().critical()) + .setError(logs.unresolved().error()) .build()) .build()) .setSystemHealth(AdminMonitoringSystemHealthSummary.newBuilder() - .setScore(doubleValue(systemHealth.get("score"))) - .setStatus(stringValue(systemHealth.get("status"))) + .setScore(systemHealth.score()) + .setStatus(systemHealth.status()) .build()); - listOfMaps(data.get("trends")).forEach(trend -> builder.addTrends(toStruct(trend))); - Object lastUpdated = data.get("lastUpdated"); - if (lastUpdated != null) { - builder.setLastUpdated(toTimestamp(lastUpdated)); + response.trends().forEach(trend -> builder.addTrends(toStruct(trend))); + if (response.lastUpdated() != null) { + builder.setLastUpdated(toTimestamp(response.lastUpdated())); } return AdminMonitoringDashboardResponse.newBuilder() .setSuccess(true) @@ -109,23 +106,11 @@ static AdminMonitoringDashboardResponse toDashboardResponse(Map .build(); } - static AdminMonitoringLogsResponse toLogsResponse(Map response) { - Map data = map(response.get("data")); - Map pagination = map(data.get("pagination")); - Map filters = map(data.get("filters")); - + static AdminMonitoringLogsResponse toLogsResponse(AdminMonitoringLogs response) { AdminMonitoringLogsData.Builder dataBuilder = AdminMonitoringLogsData.newBuilder() - .setPagination(AdminMonitoringPagination.newBuilder() - .setPage((int) longValue(pagination.get("page"))) - .setLimit((int) longValue(pagination.get("limit"))) - .setTotal(longValue(pagination.get("total"))) - .setPages((int) longValue(pagination.get("pages"))) - .build()) - .setFilters(toLogFilters(filters)); - list(data.get("logs")).stream() - .filter(SystemLog.class::isInstance) - .map(SystemLog.class::cast) - .forEach(systemLog -> dataBuilder.addLogs(toSystemLogResponse(systemLog))); + .setPagination(toPagination(response.pagination())) + .setFilters(toLogFilters(response.filters())); + response.logs().forEach(systemLog -> dataBuilder.addLogs(toSystemLogResponse(systemLog))); return AdminMonitoringLogsResponse.newBuilder() .setSuccess(true) .setData(dataBuilder.build()) @@ -142,25 +127,23 @@ static AdminMonitoringSystemLogMutationResponse toSystemLogMutationResponse(Syst return builder.build(); } - static AdminMonitoringSourcesResponse toSourcesResponse(Map response) { - Map data = map(response.get("data")); - AdminMonitoringSourcesData.Builder dataBuilder = AdminMonitoringSourcesData.newBuilder(); - list(data.get("sources")).forEach(source -> dataBuilder.addSources(stringValue(source))); - list(data.get("categories")).forEach(category -> dataBuilder.addCategories(stringValue(category))); + static AdminMonitoringSourcesResponse toSourcesResponse(AdminMonitoringSources response) { + AdminMonitoringSourcesData dataBuilder = AdminMonitoringSourcesData.newBuilder() + .addAllSources(response.sources()) + .addAllCategories(response.categories()) + .build(); return AdminMonitoringSourcesResponse.newBuilder() .setSuccess(true) - .setData(dataBuilder.build()) + .setData(dataBuilder) .build(); } - static AdminMonitoringHealthResponse toHealthResponse(Map response) { - Map data = map(response.get("data")); + static AdminMonitoringHealthResponse toHealthResponse(AdminMonitoringHealth response) { AdminMonitoringHealthData.Builder dataBuilder = AdminMonitoringHealthData.newBuilder() - .setStatus(stringValue(data.get("status"))); - listOfMaps(data.get("checks")).forEach(check -> dataBuilder.addChecks(toHealthCheck(check))); - Object timestamp = data.get("timestamp"); - if (timestamp != null) { - dataBuilder.setTimestamp(toTimestamp(timestamp)); + .setStatus(stringValue(response.status())); + response.checks().forEach(check -> dataBuilder.addChecks(toHealthCheck(check))); + if (response.timestamp() != null) { + dataBuilder.setTimestamp(toTimestamp(response.timestamp())); } return AdminMonitoringHealthResponse.newBuilder() .setSuccess(true) @@ -212,38 +195,41 @@ static AdminMonitoringPm2ToggleResponse toPm2ToggleResponse(boolean enabled, Str return builder.build(); } - private static AdminMonitoringLogFilters toLogFilters(Map filters) { - AdminMonitoringLogFilters.Builder builder = AdminMonitoringLogFilters.newBuilder(); - setFilter(filters.get("level"), builder::setLevel); - setFilter(filters.get("source"), builder::setSource); - setFilter(filters.get("serverId"), builder::setServerId); - setFilter(filters.get("category"), builder::setCategory); - setFilter(filters.get("resolved"), builder::setResolved); - setFilter(filters.get("search"), builder::setSearch); - return builder.build(); + private static AdminMonitoringPagination toPagination(AdminPagination pagination) { + return AdminMonitoringPagination.newBuilder() + .setPage(pagination.page()) + .setLimit(pagination.limit()) + .setTotal(pagination.total()) + .setPages(pagination.pages()) + .build(); } - private static void setFilter(Object value, Consumer setter) { - if (value != null) { - setter.accept(stringValue(value)); - } + private static AdminMonitoringLogFilters toLogFilters(AdminMonitoringLogs.Filters filters) { + AdminMonitoringLogFilters.Builder builder = AdminMonitoringLogFilters.newBuilder(); + setOptionalString(builder::setLevel, filters.level()); + setOptionalString(builder::setSource, filters.source()); + setOptionalString(builder::setServerId, filters.serverId()); + setOptionalString(builder::setCategory, filters.category()); + setOptionalString(builder::setResolved, filters.resolved()); + setOptionalString(builder::setSearch, filters.search()); + return builder.build(); } - private static AdminMonitoringHealthCheck toHealthCheck(Map check) { + private static AdminMonitoringHealthCheck toHealthCheck(AdminMonitoringHealth.HealthCheck check) { AdminMonitoringHealthCheck.Builder builder = AdminMonitoringHealthCheck.newBuilder() - .setName(stringValue(check.get("name"))) - .setStatus(stringValue(check.get("status"))); - if (check.get("message") != null) { - builder.setMessage(stringValue(check.get("message"))); + .setName(stringValue(check.name())) + .setStatus(stringValue(check.status())); + if (check.message() != null) { + builder.setMessage(check.message()); } - if (check.get("responseTime") != null) { - builder.setResponseTime(longValue(check.get("responseTime"))); + if (check.responseTime() != null) { + builder.setResponseTime(check.responseTime()); } - if (check.get("error") != null) { - builder.setError(stringValue(check.get("error"))); + if (check.error() != null) { + builder.setError(check.error()); } - if (check.get("count") != null) { - builder.setCount(longValue(check.get("count"))); + if (check.count() != null) { + builder.setCount(check.count()); } return builder.build(); } diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminSecurityProtoMapper.java b/src/main/java/gg/modl/backend/admin/controller/AdminSecurityProtoMapper.java index bb25d69..d986514 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminSecurityProtoMapper.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminSecurityProtoMapper.java @@ -1,6 +1,9 @@ package gg.modl.backend.admin.controller; import gg.modl.backend.admin.data.SecurityEvent; +import gg.modl.backend.admin.dto.response.AdminPagination; +import gg.modl.backend.admin.dto.response.AdminSecurityEvents; +import gg.modl.backend.admin.dto.response.AdminSecuritySummary; import gg.modl.proto.modl.v1.AdminSecurityEvent; import gg.modl.proto.modl.v1.AdminSecurityEventsData; import gg.modl.proto.modl.v1.AdminSecurityEventsResponse; @@ -10,11 +13,6 @@ import gg.modl.proto.modl.v1.AdminSecuritySummaryData; import gg.modl.proto.modl.v1.AdminSecuritySummaryResponse; -import java.util.Map; - -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.list; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.longValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.map; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toTimestamp; @@ -23,44 +21,29 @@ final class AdminSecurityProtoMapper { private AdminSecurityProtoMapper() { } - static AdminSecurityEventsResponse toEventsResponse(Map response) { - Map data = map(response.get("data")); - Map pagination = map(data.get("pagination")); - + static AdminSecurityEventsResponse toEventsResponse(AdminSecurityEvents response) { AdminSecurityEventsData.Builder dataBuilder = AdminSecurityEventsData.newBuilder() - .setPagination(AdminSecurityPagination.newBuilder() - .setPage((int) longValue(pagination.get("page"))) - .setLimit((int) longValue(pagination.get("limit"))) - .setTotal(longValue(pagination.get("total"))) - .setPages((int) longValue(pagination.get("pages"))) - .build()); - list(data.get("events")).stream() - .filter(SecurityEvent.class::isInstance) - .map(SecurityEvent.class::cast) - .forEach(event -> dataBuilder.addEvents(toEvent(event))); + .setPagination(toPagination(response.pagination())); + response.events().forEach(event -> dataBuilder.addEvents(toEvent(event))); return AdminSecurityEventsResponse.newBuilder() .setSuccess(true) .setData(dataBuilder.build()) .build(); } - static AdminSecuritySummaryResponse toSummaryResponse(Map response) { - Map data = map(response.get("data")); - Map last24Hours = map(data.get("last24Hours")); - Map last7Days = map(data.get("last7Days")); - + static AdminSecuritySummaryResponse toSummaryResponse(AdminSecuritySummary response) { + AdminSecuritySummary.Last24Hours last24Hours = response.last24Hours(); AdminSecuritySummaryData.Builder dataBuilder = AdminSecuritySummaryData.newBuilder() .setLast24Hours(AdminSecurityLast24Hours.newBuilder() - .setCritical(longValue(last24Hours.get("critical"))) - .setHigh(longValue(last24Hours.get("high"))) - .setMedium(longValue(last24Hours.get("medium"))) + .setCritical(last24Hours.critical()) + .setHigh(last24Hours.high()) + .setMedium(last24Hours.medium()) .build()) .setLast7Days(AdminSecurityLast7Days.newBuilder() - .setTotal(longValue(last7Days.get("total"))) + .setTotal(response.last7DaysTotal()) .build()); - Object timestamp = data.get("timestamp"); - if (timestamp != null) { - dataBuilder.setTimestamp(toTimestamp(timestamp)); + if (response.timestamp() != null) { + dataBuilder.setTimestamp(toTimestamp(response.timestamp())); } return AdminSecuritySummaryResponse.newBuilder() .setSuccess(true) @@ -68,6 +51,15 @@ static AdminSecuritySummaryResponse toSummaryResponse(Map respon .build(); } + private static AdminSecurityPagination toPagination(AdminPagination pagination) { + return AdminSecurityPagination.newBuilder() + .setPage(pagination.page()) + .setLimit(pagination.limit()) + .setTotal(pagination.total()) + .setPages(pagination.pages()) + .build(); + } + private static AdminSecurityEvent toEvent(SecurityEvent event) { AdminSecurityEvent.Builder builder = AdminSecurityEvent.newBuilder() .setId(stringValue(event.getId())) diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminServerController.java b/src/main/java/gg/modl/backend/admin/controller/AdminServerController.java index 7cb0898..c9fadf9 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminServerController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminServerController.java @@ -1,256 +1,257 @@ -package gg.modl.backend.admin.controller; - -import com.google.protobuf.Timestamp; -import gg.modl.backend.admin.service.AdminServerService; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.util.PaginationHelper; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import gg.modl.proto.modl.v1.AdminServerBulkOperationRequest; -import gg.modl.proto.modl.v1.AdminServerCreateRequest; -import gg.modl.proto.modl.v1.AdminServerExportRequest; -import gg.modl.proto.modl.v1.AdminServerSearchRequest; -import gg.modl.proto.modl.v1.AdminServerUpdateStatsRequest; -import gg.modl.proto.modl.v1.UpdateServerRequest; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.validation.annotation.Validated; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.ADMIN_SERVERS) -@RequiredArgsConstructor -@Slf4j -@Validated -public class AdminServerController { - private final AdminServerService serverService; - - @GetMapping - public ResponseEntity getServers( - @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, - @RequestParam(defaultValue = "20") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - @RequestParam(required = false) String search, - @RequestParam(required = false) String plan, - @RequestParam(required = false) String status, - @RequestParam(defaultValue = "createdAt") String sort, - @RequestParam(defaultValue = "desc") String order) { - - int pageNum = PaginationHelper.normalizePage(page); - int limitNum = PaginationHelper.normalizeLimit(limit, 50); - int skip = PaginationHelper.calculateSkip(page, limitNum); - - List servers = serverService.findServers(search, plan, status, sort, order, skip, limitNum); - long total = serverService.countServers(search, plan, status); - - return ResponseEntity.ok(AdminServerProtoMapper.toListResponse( - servers, pageNum, limitNum, total, PaginationHelper.calculateTotalPages(total, limitNum))); - } - - @PostMapping("/usage/batch") - public ResponseEntity getUsageBatch(@RequestBody gg.modl.proto.modl.v1.AdminServerUsageBatchRequest request) { - List serverIds = request.getServerIdsList(); - if (serverIds.isEmpty()) { - throw new ValidationException("Missing required field: serverIds"); - } - - if (serverIds.size() > 50) { - throw new ValidationException("Maximum 50 server IDs per request"); - } - - boolean forceRefresh = request.getForceRefresh(); - Map usage = serverService.getUsageStatsForServerIds(serverIds, forceRefresh); - - return ResponseEntity.ok(AdminServerProtoMapper.toUsageBatchResponse(usage)); - } - - @GetMapping("/{id}") - public ResponseEntity getServer(@PathVariable String id) { - return serverService.findById(id) - .map(server -> ResponseEntity.ok((Object) AdminServerProtoMapper.toDetailResponse(server))) - .orElseThrow(() -> new ResourceNotFoundException("Server not found")); - } - - @GetMapping("/{id}/stats") - public ResponseEntity getServerStats(@PathVariable String id) { - return serverService.findById(id) - .map(server -> { - Map stats = serverService.getServerStats(server); - return ResponseEntity.ok((Object) AdminServerProtoMapper.toStatsResponse(stats)); - }) - .orElseThrow(() -> new ResourceNotFoundException("Server not found")); - } - - @PostMapping - public ResponseEntity createServer(@RequestBody AdminServerCreateRequest request) { - Server saved = serverService.createServer( - request.getServerName(), - request.getCustomDomain(), - request.getAdminEmail(), - request.hasPlan() ? request.getPlan() : null); - return ResponseEntity.status(201).body( - AdminServerProtoMapper.toMutationResponse(saved, "Server created successfully")); - } - - @PutMapping("/{id}") - public ResponseEntity updateServer(@PathVariable String id, @RequestBody UpdateServerRequest request) { - Server server = serverService.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Server not found")); - - if (request.hasAdminEmail()) { - serverService.changeAdminEmail(server, request.getAdminEmail()); - } - - Map updateData = new HashMap<>(); - if (request.hasEmailVerified()) { - updateData.put("emailVerified", request.getEmailVerified()); - } - if (request.hasProvisioningStatus()) { - updateData.put("provisioningStatus", request.getProvisioningStatus()); - } - if (request.hasProvisioningNotes()) { - updateData.put("provisioningNotes", request.getProvisioningNotes()); - } - if (request.hasPlan()) { - updateData.put("plan", request.getPlan()); - } - if (request.hasSubscriptionStatus()) { - updateData.put("subscriptionStatus", request.getSubscriptionStatus()); - } - if (request.hasLastActivityAt()) { - updateData.put("lastActivityAt", request.getLastActivityAt()); - } - updateData.put("updatedAt", new Date()); - - Server updated = serverService.updateById(id, updateData); - if (updated != null) { - return ResponseEntity.ok(AdminServerProtoMapper.toMutationResponse(updated, "Server updated successfully")); - } - throw new ValidationException("Failed to update server"); - } - - @PutMapping("/{id}/stats") - public ResponseEntity updateServerStats(@PathVariable String id, @RequestBody AdminServerUpdateStatsRequest request) { - if (serverService.findById(id).isEmpty()) { - throw new ResourceNotFoundException("Server not found"); - } - - Map updateData = new HashMap<>(); - if (request.hasLastActivityAt()) { - updateData.put("lastActivityAt", toDate(request.getLastActivityAt())); - } - updateData.put("updatedAt", new Date()); - - Server updated = serverService.updateById(id, updateData); - if (updated != null) { - return ResponseEntity.ok(AdminServerProtoMapper.toMutationResponse(updated, "Server activity updated successfully")); - } - throw new ValidationException("Failed to update server"); - } - - @DeleteMapping("/{id}") - public ResponseEntity deleteServer(@PathVariable String id) { - if (serverService.deleteById(id)) { - return ResponseEntity.ok(AdminServerProtoMapper.toMutationResponse(null, "Server deleted successfully")); - } - throw new ResourceNotFoundException("Server not found"); - } - - @PostMapping("/bulk") - public ResponseEntity bulkOperation(@RequestBody AdminServerBulkOperationRequest request) { - List serverIds = request.getServerIdsList(); - if (request.getAction().isEmpty() || serverIds.isEmpty()) { - throw new ValidationException("Missing required fields: action, serverIds"); - } - - String action = request.getAction(); - long affectedCount = switch (action) { - case "delete" -> serverService.bulkDelete(serverIds); - case "suspend" -> serverService.bulkSuspend(serverIds); - case "activate" -> serverService.bulkActivate(serverIds); - case "update-plan" -> { - if (!request.hasParameters() || !request.getParameters().hasPlan()) { - yield -1L; - } - yield serverService.bulkUpdatePlan(serverIds, request.getParameters().getPlan()); - } - default -> -1L; - }; - - if (affectedCount < 0) { - throw new ValidationException("Invalid action or missing parameters"); - } - - return ResponseEntity.ok(AdminServerProtoMapper.toBulkOperationResponse( - action, affectedCount, serverIds, "Bulk operation '" + action + "' completed successfully")); - } - - @PostMapping("/{id}/reset-database") - public ResponseEntity resetDatabase(@PathVariable String id) { - return serverService.findById(id) - .map(server -> { - serverService.resetServerDatabase(server); - log.info("Server {} reset to provisioning state by admin", server.getServerName()); - return ResponseEntity.ok((Object) AdminServerProtoMapper.toMutationResponse(null, - "Server reset to provisioning state. The provisioning system will reinitialize the database.")); - }) - .orElseThrow(() -> new ResourceNotFoundException("Server not found")); - } - - @PostMapping("/{id}/export-data") - public ResponseEntity exportData(@PathVariable String id) { - return serverService.findById(id) - .map(server -> ResponseEntity.ok((Object) AdminServerProtoMapper.toMutationResponse(null, - "Data export initiated. You will receive an email with the download link."))) - .orElseThrow(() -> new ResourceNotFoundException("Server not found")); - } - - @PostMapping("/search") - public ResponseEntity searchServers(@RequestBody AdminServerSearchRequest request) { - String query = request.hasQuery() ? request.getQuery() : ""; - String plan = request.hasFilters() && request.getFilters().hasPlan() ? request.getFilters().getPlan() : null; - String status = request.hasFilters() && request.getFilters().hasStatus() ? request.getFilters().getStatus() : null; - - List servers = serverService.findServers(query, plan, status, "createdAt", "desc", 0, 50); - long total = serverService.countServers(query, plan, status); - - return ResponseEntity.ok(AdminServerProtoMapper.toSearchResponse(servers, total)); - } - - @PostMapping("/export") - public ResponseEntity exportServers(@RequestBody AdminServerExportRequest request) { - String format = request.hasFormat() ? request.getFormat() : "json"; - String plan = request.hasFilters() && request.getFilters().hasPlan() ? request.getFilters().getPlan() : null; - String status = request.hasFilters() && request.getFilters().hasStatus() ? request.getFilters().getStatus() : null; - - if ("csv".equalsIgnoreCase(format)) { - return ResponseEntity.ok() - .header("Content-Type", "text/csv") - .header("Content-Disposition", "attachment; filename=servers-export.csv") - .body(serverService.exportServersCsv(plan, status)); - } - - List servers = serverService.findServers(null, plan, status, "createdAt", "desc", 0, 10000); - return ResponseEntity.ok(AdminServerProtoMapper.toExportResponse(servers, new Date(), format, servers.size())); - } - - private static Date toDate(Timestamp timestamp) { - return new Date(timestamp.getSeconds() * 1000L + timestamp.getNanos() / 1_000_000L); - } -} +package gg.modl.backend.admin.controller; + +import com.google.protobuf.Timestamp; +import gg.modl.backend.admin.service.AdminServerService; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.util.PaginationHelper; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.proto.modl.v1.AdminServerBulkOperationRequest; +import gg.modl.proto.modl.v1.AdminServerCreateRequest; +import gg.modl.proto.modl.v1.AdminServerExportRequest; +import gg.modl.proto.modl.v1.AdminServerSearchRequest; +import gg.modl.proto.modl.v1.AdminServerUpdateStatsRequest; +import gg.modl.proto.modl.v1.AdminServerUsageBatchRequest; +import gg.modl.proto.modl.v1.UpdateServerRequest; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.ADMIN_SERVERS) +@RequiredArgsConstructor +@Slf4j +@Validated +public class AdminServerController { + private final AdminServerService serverService; + + @GetMapping + public ResponseEntity getServers( + @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, + @RequestParam(defaultValue = "20") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + @RequestParam(required = false) String search, + @RequestParam(required = false) String plan, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "createdAt") String sort, + @RequestParam(defaultValue = "desc") String order) { + + int pageNum = PaginationHelper.normalizePage(page); + int limitNum = PaginationHelper.normalizeLimit(limit, 50); + int skip = PaginationHelper.calculateSkip(page, limitNum); + + List servers = serverService.findServers(search, plan, status, sort, order, skip, limitNum); + long total = serverService.countServers(search, plan, status); + + return ResponseEntity.ok(AdminServerProtoMapper.toListResponse( + servers, pageNum, limitNum, total, PaginationHelper.calculateTotalPages(total, limitNum))); + } + + @PostMapping("/usage/batch") + public ResponseEntity getUsageBatch(@RequestBody AdminServerUsageBatchRequest request) { + List serverIds = request.getServerIdsList(); + if (serverIds.isEmpty()) { + throw new ValidationException("Missing required field: serverIds"); + } + + if (serverIds.size() > 50) { + throw new ValidationException("Maximum 50 server IDs per request"); + } + + boolean forceRefresh = request.getForceRefresh(); + Map usage = serverService.getUsageStatsForServerIds(serverIds, forceRefresh); + + return ResponseEntity.ok(AdminServerProtoMapper.toUsageBatchResponse(usage)); + } + + @GetMapping("/{id}") + public ResponseEntity getServer(@PathVariable String id) { + return serverService.findById(id) + .map(server -> ResponseEntity.ok((Object) AdminServerProtoMapper.toDetailResponse(server))) + .orElseThrow(() -> new ResourceNotFoundException("Server not found")); + } + + @GetMapping("/{id}/stats") + public ResponseEntity getServerStats(@PathVariable String id) { + return serverService.findById(id) + .map(server -> { + Map stats = serverService.getServerStats(server); + return ResponseEntity.ok((Object) AdminServerProtoMapper.toStatsResponse(stats)); + }) + .orElseThrow(() -> new ResourceNotFoundException("Server not found")); + } + + @PostMapping + public ResponseEntity createServer(@RequestBody AdminServerCreateRequest request) { + Server saved = serverService.createServer( + request.getServerName(), + request.getCustomDomain(), + request.getAdminEmail(), + request.hasPlan() ? request.getPlan() : null); + return ResponseEntity.status(201).body( + AdminServerProtoMapper.toMutationResponse(saved, "Server created successfully")); + } + + @PutMapping("/{id}") + public ResponseEntity updateServer(@PathVariable String id, @RequestBody UpdateServerRequest request) { + Server server = serverService.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Server not found")); + + if (request.hasAdminEmail()) { + serverService.changeAdminEmail(server, request.getAdminEmail()); + } + + Map updateData = new HashMap<>(); + if (request.hasEmailVerified()) { + updateData.put("emailVerified", request.getEmailVerified()); + } + if (request.hasProvisioningStatus()) { + updateData.put("provisioningStatus", request.getProvisioningStatus()); + } + if (request.hasProvisioningNotes()) { + updateData.put("provisioningNotes", request.getProvisioningNotes()); + } + if (request.hasPlan()) { + updateData.put("plan", request.getPlan()); + } + if (request.hasSubscriptionStatus()) { + updateData.put("subscriptionStatus", request.getSubscriptionStatus()); + } + if (request.hasLastActivityAt()) { + updateData.put("lastActivityAt", request.getLastActivityAt()); + } + updateData.put("updatedAt", new Date()); + + Server updated = serverService.updateById(id, updateData); + if (updated != null) { + return ResponseEntity.ok(AdminServerProtoMapper.toMutationResponse(updated, "Server updated successfully")); + } + throw new ValidationException("Failed to update server"); + } + + @PutMapping("/{id}/stats") + public ResponseEntity updateServerStats(@PathVariable String id, @RequestBody AdminServerUpdateStatsRequest request) { + if (serverService.findById(id).isEmpty()) { + throw new ResourceNotFoundException("Server not found"); + } + + Map updateData = new HashMap<>(); + if (request.hasLastActivityAt()) { + updateData.put("lastActivityAt", toDate(request.getLastActivityAt())); + } + updateData.put("updatedAt", new Date()); + + Server updated = serverService.updateById(id, updateData); + if (updated != null) { + return ResponseEntity.ok(AdminServerProtoMapper.toMutationResponse(updated, "Server activity updated successfully")); + } + throw new ValidationException("Failed to update server"); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteServer(@PathVariable String id) { + if (serverService.deleteById(id)) { + return ResponseEntity.ok(AdminServerProtoMapper.toMutationResponse(null, "Server deleted successfully")); + } + throw new ResourceNotFoundException("Server not found"); + } + + @PostMapping("/bulk") + public ResponseEntity bulkOperation(@RequestBody AdminServerBulkOperationRequest request) { + List serverIds = request.getServerIdsList(); + if (request.getAction().isEmpty() || serverIds.isEmpty()) { + throw new ValidationException("Missing required fields: action, serverIds"); + } + + String action = request.getAction(); + long affectedCount = switch (action) { + case "delete" -> serverService.bulkDelete(serverIds); + case "suspend" -> serverService.bulkSuspend(serverIds); + case "activate" -> serverService.bulkActivate(serverIds); + case "update-plan" -> { + if (!request.hasParameters() || !request.getParameters().hasPlan()) { + yield -1L; + } + yield serverService.bulkUpdatePlan(serverIds, request.getParameters().getPlan()); + } + default -> -1L; + }; + + if (affectedCount < 0) { + throw new ValidationException("Invalid action or missing parameters"); + } + + return ResponseEntity.ok(AdminServerProtoMapper.toBulkOperationResponse( + action, affectedCount, serverIds, "Bulk operation '" + action + "' completed successfully")); + } + + @PostMapping("/{id}/reset-database") + public ResponseEntity resetDatabase(@PathVariable String id) { + return serverService.findById(id) + .map(server -> { + serverService.resetServerDatabase(server); + log.info("Server {} reset to provisioning state by admin", server.getServerName()); + return ResponseEntity.ok((Object) AdminServerProtoMapper.toMutationResponse(null, + "Server reset to provisioning state. The provisioning system will reinitialize the database.")); + }) + .orElseThrow(() -> new ResourceNotFoundException("Server not found")); + } + + @PostMapping("/{id}/export-data") + public ResponseEntity exportData(@PathVariable String id) { + return serverService.findById(id) + .map(server -> ResponseEntity.ok((Object) AdminServerProtoMapper.toMutationResponse(null, + "Data export initiated. You will receive an email with the download link."))) + .orElseThrow(() -> new ResourceNotFoundException("Server not found")); + } + + @PostMapping("/search") + public ResponseEntity searchServers(@RequestBody AdminServerSearchRequest request) { + String query = request.hasQuery() ? request.getQuery() : ""; + String plan = request.hasFilters() && request.getFilters().hasPlan() ? request.getFilters().getPlan() : null; + String status = request.hasFilters() && request.getFilters().hasStatus() ? request.getFilters().getStatus() : null; + + List servers = serverService.findServers(query, plan, status, "createdAt", "desc", 0, 50); + long total = serverService.countServers(query, plan, status); + + return ResponseEntity.ok(AdminServerProtoMapper.toSearchResponse(servers, total)); + } + + @PostMapping("/export") + public ResponseEntity exportServers(@RequestBody AdminServerExportRequest request) { + String format = request.hasFormat() ? request.getFormat() : "json"; + String plan = request.hasFilters() && request.getFilters().hasPlan() ? request.getFilters().getPlan() : null; + String status = request.hasFilters() && request.getFilters().hasStatus() ? request.getFilters().getStatus() : null; + + if ("csv".equalsIgnoreCase(format)) { + return ResponseEntity.ok() + .header("Content-Type", "text/csv") + .header("Content-Disposition", "attachment; filename=servers-export.csv") + .body(serverService.exportServersCsv(plan, status)); + } + + List servers = serverService.findServers(null, plan, status, "createdAt", "desc", 0, 10000); + return ResponseEntity.ok(AdminServerProtoMapper.toExportResponse(servers, new Date(), format, servers.size())); + } + + private static Date toDate(Timestamp timestamp) { + return new Date(timestamp.getSeconds() * 1000L + timestamp.getNanos() / 1_000_000L); + } +} diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminSystemController.java b/src/main/java/gg/modl/backend/admin/controller/AdminSystemController.java index 172f2af..84e05fb 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminSystemController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminSystemController.java @@ -1,103 +1,106 @@ -package gg.modl.backend.admin.controller; - -import gg.modl.backend.admin.data.SystemConfig; -import gg.modl.backend.admin.data.SystemPrompt; -import gg.modl.backend.admin.dto.request.ToggleMaintenanceRequest; -import gg.modl.backend.admin.service.GlobalSystemService; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import java.util.Date; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.ADMIN_SYSTEM) -@RequiredArgsConstructor -@Slf4j -public class AdminSystemController { - private final GlobalSystemService globalSystemService; - - @GetMapping("/config") - public ResponseEntity getConfig() { - SystemConfig config = globalSystemService.getOrCreateConfig(); - return ResponseEntity.ok(AdminSystemProtoMapper.toConfigResponse(config, null)); - } - - @PutMapping("/config") - public ResponseEntity updateConfig(@RequestBody gg.modl.proto.modl.v1.UpdateSystemConfigRequest request) { - SystemConfig saved = globalSystemService.updateConfig(AdminSystemProtoMapper.fromUpdateConfig(request)); - log.info("Configuration updated by admin"); - return ResponseEntity.ok(AdminSystemProtoMapper.toConfigResponse(saved, "Configuration updated successfully")); - } - - @GetMapping("/maintenance") - public ResponseEntity getMaintenanceStatus() { - return ResponseEntity.ok(AdminSystemProtoMapper.toMaintenanceResponse(globalSystemService.getMaintenanceStatus(), null)); - } - - @PostMapping("/maintenance/toggle") - public ResponseEntity toggleMaintenance(@RequestBody gg.modl.proto.modl.v1.ToggleMaintenanceRequest request) { - ToggleMaintenanceRequest domainRequest = AdminSystemProtoMapper.fromToggleMaintenance(request); - boolean enabled = domainRequest.enabled(); - Map data = globalSystemService.toggleMaintenance(domainRequest); - log.info("Maintenance mode {} by admin", enabled ? "enabled" : "disabled"); - return ResponseEntity.ok(AdminSystemProtoMapper.toMaintenanceResponse( - data, "Maintenance mode " + (enabled ? "enabled" : "disabled"))); - } - - @GetMapping("/rate-limits") - public ResponseEntity getRateLimits() { - return ResponseEntity.ok(AdminSystemProtoMapper.toRateLimitsResponse(globalSystemService.getRateLimitStatus())); - } - - @PutMapping("/rate-limits") - public ResponseEntity updateRateLimits(@RequestBody gg.modl.proto.modl.v1.UpdateRateLimitsRequest request) { - SystemConfig.PerformanceConfig performanceConfig = globalSystemService.updateRateLimits( - AdminSystemProtoMapper.fromUpdateRateLimits(request)); - log.info("Rate limits updated by admin"); - return ResponseEntity.ok(AdminSystemProtoMapper.toRateLimitsUpdateResponse(performanceConfig, "Rate limits updated successfully")); - } - - @GetMapping("/prompts") - public ResponseEntity getPrompt() { - SystemPrompt prompt = globalSystemService.getPrompt(); - return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(prompt, null)); - } - - @PutMapping("/prompts") - public ResponseEntity updatePrompt(@RequestBody gg.modl.proto.modl.v1.UpdatePromptRequest request) { - SystemPrompt updated = globalSystemService.updatePrompt(AdminSystemProtoMapper.fromUpdatePrompt(request)); - log.info("System prompt updated"); - return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(updated, "System prompt updated successfully")); - } - - @PostMapping("/prompts/reset") - public ResponseEntity resetPrompt() { - SystemPrompt reset = globalSystemService.resetPrompt(); - log.info("System prompt reset to default"); - return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(reset, "System prompt reset to default")); - } - - @PostMapping("/services/{service}/restart") - public ResponseEntity restartService(@PathVariable String service) { - List allowedServices = List.of("api", "worker", "scheduler", "cache", "database"); - if (!allowedServices.contains(service)) { - throw new ValidationException("Invalid service name. Allowed: " + String.join(", ", allowedServices)); - } - - log.info("Service restart requested for: {} by admin", service); - - return ResponseEntity.ok(AdminSystemProtoMapper.toServiceRestartResponse( - service, "restarting", new Date(), "Service " + service + " restart initiated")); - } -} +package gg.modl.backend.admin.controller; + +import gg.modl.backend.admin.data.SystemConfig; +import gg.modl.backend.admin.data.SystemPrompt; +import gg.modl.backend.admin.dto.request.ToggleMaintenanceRequest; +import gg.modl.backend.admin.dto.response.AdminMaintenanceStatus; +import gg.modl.backend.admin.service.GlobalSystemService; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.proto.modl.v1.UpdatePromptRequest; +import gg.modl.proto.modl.v1.UpdateRateLimitsRequest; +import gg.modl.proto.modl.v1.UpdateSystemConfigRequest; +import java.util.Date; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.ADMIN_SYSTEM) +@RequiredArgsConstructor +@Slf4j +public class AdminSystemController { + private final GlobalSystemService globalSystemService; + + @GetMapping("/config") + public ResponseEntity getConfig() { + SystemConfig config = globalSystemService.getOrCreateConfig(); + return ResponseEntity.ok(AdminSystemProtoMapper.toConfigResponse(config, null)); + } + + @PutMapping("/config") + public ResponseEntity updateConfig(@RequestBody UpdateSystemConfigRequest request) { + SystemConfig saved = globalSystemService.updateConfig(AdminSystemProtoMapper.fromUpdateConfig(request)); + log.info("Configuration updated by admin"); + return ResponseEntity.ok(AdminSystemProtoMapper.toConfigResponse(saved, "Configuration updated successfully")); + } + + @GetMapping("/maintenance") + public ResponseEntity getMaintenanceStatus() { + return ResponseEntity.ok(AdminSystemProtoMapper.toMaintenanceResponse(globalSystemService.getMaintenanceStatus(), null)); + } + + @PostMapping("/maintenance/toggle") + public ResponseEntity toggleMaintenance(@RequestBody gg.modl.proto.modl.v1.ToggleMaintenanceRequest request) { + ToggleMaintenanceRequest domainRequest = AdminSystemProtoMapper.fromToggleMaintenance(request); + boolean enabled = domainRequest.enabled(); + AdminMaintenanceStatus data = globalSystemService.toggleMaintenance(domainRequest); + log.info("Maintenance mode {} by admin", enabled ? "enabled" : "disabled"); + return ResponseEntity.ok(AdminSystemProtoMapper.toMaintenanceResponse( + data, "Maintenance mode " + (enabled ? "enabled" : "disabled"))); + } + + @GetMapping("/rate-limits") + public ResponseEntity getRateLimits() { + return ResponseEntity.ok(AdminSystemProtoMapper.toRateLimitsResponse(globalSystemService.getRateLimitStatus())); + } + + @PutMapping("/rate-limits") + public ResponseEntity updateRateLimits(@RequestBody UpdateRateLimitsRequest request) { + SystemConfig.PerformanceConfig performanceConfig = globalSystemService.updateRateLimits( + AdminSystemProtoMapper.fromUpdateRateLimits(request)); + log.info("Rate limits updated by admin"); + return ResponseEntity.ok(AdminSystemProtoMapper.toRateLimitsUpdateResponse(performanceConfig, "Rate limits updated successfully")); + } + + @GetMapping("/prompts") + public ResponseEntity getPrompt() { + SystemPrompt prompt = globalSystemService.getPrompt(); + return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(prompt, null)); + } + + @PutMapping("/prompts") + public ResponseEntity updatePrompt(@RequestBody UpdatePromptRequest request) { + SystemPrompt updated = globalSystemService.updatePrompt(AdminSystemProtoMapper.fromUpdatePrompt(request)); + log.info("System prompt updated"); + return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(updated, "System prompt updated successfully")); + } + + @PostMapping("/prompts/reset") + public ResponseEntity resetPrompt() { + SystemPrompt reset = globalSystemService.resetPrompt(); + log.info("System prompt reset to default"); + return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(reset, "System prompt reset to default")); + } + + @PostMapping("/services/{service}/restart") + public ResponseEntity restartService(@PathVariable String service) { + List allowedServices = List.of("api", "worker", "scheduler", "cache", "database"); + if (!allowedServices.contains(service)) { + throw new ValidationException("Invalid service name. Allowed: " + String.join(", ", allowedServices)); + } + + log.info("Service restart requested for: {} by admin", service); + + return ResponseEntity.ok(AdminSystemProtoMapper.toServiceRestartResponse( + service, "restarting", new Date(), "Service " + service + " restart initiated")); + } +} diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminSystemProtoMapper.java b/src/main/java/gg/modl/backend/admin/controller/AdminSystemProtoMapper.java index 7bab645..ea4b766 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminSystemProtoMapper.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminSystemProtoMapper.java @@ -6,6 +6,8 @@ import gg.modl.backend.admin.dto.request.UpdatePromptRequest; import gg.modl.backend.admin.dto.request.UpdateRateLimitsRequest; import gg.modl.backend.admin.dto.request.UpdateSystemConfigRequest; +import gg.modl.backend.admin.dto.response.AdminMaintenanceStatus; +import gg.modl.backend.admin.dto.response.AdminRateLimitStatus; import gg.modl.proto.modl.v1.AdminSystemConfig; import gg.modl.proto.modl.v1.AdminSystemConfigResponse; import gg.modl.proto.modl.v1.AdminSystemFeaturesConfig; @@ -27,9 +29,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; -import java.util.Map; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.booleanValue; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toTimestamp; @@ -76,12 +76,11 @@ static AdminSystemConfigResponse toConfigResponse(SystemConfig config, String me return builder.build(); } - static AdminSystemMaintenanceResponse toMaintenanceResponse(Map status, String message) { + static AdminSystemMaintenanceResponse toMaintenanceResponse(AdminMaintenanceStatus status, String message) { AdminSystemMaintenanceStatus.Builder data = AdminSystemMaintenanceStatus.newBuilder() - .setIsActive(booleanValue(status.get("isActive"))); - Object messageValue = status.get("message"); - if (messageValue != null) { - data.setMessage(stringValue(messageValue)); + .setIsActive(status.active()); + if (status.message() != null) { + data.setMessage(status.message()); } AdminSystemMaintenanceResponse.Builder builder = AdminSystemMaintenanceResponse.newBuilder() .setSuccess(true) @@ -92,16 +91,14 @@ static AdminSystemMaintenanceResponse toMaintenanceResponse(Map return builder.build(); } - static AdminSystemRateLimitsResponse toRateLimitsResponse(Map status) { + static AdminSystemRateLimitsResponse toRateLimitsResponse(AdminRateLimitStatus status) { AdminSystemRateLimitsData.Builder data = AdminSystemRateLimitsData.newBuilder() - .setActive(booleanValue(status.get("active"))); - Object current = status.get("current"); - if (current instanceof SystemConfig.PerformanceConfig performance) { - data.setCurrent(toPerformance(performance)); + .setActive(status.active()); + if (status.current() != null) { + data.setCurrent(toPerformance(status.current())); } - Object resetTime = status.get("resetTime"); - if (resetTime != null) { - data.setResetTime(toTimestamp(resetTime)); + if (status.resetTime() != null) { + data.setResetTime(toTimestamp(status.resetTime())); } return AdminSystemRateLimitsResponse.newBuilder() .setSuccess(true) diff --git a/src/main/java/gg/modl/backend/admin/data/AdminUser.java b/src/main/java/gg/modl/backend/admin/data/AdminUser.java index 2ff143e..f2c1ccf 100644 --- a/src/main/java/gg/modl/backend/admin/data/AdminUser.java +++ b/src/main/java/gg/modl/backend/admin/data/AdminUser.java @@ -1,37 +1,38 @@ -package gg.modl.backend.admin.data; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import gg.modl.backend.email.EmailAddressUtil; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import lombok.Data; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Data -@Document(collection = "admin_users") -@GenerateMongoFields -public class AdminUser { - @Id - private String id; - @Field("email") - private String email; - @Field("loggedInIps") - private List loggedInIps = new ArrayList<>(); - @Field("lastActivityAt") - private Date lastActivityAt; - @Field("createdAt") - private Date createdAt; - - public AdminUser(String email) { - this(); - this.email = EmailAddressUtil.normalize(email); - } - - public AdminUser() { - this.createdAt = new Date(); - this.lastActivityAt = new Date(); - } -} +package gg.modl.backend.admin.data; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import gg.modl.backend.email.EmailAddressUtil; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.Data; +import org.springframework.data.annotation.Id; +import gg.modl.backend.database.CollectionName; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Data +@Document(collection = CollectionName.ADMIN_USERS) +@GenerateMongoFields +public class AdminUser { + @Id + private String id; + @Field("email") + private String email; + @Field("loggedInIps") + private List loggedInIps = new ArrayList<>(); + @Field("lastActivityAt") + private Date lastActivityAt; + @Field("createdAt") + private Date createdAt; + + public AdminUser(String email) { + this(); + this.email = EmailAddressUtil.normalize(email); + } + + public AdminUser() { + this.createdAt = new Date(); + this.lastActivityAt = new Date(); + } +} diff --git a/src/main/java/gg/modl/backend/admin/data/SystemConfig.java b/src/main/java/gg/modl/backend/admin/data/SystemConfig.java index 0a590fd..88a8df3 100644 --- a/src/main/java/gg/modl/backend/admin/data/SystemConfig.java +++ b/src/main/java/gg/modl/backend/admin/data/SystemConfig.java @@ -1,133 +1,134 @@ -package gg.modl.backend.admin.data; - -import gg.modl.backend.Constants; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import lombok.Data; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Data -@Document(collection = "system_config") -@GenerateMongoFields -public class SystemConfig { - @Id - private String id; - @Field("configId") - private String configId = "main_config"; - - @Field("general") - private GeneralConfig general = new GeneralConfig(); - @Field("logging") - private LoggingConfig logging = new LoggingConfig(); - @Field("security") - private SecurityConfig security = new SecurityConfig(); - @Field("notifications") - private NotificationsConfig notifications = new NotificationsConfig(); - @Field("performance") - private PerformanceConfig performance = new PerformanceConfig(); - @Field("features") - private FeaturesConfig features = new FeaturesConfig(); - - @Field("createdAt") - private Date createdAt = new Date(); - @Field("updatedAt") - private Date updatedAt = new Date(); - - @Data - public static class GeneralConfig { - @Field("systemName") - private String systemName = "modl Admin"; - @Field("adminEmail") - private String adminEmail = Constants.Email.ADMIN; - @Field("timezone") - private String timezone = "UTC"; - @Field("defaultLanguage") - private String defaultLanguage = "en"; - @Field("maintenanceMode") - private boolean maintenanceMode = false; - @Field("maintenanceMessage") - private String maintenanceMessage = "System under maintenance. Please check back later."; - } - - @Data - public static class LoggingConfig { - @Field("pm2LoggingEnabled") - private boolean pm2LoggingEnabled = true; - @Field("logRetentionDays") - private int logRetentionDays = 30; - @Field("maxLogSizePerDay") - private int maxLogSizePerDay = 1000000; - } - - @Data - public static class SecurityConfig { - @Field("sessionTimeout") - private int sessionTimeout = 60; - @Field("maxLoginAttempts") - private int maxLoginAttempts = 5; - @Field("lockoutDuration") - private int lockoutDuration = 15; - @Field("requireTwoFactor") - private boolean requireTwoFactor = false; - @Field("passwordMinLength") - private int passwordMinLength = 8; - @Field("passwordRequireSpecial") - private boolean passwordRequireSpecial = false; - @Field("ipWhitelist") - private List ipWhitelist = new ArrayList<>(); - @Field("corsOrigins") - private List corsOrigins = new ArrayList<>(List.of(Constants.Domain.HTTPS_ADMIN)); - } - - @Data - public static class NotificationsConfig { - @Field("emailNotifications") - private boolean emailNotifications = true; - @Field("criticalAlerts") - private boolean criticalAlerts = true; - @Field("weeklyReports") - private boolean weeklyReports = true; - @Field("maintenanceAlerts") - private boolean maintenanceAlerts = true; - @Field("slackWebhook") - private String slackWebhook = ""; - @Field("discordWebhook") - private String discordWebhook = ""; - } - - @Data - public static class PerformanceConfig { - @Field("cacheTtl") - private int cacheTtl = 300; - @Field("rateLimitRequests") - private int rateLimitRequests = 100; - @Field("rateLimitWindow") - private int rateLimitWindow = 60; - @Field("databaseConnectionPool") - private int databaseConnectionPool = 10; - @Field("enableCompression") - private boolean enableCompression = true; - @Field("enableCaching") - private boolean enableCaching = true; - } - - @Data - public static class FeaturesConfig { - @Field("analyticsEnabled") - private boolean analyticsEnabled = true; - @Field("auditLoggingEnabled") - private boolean auditLoggingEnabled = true; - @Field("apiAccessEnabled") - private boolean apiAccessEnabled = true; - @Field("bulkOperationsEnabled") - private boolean bulkOperationsEnabled = true; - @Field("advancedFiltering") - private boolean advancedFiltering = true; - @Field("realTimeUpdates") - private boolean realTimeUpdates = true; - } -} +package gg.modl.backend.admin.data; + +import gg.modl.backend.Constants; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.Data; +import org.springframework.data.annotation.Id; +import gg.modl.backend.database.CollectionName; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Data +@Document(collection = CollectionName.SYSTEM_CONFIG) +@GenerateMongoFields +public class SystemConfig { + @Id + private String id; + @Field("configId") + private String configId = "main_config"; + + @Field("general") + private GeneralConfig general = new GeneralConfig(); + @Field("logging") + private LoggingConfig logging = new LoggingConfig(); + @Field("security") + private SecurityConfig security = new SecurityConfig(); + @Field("notifications") + private NotificationsConfig notifications = new NotificationsConfig(); + @Field("performance") + private PerformanceConfig performance = new PerformanceConfig(); + @Field("features") + private FeaturesConfig features = new FeaturesConfig(); + + @Field("createdAt") + private Date createdAt = new Date(); + @Field("updatedAt") + private Date updatedAt = new Date(); + + @Data + public static class GeneralConfig { + @Field("systemName") + private String systemName = "modl Admin"; + @Field("adminEmail") + private String adminEmail = Constants.Email.ADMIN; + @Field("timezone") + private String timezone = "UTC"; + @Field("defaultLanguage") + private String defaultLanguage = "en"; + @Field("maintenanceMode") + private boolean maintenanceMode = false; + @Field("maintenanceMessage") + private String maintenanceMessage = "System under maintenance. Please check back later."; + } + + @Data + public static class LoggingConfig { + @Field("pm2LoggingEnabled") + private boolean pm2LoggingEnabled = true; + @Field("logRetentionDays") + private int logRetentionDays = 30; + @Field("maxLogSizePerDay") + private int maxLogSizePerDay = 1000000; + } + + @Data + public static class SecurityConfig { + @Field("sessionTimeout") + private int sessionTimeout = 60; + @Field("maxLoginAttempts") + private int maxLoginAttempts = 5; + @Field("lockoutDuration") + private int lockoutDuration = 15; + @Field("requireTwoFactor") + private boolean requireTwoFactor = false; + @Field("passwordMinLength") + private int passwordMinLength = 8; + @Field("passwordRequireSpecial") + private boolean passwordRequireSpecial = false; + @Field("ipWhitelist") + private List ipWhitelist = new ArrayList<>(); + @Field("corsOrigins") + private List corsOrigins = new ArrayList<>(List.of(Constants.Domain.HTTPS_ADMIN)); + } + + @Data + public static class NotificationsConfig { + @Field("emailNotifications") + private boolean emailNotifications = true; + @Field("criticalAlerts") + private boolean criticalAlerts = true; + @Field("weeklyReports") + private boolean weeklyReports = true; + @Field("maintenanceAlerts") + private boolean maintenanceAlerts = true; + @Field("slackWebhook") + private String slackWebhook = ""; + @Field("discordWebhook") + private String discordWebhook = ""; + } + + @Data + public static class PerformanceConfig { + @Field("cacheTtl") + private int cacheTtl = 300; + @Field("rateLimitRequests") + private int rateLimitRequests = 100; + @Field("rateLimitWindow") + private int rateLimitWindow = 60; + @Field("databaseConnectionPool") + private int databaseConnectionPool = 10; + @Field("enableCompression") + private boolean enableCompression = true; + @Field("enableCaching") + private boolean enableCaching = true; + } + + @Data + public static class FeaturesConfig { + @Field("analyticsEnabled") + private boolean analyticsEnabled = true; + @Field("auditLoggingEnabled") + private boolean auditLoggingEnabled = true; + @Field("apiAccessEnabled") + private boolean apiAccessEnabled = true; + @Field("bulkOperationsEnabled") + private boolean bulkOperationsEnabled = true; + @Field("advancedFiltering") + private boolean advancedFiltering = true; + @Field("realTimeUpdates") + private boolean realTimeUpdates = true; + } +} diff --git a/src/main/java/gg/modl/backend/admin/data/SystemLog.java b/src/main/java/gg/modl/backend/admin/data/SystemLog.java index 60fbbfe..8db9225 100644 --- a/src/main/java/gg/modl/backend/admin/data/SystemLog.java +++ b/src/main/java/gg/modl/backend/admin/data/SystemLog.java @@ -1,50 +1,50 @@ -package gg.modl.backend.admin.data; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import lombok.Data; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Data -@Document(collection = "system_logs") -@GenerateMongoFields -public class SystemLog { - @Id - private String id; - @Field("level") - private String level; - @Field("message") - private String message; - @Field("source") - private String source; - @Field("category") - private String category; - @Field("serverId") - private String serverId; - @Field("metadata") - private Map metadata = new HashMap<>(); - @Field("resolved") - private boolean resolved; - @Field("resolvedBy") - private String resolvedBy; - @Field("resolvedAt") - private Date resolvedAt; - @Field("timestamp") - private Date timestamp; - - public SystemLog(String level, String message, String source) { - this(); - this.level = level; - this.message = message; - this.source = source; - } - - public SystemLog() { - this.timestamp = new Date(); - this.resolved = false; - } -} +package gg.modl.backend.admin.data; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Data +@Document(collection = "system_logs") +@GenerateMongoFields +public class SystemLog { + @Id + private String id; + @Field("level") + private String level; + @Field("message") + private String message; + @Field("source") + private String source; + @Field("category") + private String category; + @Field("serverId") + private String serverId; + @Field("metadata") + private Map metadata = new HashMap<>(); + @Field("resolved") + private boolean resolved; + @Field("resolvedBy") + private String resolvedBy; + @Field("resolvedAt") + private Date resolvedAt; + @Field("timestamp") + private Date timestamp; + + public SystemLog(String level, String message, String source) { + this(); + this.level = level; + this.message = message; + this.source = source; + } + + public SystemLog() { + this.timestamp = new Date(); + this.resolved = false; + } +} diff --git a/src/main/java/gg/modl/backend/admin/data/SystemPrompt.java b/src/main/java/gg/modl/backend/admin/data/SystemPrompt.java index ce413ff..9235ef1 100644 --- a/src/main/java/gg/modl/backend/admin/data/SystemPrompt.java +++ b/src/main/java/gg/modl/backend/admin/data/SystemPrompt.java @@ -4,11 +4,12 @@ import java.util.Date; import lombok.Data; import org.springframework.data.annotation.Id; +import gg.modl.backend.database.CollectionName; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; @Data -@Document(collection = "systemprompts") +@Document(collection = CollectionName.SYSTEM_PROMPTS) @GenerateMongoFields public class SystemPrompt { @Id diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsActivity.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsActivity.java new file mode 100644 index 0000000..7cb737e --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsActivity.java @@ -0,0 +1,16 @@ +package gg.modl.backend.admin.dto.response; + +import java.util.List; + +public record AdminAnalyticsActivity( + long totalPlayers, + long totalServers, + List data +) { + public record ActivityPoint( + String date, + long activeServers, + int onlinePlayers + ) { + } +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsDashboard.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsDashboard.java new file mode 100644 index 0000000..7fb30f9 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsDashboard.java @@ -0,0 +1,60 @@ +package gg.modl.backend.admin.dto.response; + +import gg.modl.backend.server.data.Server; +import java.util.List; + +public record AdminAnalyticsDashboard( + Overview overview, + ServerMetrics serverMetrics, + UsageStatistics usageStatistics +) { + public record Overview( + long totalServers, + long activeServers, + long totalUsers, + long totalTickets, + String serverGrowthRate, + String userGrowthRate, + String avgPlayersPerServer, + String avgTicketsPerServer + ) { + } + + public record ServerMetrics( + List byPlan, + List byStatus, + List registrationTrend + ) { + } + + public record UsageStatistics( + List topServersByUsers, + List serverActivity, + List liveServers, + int totalPlayerCount, + List playerActivity + ) { + } + + public record ServerActivityPoint( + String date, + long activeServers + ) { + } + + public record LiveServer( + String serverId, + String serverName, + int playerCount, + String platform, + String version, + String pluginVersion + ) { + } + + public record PlayerActivityPoint( + String date, + int players + ) { + } +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsExport.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsExport.java new file mode 100644 index 0000000..1243464 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsExport.java @@ -0,0 +1,10 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminAnalyticsExport( + String exportDate, + String range, + long servers, + long users, + long tickets +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsHistorical.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsHistorical.java new file mode 100644 index 0000000..2c0e2fd --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsHistorical.java @@ -0,0 +1,10 @@ +package gg.modl.backend.admin.dto.response; + +import java.util.List; + +public record AdminAnalyticsHistorical( + String metric, + String range, + List data +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsUsage.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsUsage.java new file mode 100644 index 0000000..c0584b3 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsUsage.java @@ -0,0 +1,10 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminAnalyticsUsage( + long monthlyActiveServers, + long storage, + double storagePercent, + long apiCalls, + long databaseQueries +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminHistoricalPoint.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminHistoricalPoint.java new file mode 100644 index 0000000..9f27d24 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminHistoricalPoint.java @@ -0,0 +1,7 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminHistoricalPoint( + String date, + long value +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminMaintenanceStatus.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminMaintenanceStatus.java new file mode 100644 index 0000000..2f00c3e --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminMaintenanceStatus.java @@ -0,0 +1,7 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminMaintenanceStatus( + boolean active, + String message +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringDashboard.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringDashboard.java new file mode 100644 index 0000000..737ba5c --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringDashboard.java @@ -0,0 +1,50 @@ +package gg.modl.backend.admin.dto.response; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +public record AdminMonitoringDashboard( + ServerMetrics servers, + LogMetrics logs, + SystemHealth systemHealth, + List> trends, + Date lastUpdated +) { + public record ServerMetrics( + long total, + long active, + long pending, + long failed, + long recentRegistrations, + long concurrentServers, + long concurrentPlayers + ) { + } + + public record LogMetrics( + LogWindow last24h, + UnresolvedLogs unresolved + ) { + } + + public record LogWindow( + long total, + long critical, + long error, + long warning + ) { + } + + public record UnresolvedLogs( + long critical, + long error + ) { + } + + public record SystemHealth( + int score, + String status + ) { + } +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringHealth.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringHealth.java new file mode 100644 index 0000000..0a36f32 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringHealth.java @@ -0,0 +1,31 @@ +package gg.modl.backend.admin.dto.response; + +import java.util.Date; +import java.util.List; + +public record AdminMonitoringHealth( + String status, + List checks, + Date timestamp +) { + public record HealthCheck( + String name, + String status, + String message, + Long responseTime, + String error, + Long count + ) { + public static HealthCheck responsive(String name, String status, String message, long responseTime) { + return new HealthCheck(name, status, message, responseTime, null, null); + } + + public static HealthCheck failure(String name, String status, String message, String error) { + return new HealthCheck(name, status, message, null, error, null); + } + + public static HealthCheck counted(String name, String status, String message, long count) { + return new HealthCheck(name, status, message, null, null, count); + } + } +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringLogs.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringLogs.java new file mode 100644 index 0000000..78ea12b --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringLogs.java @@ -0,0 +1,20 @@ +package gg.modl.backend.admin.dto.response; + +import gg.modl.backend.admin.data.SystemLog; +import java.util.List; + +public record AdminMonitoringLogs( + List logs, + AdminPagination pagination, + Filters filters +) { + public record Filters( + String level, + String source, + String serverId, + String category, + String resolved, + String search + ) { + } +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringSources.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringSources.java new file mode 100644 index 0000000..8e8eb43 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringSources.java @@ -0,0 +1,9 @@ +package gg.modl.backend.admin.dto.response; + +import java.util.List; + +public record AdminMonitoringSources( + List sources, + List categories +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminNameCount.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminNameCount.java new file mode 100644 index 0000000..7822eb5 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminNameCount.java @@ -0,0 +1,7 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminNameCount( + String name, + int value +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminPagination.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminPagination.java new file mode 100644 index 0000000..e9a7500 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminPagination.java @@ -0,0 +1,9 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminPagination( + int page, + int limit, + long total, + int pages +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminRateLimitStatus.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminRateLimitStatus.java new file mode 100644 index 0000000..9778bfb --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminRateLimitStatus.java @@ -0,0 +1,11 @@ +package gg.modl.backend.admin.dto.response; + +import gg.modl.backend.admin.data.SystemConfig; +import java.util.Date; + +public record AdminRateLimitStatus( + SystemConfig.PerformanceConfig current, + boolean active, + Date resetTime +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminRegistrationPoint.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminRegistrationPoint.java new file mode 100644 index 0000000..c3e1c6a --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminRegistrationPoint.java @@ -0,0 +1,7 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminRegistrationPoint( + String date, + int servers +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminSecurityEvents.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminSecurityEvents.java new file mode 100644 index 0000000..d9ad6cb --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminSecurityEvents.java @@ -0,0 +1,10 @@ +package gg.modl.backend.admin.dto.response; + +import gg.modl.backend.admin.data.SecurityEvent; +import java.util.List; + +public record AdminSecurityEvents( + List events, + AdminPagination pagination +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminSecuritySummary.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminSecuritySummary.java new file mode 100644 index 0000000..369bb68 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminSecuritySummary.java @@ -0,0 +1,16 @@ +package gg.modl.backend.admin.dto.response; + +import java.util.Date; + +public record AdminSecuritySummary( + Last24Hours last24Hours, + long last7DaysTotal, + Date timestamp +) { + public record Last24Hours( + long critical, + long high, + long medium + ) { + } +} diff --git a/src/main/java/gg/modl/backend/admin/service/AdminAnalyticsService.java b/src/main/java/gg/modl/backend/admin/service/AdminAnalyticsService.java index cacf29a..b1aa1f9 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminAnalyticsService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminAnalyticsService.java @@ -1,16 +1,28 @@ package gg.modl.backend.admin.service; +import gg.modl.backend.admin.dto.response.AdminAnalyticsActivity; +import gg.modl.backend.admin.dto.response.AdminAnalyticsDashboard; +import gg.modl.backend.admin.dto.response.AdminAnalyticsExport; +import gg.modl.backend.admin.dto.response.AdminAnalyticsHistorical; +import gg.modl.backend.admin.dto.response.AdminAnalyticsUsage; +import gg.modl.backend.admin.dto.response.AdminHistoricalPoint; +import gg.modl.backend.admin.dto.response.AdminNameCount; +import gg.modl.backend.admin.dto.response.AdminRegistrationPoint; import gg.modl.backend.analytics.data.MetricSnapshot; import gg.modl.backend.analytics.data.ServerInstanceSnapshot; import gg.modl.backend.database.mongo.repository.GlobalMongoAdminRepository; import gg.modl.backend.database.mongo.repository.MetricSnapshotMongoRepository; import gg.modl.backend.database.mongo.repository.ServerInstanceSnapshotMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.server.data.Server; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository.DateServersResult; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository.DateValueResult; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository.NameValueResult; +import gg.modl.backend.database.mongo.repository.ServerUsageRepository; +import gg.modl.backend.infrastructure.exception.ValidationException; import gg.modl.backend.infrastructure.util.DateRangeUtil; +import gg.modl.backend.server.data.Server; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -19,28 +31,26 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository.DateServersResult; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository.DateValueResult; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository.NameValueResult; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor @Slf4j public class AdminAnalyticsService { - private final ServerMongoRepository serverRepository; + private final ServerMetricsRepository serverMetricsRepository; + private final ServerUsageRepository serverUsageRepository; private final MetricSnapshotMongoRepository metricSnapshotRepository; private final ServerInstanceSnapshotMongoRepository serverInstanceSnapshotRepository; private final GlobalMongoAdminRepository globalMongoAdminRepository; private final AdminServerService adminServerService; - public Map getDashboard(String range) { + public AdminAnalyticsDashboard getDashboard(String range) { int days = DateRangeUtil.resolveRangeDays(range); Instant now = Instant.now(); Date startDate = Date.from(now.minus(days, ChronoUnit.DAYS)); Date previousStartDate = Date.from(now.minus(days * 2L, ChronoUnit.DAYS)); - ServerMongoRepository.DashboardStats stats = serverRepository.aggregateDashboardStats(startDate, previousStartDate); + ServerMetricsRepository.DashboardStats stats = serverMetricsRepository.aggregateDashboardStats(startDate, previousStartDate); long totalServers = stats.totalServers(); int refreshLimit = totalServers <= 200 ? (int) Math.max(totalServers, 1) : 50; @@ -56,10 +66,10 @@ public Map getDashboard(String range) { ? ((currentPeriodServers - previousPeriodServers) / (double) previousPeriodServers) * 100 : (currentPeriodServers > 0 ? 100 : 0); - List planResults = serverRepository.aggregatePlanCounts(); - List statusResults = serverRepository.aggregateProvisioningStatusCounts(); - List registrationTrend = serverRepository.findRegistrationTrend(startDate); - List topServers = serverRepository.findTopCompletedVerifiedByUserCount(10); + List planResults = serverMetricsRepository.aggregatePlanCounts(); + List statusResults = serverMetricsRepository.aggregateProvisioningStatusCounts(); + List registrationTrend = serverMetricsRepository.findRegistrationTrend(startDate); + List topServers = serverMetricsRepository.findTopCompletedVerifiedByUserCount(10); long serversWithData = stats.serversWithData(); double avgPlayersPerServer = serversWithData > 0 ? (double) totalUsers / serversWithData : 0; @@ -69,10 +79,9 @@ public Map getDashboard(String range) { Date last24h = Date.from(now.minus(24, ChronoUnit.HOURS)); List metricSnapshots = metricSnapshotRepository.findSinceOrdered(last24h); - List> serverActivity = metricSnapshots.stream().map(s -> Map.of( - "date", s.getDate().toInstant().toString(), - "activeServers", s.getActiveServers() - )).toList(); + List serverActivity = metricSnapshots.stream() + .map(s -> new AdminAnalyticsDashboard.ServerActivityPoint(s.getDate().toInstant().toString(), s.getActiveServers())) + .toList(); List instanceSnapshots = serverInstanceSnapshotRepository.findSinceOrdered(last24h); @@ -81,24 +90,23 @@ public Map getDashboard(String range) { ? instanceSnapshots.getLast() : null; - List> liveServers; + List liveServers; if (latestSnapshot != null && latestSnapshot.getServers() != null && !latestSnapshot.getServers().isEmpty()) { List serverIds = latestSnapshot.getServers().stream() .map(ServerInstanceSnapshot.ServerEntry::getServerId) .collect(Collectors.toList()); - Map serverNameMap = serverRepository.findUsageTargetsByIds(serverIds).stream() + Map serverNameMap = serverUsageRepository.findUsageTargetsByIds(serverIds).stream() .collect(Collectors.toMap(Server::getId, Server::getServerName, (a, b) -> a)); - liveServers = latestSnapshot.getServers().stream().map(srv -> { - Map map = new HashMap<>(); - map.put("serverId", srv.getServerId()); - map.put("serverName", serverNameMap.getOrDefault(srv.getServerId(), srv.getServerName())); - map.put("playerCount", srv.getPlayerCount()); - map.put("platform", srv.getPlatform()); - map.put("version", srv.getVersion()); - map.put("pluginVersion", srv.getPluginVersion()); - return map; - }).toList(); + liveServers = latestSnapshot.getServers().stream() + .map(srv -> new AdminAnalyticsDashboard.LiveServer( + srv.getServerId(), + serverNameMap.getOrDefault(srv.getServerId(), srv.getServerName()), + srv.getPlayerCount(), + srv.getPlatform(), + srv.getVersion(), + srv.getPluginVersion())) + .toList(); } else { liveServers = List.of(); } @@ -107,40 +115,33 @@ public Map getDashboard(String range) { ? sumPlayerCount(latestSnapshot) : 0; - List> playerActivity = instanceSnapshots.stream().map(s -> Map.of( - "date", s.getDate().toInstant().toString(), - "players", sumPlayerCount(s) - )).toList(); - - Map usageStatistics = new HashMap<>(); - usageStatistics.put("topServersByUsers", topServers); - usageStatistics.put("serverActivity", serverActivity); - usageStatistics.put("liveServers", liveServers); - usageStatistics.put("totalPlayerCount", totalPlayerCount); - usageStatistics.put("playerActivity", playerActivity); - - Map response = new HashMap<>(); - response.put("success", true); - response.put("data", Map.of( - "overview", Map.of( - "totalServers", totalServers, - "activeServers", activeServers, - "totalUsers", totalUsers, - "totalTickets", totalTickets, - "serverGrowthRate", String.format("%.2f", serverGrowthRate), - "userGrowthRate", "0.00", - "avgPlayersPerServer", String.format("%.1f", avgPlayersPerServer), - "avgTicketsPerServer", String.format("%.1f", avgTicketsPerServer) - ), - "serverMetrics", Map.of( - "byPlan", planResults, - "byStatus", statusResults, - "registrationTrend", registrationTrend - ), - "usageStatistics", usageStatistics, - "systemHealth", Map.of("errorRates", Collections.emptyList()) - )); - return response; + List playerActivity = instanceSnapshots.stream() + .map(s -> new AdminAnalyticsDashboard.PlayerActivityPoint(s.getDate().toInstant().toString(), sumPlayerCount(s))) + .toList(); + + AdminAnalyticsDashboard.Overview overview = new AdminAnalyticsDashboard.Overview( + totalServers, + activeServers, + totalUsers, + totalTickets, + String.format("%.2f", serverGrowthRate), + "0.00", + String.format("%.1f", avgPlayersPerServer), + String.format("%.1f", avgTicketsPerServer)); + + AdminAnalyticsDashboard.ServerMetrics serverMetrics = new AdminAnalyticsDashboard.ServerMetrics( + toNameCounts(planResults), + toNameCounts(statusResults), + toRegistrationPoints(registrationTrend)); + + AdminAnalyticsDashboard.UsageStatistics usageStatistics = new AdminAnalyticsDashboard.UsageStatistics( + topServers, + serverActivity, + liveServers, + totalPlayerCount, + playerActivity); + + return new AdminAnalyticsDashboard(overview, serverMetrics, usageStatistics); } private int sumPlayerCount(ServerInstanceSnapshot snapshot) { @@ -149,15 +150,15 @@ private int sumPlayerCount(ServerInstanceSnapshot snapshot) { : 0; } - public Map getActivity(String range) { + public AdminAnalyticsActivity getActivity(String range) { int days = DateRangeUtil.resolveRangeDays(range); Date startDate = Date.from(Instant.now().minus(days, ChronoUnit.DAYS)); List snapshots = metricSnapshotRepository.findSinceOrdered(startDate); List instanceSnapshots = serverInstanceSnapshotRepository.findSinceOrdered(startDate); - long totalServers = serverRepository.countAll(); - long totalPlayers = serverRepository.getUsageTotals().totalUsers(); + long totalServers = serverMetricsRepository.countAll(); + long totalPlayers = serverMetricsRepository.getUsageTotals().totalUsers(); Map playersByBucket = new HashMap<>(); for (ServerInstanceSnapshot inst : instanceSnapshots) { @@ -166,88 +167,64 @@ public Map getActivity(String range) { playersByBucket.merge(bucketKey, players, Math::max); } - List> activityData = snapshots.stream().map(s -> { + List activityData = snapshots.stream().map(s -> { String dateKey = s.getDate().toInstant().toString(); int onlinePlayers = playersByBucket.getOrDefault(dateKey, 0); - return Map.of( - "date", dateKey, - "activeServers", s.getActiveServers(), - "onlinePlayers", onlinePlayers - ); + return new AdminAnalyticsActivity.ActivityPoint(dateKey, s.getActiveServers(), onlinePlayers); }).toList(); - return Map.of("success", true, "data", activityData, - "totalPlayers", totalPlayers, "totalServers", totalServers); + return new AdminAnalyticsActivity(totalPlayers, totalServers, activityData); } - public Map getUsage() { + public AdminAnalyticsUsage getUsage() { Date thirtyDaysAgo = Date.from(Instant.now().minus(30, ChronoUnit.DAYS)); - long activeServers = serverRepository.countActiveSince(thirtyDaysAgo); + long activeServers = serverMetricsRepository.countActiveSince(thirtyDaysAgo); long storageSize = globalMongoAdminRepository.getStorageSize(); - return Map.of( - "success", true, - "data", Map.of( - "userEngagement", Map.of("monthlyActiveServers", activeServers), - "resourceUtilization", Map.of( - "storage", storageSize, - "storagePercent", 0.0, - "apiCalls", 0, - "databaseQueries", 0 - ) - ) - ); + return new AdminAnalyticsUsage(activeServers, storageSize, 0.0, 0L, 0L); } - public Map getHistorical(String metric, String range) { + public AdminAnalyticsHistorical getHistorical(String metric, String range) { if (metric == null || (!metric.equals("servers") && !metric.equals("users") && !metric.equals("tickets"))) { - return Map.of("success", false, "error", "Invalid metric type"); + throw new ValidationException("Invalid metric type"); } int days = DateRangeUtil.resolveRangeDays(range); Date startDate = Date.from(Instant.now().minus(days, ChronoUnit.DAYS)); - List results = serverRepository.aggregateHistoricalMetric(metric, startDate); - - return Map.of( - "success", true, - "data", Map.of( - "metric", metric, - "range", range != null && !range.isBlank() ? range : "30d", - "data", results - ) - ); + List results = serverMetricsRepository.aggregateHistoricalMetric(metric, startDate); + + return new AdminAnalyticsHistorical( + metric, + range != null && !range.isBlank() ? range : "30d", + results.stream().map(r -> new AdminHistoricalPoint(r.date(), r.value())).toList()); } - public Object exportAnalytics(String type, String range) { - String normalizedType = type != null ? type : "json"; + public String exportCsv(String range) { String normalizedRange = range != null && !range.isBlank() ? range : "30d"; - int days = DateRangeUtil.resolveRangeDays(normalizedRange); Date startDate = Date.from(Instant.now().minus(days, ChronoUnit.DAYS)); + return buildCsv(startDate); + } - if ("csv".equals(normalizedType)) { - return buildCsv(startDate); - } + public AdminAnalyticsExport exportJson(String range) { + String normalizedRange = range != null && !range.isBlank() ? range : "30d"; + + long totalServers = serverMetricsRepository.countAll(); + ServerMetricsRepository.UsageTotals totals = serverMetricsRepository.getUsageTotals(); - long totalServers = serverRepository.countAll(); - ServerMongoRepository.UsageTotals totals = serverRepository.getUsageTotals(); - - return Map.of( - "exportDate", new Date().toString(), - "range", normalizedRange, - "data", Map.of( - "servers", totalServers, - "users", totals.totalUsers(), - "tickets", totals.totalTickets() - ) - ); + return new AdminAnalyticsExport( + new Date().toString(), + normalizedRange, + totalServers, + totals.totalUsers(), + totals.totalTickets()); } private String buildCsv(Date startDate) { - List servers = serverRepository.aggregateHistoricalMetric("servers", startDate); - List users = serverRepository.aggregateHistoricalMetric("users", startDate); - List tickets = serverRepository.aggregateHistoricalMetric("tickets", startDate); + List servers = serverMetricsRepository.aggregateHistoricalMetric("servers", startDate); + List users = serverMetricsRepository.aggregateHistoricalMetric("users", startDate); + List tickets = serverMetricsRepository.aggregateHistoricalMetric("tickets", startDate); TreeMap byDate = new TreeMap<>(); for (DateValueResult r : servers) { @@ -270,4 +247,12 @@ private String buildCsv(Date startDate) { } return sb.toString(); } + + private static List toNameCounts(List results) { + return results.stream().map(r -> new AdminNameCount(r.name(), r.value())).toList(); + } + + private static List toRegistrationPoints(List results) { + return results.stream().map(r -> new AdminRegistrationPoint(r.date(), r.servers())).toList(); + } } diff --git a/src/main/java/gg/modl/backend/admin/service/AdminAuthService.java b/src/main/java/gg/modl/backend/admin/service/AdminAuthService.java index 4bfbb9f..eb679e0 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminAuthService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminAuthService.java @@ -13,6 +13,7 @@ import java.util.LinkedHashSet; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -77,7 +78,7 @@ public Set extractSessionIds(HttpServletRequest request) { .filter(cookie -> RESTSecurityRole.ADMIN_SESSION_COOKIE.equals(cookie.getName())) .map(Cookie::getValue) .filter(value -> value != null && !value.isBlank()) - .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + .collect(Collectors.toCollection(LinkedHashSet::new)); } public boolean isAdminSessionExpired(AuthSessionData session) { diff --git a/src/main/java/gg/modl/backend/admin/service/AdminMonitoringService.java b/src/main/java/gg/modl/backend/admin/service/AdminMonitoringService.java index f2fc6cd..44836db 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminMonitoringService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminMonitoringService.java @@ -1,297 +1,253 @@ -package gg.modl.backend.admin.service; - -import gg.modl.backend.admin.data.SystemLog; -import gg.modl.backend.admin.dto.request.CreateSystemLogRequest; -import gg.modl.backend.admin.dto.request.ResolveLogRequest; -import gg.modl.backend.database.mongo.repository.GlobalMongoAdminRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.database.mongo.repository.SystemLogMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository.MonitoringServerStats; -import gg.modl.backend.database.mongo.repository.SystemLogMongoRepository.MonitoringLogStats; -import gg.modl.backend.server.data.ProvisioningStatus; -import gg.modl.backend.infrastructure.util.CsvUtil; -import gg.modl.backend.infrastructure.util.DateRangeUtil; -import gg.modl.backend.infrastructure.util.PaginationHelper; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class AdminMonitoringService { - private final SystemLogMongoRepository systemLogRepository; - private final ServerMongoRepository serverRepository; - private final GlobalMongoAdminRepository globalMongoAdminRepository; - - public Map getDashboard() { - Date oneDayAgo = Date.from(Instant.now().minus(1, ChronoUnit.DAYS)); - Date oneWeekAgo = Date.from(Instant.now().minus(7, ChronoUnit.DAYS)); - Date fiveMinutesAgo = Date.from(Instant.now().minus(5, ChronoUnit.MINUTES)); - - MonitoringServerStats serverStats = serverRepository.aggregateMonitoringServerStats(fiveMinutesAgo, oneWeekAgo); - MonitoringLogStats logStats = systemLogRepository.aggregateMonitoringLogStats(oneDayAgo); - - int healthScore = calculateHealthScore( - serverStats.total(), - serverStats.active(), - serverStats.failed(), - logStats.critical24h(), - logStats.error24h(), - logStats.unresolvedCritical(), - logStats.unresolvedError() - ); - String healthStatus = healthScore >= 95 ? "excellent" - : healthScore >= 85 ? "good" - : healthScore >= 70 ? "fair" - : "poor"; - - return Map.of( - "success", true, - "data", Map.of( - "servers", Map.of( - "total", serverStats.total(), - "active", serverStats.active(), - "pending", serverStats.pending(), - "failed", serverStats.failed(), - "recentRegistrations", serverStats.recentRegistrations(), - "concurrentServers", serverStats.concurrent(), - "concurrentPlayers", serverStats.concurrentPlayers() - ), - "logs", Map.of( - "last24h", Map.of( - "total", logStats.total24h(), - "critical", logStats.critical24h(), - "error", logStats.error24h(), - "warning", logStats.warning24h() - ), - "unresolved", Map.of( - "critical", logStats.unresolvedCritical(), - "error", logStats.unresolvedError() - ) - ), - "systemHealth", Map.of("score", healthScore, "status", healthStatus), - "trends", systemLogRepository.findLogTrends(oneWeekAgo), - "lastUpdated", new Date() - ) - ); - } - - private int calculateHealthScore(long total, long active, long failed, long critical, long errors, long unresolvedCritical, long unresolvedErrors) { - int score = 100; - if (total > 0) { - score -= (int) ((failed / (double) total) * 30); - } - score -= (int) Math.min(critical * 5, 25); - score -= (int) Math.min(errors, 20); - score -= (int) (unresolvedCritical * 10); - score -= (int) (unresolvedErrors * 3); - return Math.max(0, score); - } - - public Map getLogs( - int page, - int limit, - String level, - String source, - String serverId, - String category, - String resolved, - String search, - String startDate, - String endDate, - String sort, - String order - ) { - int pageNum = PaginationHelper.normalizePage(page); - int limitNum = PaginationHelper.normalizeLimit(limit, 100); - int skip = PaginationHelper.calculateSkip(page, limitNum); - Date start = DateRangeUtil.parseEpochMillis(startDate); - Date end = DateRangeUtil.parseEpochMillis(endDate); - - List logs = systemLogRepository.findLogs( - level, - source, - serverId, - category, - resolved, - search, - start, - end, - sort, - order, - skip, - limitNum - ); - long total = systemLogRepository.countLogs(level, source, serverId, category, resolved, search, start, end); - - Map filters = new LinkedHashMap<>(); - filters.put("level", level); - filters.put("source", source); - filters.put("serverId", serverId); - filters.put("category", category); - filters.put("resolved", resolved); - filters.put("search", search); - - return Map.of( - "success", true, - "data", Map.of( - "logs", logs, - "pagination", Map.of( - "page", pageNum, - "limit", limitNum, - "total", total, - "pages", PaginationHelper.calculateTotalPages(total, limitNum) - ), - "filters", filters - ) - ); - } - - public SystemLog createLog(CreateSystemLogRequest request) { - SystemLog logData = request.toSystemLog(); - logData.setTimestamp(new Date()); - return systemLogRepository.saveEntity(logData); - } - - public Map getSources() { - List sources = systemLogRepository.findDistinctSources(); - List categories = systemLogRepository.findDistinctCategories(); - - sources.removeIf(Objects::isNull); - categories.removeIf(Objects::isNull); - - return Map.of( - "success", true, - "data", Map.of( - "sources", sources, - "categories", categories - ) - ); - } - - public Optional resolveLog(String id, ResolveLogRequest request) { - return Optional.ofNullable(systemLogRepository.resolveById( - id, - request.resolvedBy() != null ? request.resolvedBy() : "admin", - new Date() - )); - } - - public Map getHealth() { - List> checks = new ArrayList<>(); - String overallStatus = "healthy"; - - try { - long start = System.currentTimeMillis(); - globalMongoAdminRepository.ping(); - long responseTime = System.currentTimeMillis() - start; - checks.add(Map.of( - "name", "Database Connectivity", - "status", "healthy", - "message", "MongoDB connection is responsive.", - "responseTime", responseTime - )); - } catch (Exception exception) { - checks.add(Map.of( - "name", "Database Connectivity", - "status", "critical", - "message", "Failed to ping MongoDB.", - "error", exception.getMessage() - )); - overallStatus = "critical"; - } - - long criticalCount = systemLogRepository.countUnresolvedByLevelSince( - "critical", - Date.from(Instant.now().minus(1, ChronoUnit.DAYS)) - ); - String logStatus = criticalCount > 5 ? "critical" : criticalCount > 0 ? "degraded" : "healthy"; - checks.add(Map.of( - "name", "Critical System Logs", - "status", logStatus, - "message", criticalCount + " unresolved critical log(s) in the last 24 hours.", - "count", criticalCount - )); - if ("critical".equals(logStatus)) { - overallStatus = "critical"; - } else if ("degraded".equals(logStatus) && !"critical".equals(overallStatus)) { - overallStatus = "degraded"; - } - - long failedCount = serverRepository.countByProvisioningStatus(ProvisioningStatus.FAILED); - String serverStatus = failedCount > 0 ? "degraded" : "healthy"; - checks.add(Map.of( - "name", "Server Provisioning", - "status", serverStatus, - "message", failedCount + " server(s) failed to provision.", - "count", failedCount - )); - if ("degraded".equals(serverStatus) && !"critical".equals(overallStatus)) { - overallStatus = "degraded"; - } - - return Map.of( - "success", true, - "data", Map.of( - "status", overallStatus, - "checks", checks, - "timestamp", new Date() - ) - ); - } - - public long deleteLogs(List logIds) { - return systemLogRepository.deleteByIds(logIds); - } - - public String exportLogs( - String level, - String source, - String category, - String resolved, - String search, - String startDate, - String endDate - ) { - List logs = systemLogRepository.findLogsForExport( - DateRangeUtil.normalizeAllFilter(level), - DateRangeUtil.normalizeAllFilter(source), - null, - DateRangeUtil.normalizeAllFilter(category), - DateRangeUtil.normalizeAllFilter(resolved), - search, - DateRangeUtil.parseEpochMillis(startDate), - DateRangeUtil.parseEpochMillis(endDate), - 10000 - ); - - StringBuilder csv = new StringBuilder(CsvUtil.row("Timestamp", "Level", "Source", "Category", "Message", "Resolved", "Resolved By")); - for (SystemLog logEntry : logs) { - csv.append(CsvUtil.row( - logEntry.getTimestamp(), - logEntry.getLevel(), - logEntry.getSource(), - logEntry.getCategory(), - logEntry.getMessage(), - logEntry.isResolved() ? "Yes" : "No", - logEntry.getResolvedBy())); - } - return csv.toString(); - } - - public long clearAllLogs() { - long deletedCount = systemLogRepository.deleteAllLogs(); - log.info("All system logs cleared by admin"); - return deletedCount; - } -} +package gg.modl.backend.admin.service; + +import gg.modl.backend.admin.data.SystemLog; +import gg.modl.backend.admin.dto.request.CreateSystemLogRequest; +import gg.modl.backend.admin.dto.request.ResolveLogRequest; +import gg.modl.backend.admin.dto.response.AdminMonitoringDashboard; +import gg.modl.backend.admin.dto.response.AdminMonitoringHealth; +import gg.modl.backend.admin.dto.response.AdminMonitoringLogs; +import gg.modl.backend.admin.dto.response.AdminMonitoringSources; +import gg.modl.backend.admin.dto.response.AdminPagination; +import gg.modl.backend.database.mongo.repository.GlobalMongoAdminRepository; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository; +import gg.modl.backend.database.mongo.repository.SystemLogMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository.MonitoringServerStats; +import gg.modl.backend.database.mongo.repository.SystemLogMongoRepository.MonitoringLogStats; +import gg.modl.backend.server.data.ProvisioningStatus; +import gg.modl.backend.infrastructure.util.CsvUtil; +import gg.modl.backend.infrastructure.util.DateRangeUtil; +import gg.modl.backend.infrastructure.util.PaginationHelper; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AdminMonitoringService { + private final SystemLogMongoRepository systemLogRepository; + private final ServerMetricsRepository serverMetricsRepository; + private final GlobalMongoAdminRepository globalMongoAdminRepository; + + public AdminMonitoringDashboard getDashboard() { + Date oneDayAgo = Date.from(Instant.now().minus(1, ChronoUnit.DAYS)); + Date oneWeekAgo = Date.from(Instant.now().minus(7, ChronoUnit.DAYS)); + Date fiveMinutesAgo = Date.from(Instant.now().minus(5, ChronoUnit.MINUTES)); + + MonitoringServerStats serverStats = serverMetricsRepository.aggregateMonitoringServerStats(fiveMinutesAgo, oneWeekAgo); + MonitoringLogStats logStats = systemLogRepository.aggregateMonitoringLogStats(oneDayAgo); + + int healthScore = calculateHealthScore(serverStats, logStats); + String healthStatus = healthScore >= 95 ? "excellent" + : healthScore >= 85 ? "good" + : healthScore >= 70 ? "fair" + : "poor"; + + List> trends = new ArrayList<>(systemLogRepository.findLogTrends(oneWeekAgo)); + + return new AdminMonitoringDashboard( + new AdminMonitoringDashboard.ServerMetrics( + serverStats.total(), + serverStats.active(), + serverStats.pending(), + serverStats.failed(), + serverStats.recentRegistrations(), + serverStats.concurrent(), + serverStats.concurrentPlayers()), + new AdminMonitoringDashboard.LogMetrics( + new AdminMonitoringDashboard.LogWindow( + logStats.total24h(), + logStats.critical24h(), + logStats.error24h(), + logStats.warning24h()), + new AdminMonitoringDashboard.UnresolvedLogs( + logStats.unresolvedCritical(), + logStats.unresolvedError())), + new AdminMonitoringDashboard.SystemHealth(healthScore, healthStatus), + trends, + new Date()); + } + + private int calculateHealthScore(MonitoringServerStats serverStats, MonitoringLogStats logStats) { + int score = 100; + if (serverStats.total() > 0) { + score -= (int) ((serverStats.failed() / (double) serverStats.total()) * 30); + } + score -= (int) Math.min(logStats.critical24h() * 5, 25); + score -= (int) Math.min(logStats.error24h(), 20); + score -= (int) (logStats.unresolvedCritical() * 10); + score -= (int) (logStats.unresolvedError() * 3); + return Math.max(0, score); + } + + public AdminMonitoringLogs getLogs( + int page, + int limit, + String level, + String source, + String serverId, + String category, + String resolved, + String search, + String startDate, + String endDate, + String sort, + String order + ) { + int pageNum = PaginationHelper.normalizePage(page); + int limitNum = PaginationHelper.normalizeLimit(limit, 100); + int skip = PaginationHelper.calculateSkip(page, limitNum); + Date start = DateRangeUtil.parseEpochMillis(startDate); + Date end = DateRangeUtil.parseEpochMillis(endDate); + + List logs = systemLogRepository.findLogs( + level, + source, + serverId, + category, + resolved, + search, + start, + end, + sort, + order, + skip, + limitNum + ); + long total = systemLogRepository.countLogs(level, source, serverId, category, resolved, search, start, end); + + return new AdminMonitoringLogs( + logs, + new AdminPagination(pageNum, limitNum, total, PaginationHelper.calculateTotalPages(total, limitNum)), + new AdminMonitoringLogs.Filters(level, source, serverId, category, resolved, search)); + } + + public SystemLog createLog(CreateSystemLogRequest request) { + SystemLog logData = request.toSystemLog(); + logData.setTimestamp(new Date()); + return systemLogRepository.saveEntity(logData); + } + + public AdminMonitoringSources getSources() { + List sources = systemLogRepository.findDistinctSources(); + List categories = systemLogRepository.findDistinctCategories(); + + sources.removeIf(Objects::isNull); + categories.removeIf(Objects::isNull); + + return new AdminMonitoringSources(sources, categories); + } + + public Optional resolveLog(String id, ResolveLogRequest request) { + return Optional.ofNullable(systemLogRepository.resolveById( + id, + request.resolvedBy() != null ? request.resolvedBy() : "admin", + new Date() + )); + } + + public AdminMonitoringHealth getHealth() { + List checks = new ArrayList<>(); + String overallStatus = "healthy"; + + try { + long start = System.currentTimeMillis(); + globalMongoAdminRepository.ping(); + long responseTime = System.currentTimeMillis() - start; + checks.add(AdminMonitoringHealth.HealthCheck.responsive( + "Database Connectivity", + "healthy", + "MongoDB connection is responsive.", + responseTime)); + } catch (Exception exception) { + checks.add(AdminMonitoringHealth.HealthCheck.failure( + "Database Connectivity", + "critical", + "Failed to ping MongoDB.", + exception.getMessage())); + overallStatus = "critical"; + } + + long criticalCount = systemLogRepository.countUnresolvedByLevelSince( + "critical", + Date.from(Instant.now().minus(1, ChronoUnit.DAYS)) + ); + String logStatus = criticalCount > 5 ? "critical" : criticalCount > 0 ? "degraded" : "healthy"; + checks.add(AdminMonitoringHealth.HealthCheck.counted( + "Critical System Logs", + logStatus, + criticalCount + " unresolved critical log(s) in the last 24 hours.", + criticalCount)); + if ("critical".equals(logStatus)) { + overallStatus = "critical"; + } else if ("degraded".equals(logStatus) && !"critical".equals(overallStatus)) { + overallStatus = "degraded"; + } + + long failedCount = serverMetricsRepository.countByProvisioningStatus(ProvisioningStatus.FAILED); + String serverStatus = failedCount > 0 ? "degraded" : "healthy"; + checks.add(AdminMonitoringHealth.HealthCheck.counted( + "Server Provisioning", + serverStatus, + failedCount + " server(s) failed to provision.", + failedCount)); + if ("degraded".equals(serverStatus) && !"critical".equals(overallStatus)) { + overallStatus = "degraded"; + } + + return new AdminMonitoringHealth(overallStatus, checks, new Date()); + } + + public long deleteLogs(List logIds) { + return systemLogRepository.deleteByIds(logIds); + } + + public String exportLogs( + String level, + String source, + String category, + String resolved, + String search, + String startDate, + String endDate + ) { + List logs = systemLogRepository.findLogsForExport( + DateRangeUtil.normalizeAllFilter(level), + DateRangeUtil.normalizeAllFilter(source), + null, + DateRangeUtil.normalizeAllFilter(category), + DateRangeUtil.normalizeAllFilter(resolved), + search, + DateRangeUtil.parseEpochMillis(startDate), + DateRangeUtil.parseEpochMillis(endDate), + 10000 + ); + + StringBuilder csv = new StringBuilder(CsvUtil.row("Timestamp", "Level", "Source", "Category", "Message", "Resolved", "Resolved By")); + for (SystemLog logEntry : logs) { + csv.append(CsvUtil.row( + logEntry.getTimestamp(), + logEntry.getLevel(), + logEntry.getSource(), + logEntry.getCategory(), + logEntry.getMessage(), + logEntry.isResolved() ? "Yes" : "No", + logEntry.getResolvedBy())); + } + return csv.toString(); + } + + public long clearAllLogs() { + long deletedCount = systemLogRepository.deleteAllLogs(); + log.info("All system logs cleared by admin"); + return deletedCount; + } +} diff --git a/src/main/java/gg/modl/backend/admin/service/AdminSecurityService.java b/src/main/java/gg/modl/backend/admin/service/AdminSecurityService.java index 219d7bd..e16149e 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminSecurityService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminSecurityService.java @@ -1,75 +1,60 @@ -package gg.modl.backend.admin.service; - -import gg.modl.backend.admin.data.SecurityEvent; -import gg.modl.backend.database.mongo.repository.SecurityEventMongoRepository; -import gg.modl.backend.infrastructure.util.DateRangeUtil; -import gg.modl.backend.infrastructure.util.PaginationHelper; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Date; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AdminSecurityService { - private final SecurityEventMongoRepository securityEventRepository; - - public Map getSecurityEvents( - int page, - int limit, - String type, - String severity, - String source, - String search, - String startDate, - String endDate - ) { - int pageNum = PaginationHelper.normalizePage(page); - int limitNum = PaginationHelper.normalizeLimit(limit, 100); - int skip = PaginationHelper.calculateSkip(page, limitNum); - Date start = DateRangeUtil.parseEpochMillis(startDate); - Date end = DateRangeUtil.parseEpochMillis(endDate); - - List events = securityEventRepository.findSecurityEvents(type, severity, source, search, start, end, skip, limitNum); - long total = securityEventRepository.countSecurityEvents(type, severity, source, search, start, end); - - return Map.of( - "success", true, - "data", Map.of( - "events", events, - "pagination", Map.of( - "page", pageNum, - "limit", limitNum, - "total", total, - "pages", PaginationHelper.calculateTotalPages(total, limitNum) - ) - ) - ); - } - - public Map getSecuritySummary() { - Date last24h = Date.from(Instant.now().minus(24, ChronoUnit.HOURS)); - Date last7d = Date.from(Instant.now().minus(7, ChronoUnit.DAYS)); - - long criticalEvents24h = securityEventRepository.countBySeveritySince("critical", last24h); - long highEvents24h = securityEventRepository.countBySeveritySince("high", last24h); - long mediumEvents24h = securityEventRepository.countBySeveritySince("medium", last24h); - long totalEvents7d = securityEventRepository.countSince(last7d); - - return Map.of( - "success", true, - "data", Map.of( - "last24Hours", Map.of( - "critical", criticalEvents24h, - "high", highEvents24h, - "medium", mediumEvents24h - ), - "last7Days", Map.of("total", totalEvents7d), - "timestamp", new Date() - ) - ); - } -} +package gg.modl.backend.admin.service; + +import gg.modl.backend.admin.data.SecurityEvent; +import gg.modl.backend.admin.dto.response.AdminPagination; +import gg.modl.backend.admin.dto.response.AdminSecurityEvents; +import gg.modl.backend.admin.dto.response.AdminSecuritySummary; +import gg.modl.backend.database.mongo.repository.SecurityEventMongoRepository; +import gg.modl.backend.infrastructure.util.DateRangeUtil; +import gg.modl.backend.infrastructure.util.PaginationHelper; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminSecurityService { + private final SecurityEventMongoRepository securityEventRepository; + + public AdminSecurityEvents getSecurityEvents( + int page, + int limit, + String type, + String severity, + String source, + String search, + String startDate, + String endDate + ) { + int pageNum = PaginationHelper.normalizePage(page); + int limitNum = PaginationHelper.normalizeLimit(limit, 100); + int skip = PaginationHelper.calculateSkip(page, limitNum); + Date start = DateRangeUtil.parseEpochMillis(startDate); + Date end = DateRangeUtil.parseEpochMillis(endDate); + + List events = securityEventRepository.findSecurityEvents(type, severity, source, search, start, end, skip, limitNum); + long total = securityEventRepository.countSecurityEvents(type, severity, source, search, start, end); + + return new AdminSecurityEvents( + events, + new AdminPagination(pageNum, limitNum, total, PaginationHelper.calculateTotalPages(total, limitNum))); + } + + public AdminSecuritySummary getSecuritySummary() { + Date last24h = Date.from(Instant.now().minus(24, ChronoUnit.HOURS)); + Date last7d = Date.from(Instant.now().minus(7, ChronoUnit.DAYS)); + + long criticalEvents24h = securityEventRepository.countBySeveritySince("critical", last24h); + long highEvents24h = securityEventRepository.countBySeveritySince("high", last24h); + long mediumEvents24h = securityEventRepository.countBySeveritySince("medium", last24h); + long totalEvents7d = securityEventRepository.countSince(last7d); + + return new AdminSecuritySummary( + new AdminSecuritySummary.Last24Hours(criticalEvents24h, highEvents24h, mediumEvents24h), + totalEvents7d, + new Date()); + } +} diff --git a/src/main/java/gg/modl/backend/admin/service/AdminServerService.java b/src/main/java/gg/modl/backend/admin/service/AdminServerService.java index 6dc0388..efe7458 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminServerService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminServerService.java @@ -1,268 +1,332 @@ -package gg.modl.backend.admin.service; - -import gg.modl.backend.database.mongo.repository.ServerDatabaseMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.email.EmailAddressUtil; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.util.CsvUtil; -import gg.modl.backend.server.ServerService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.service.ServerProvisioningService; -import gg.modl.backend.staff.service.StaffService; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class AdminServerService { - private final ServerMongoRepository serverRepository; - private final ServerDatabaseMongoRepository serverDatabaseRepository; - private final ServerProvisioningService provisioningService; - private final ServerService serverService; - private final StaffService staffService; - private static final long USAGE_STATS_TTL_MILLIS = 10 * 60 * 1000L; - private static final int MAX_USAGE_BATCH_SIZE = 50; - - public long countServers(String search, String plan, String status) { - return serverRepository.countAdminServers(search, plan, status); - } - - @Async - public void refreshUsageStatsForActiveServers(int maxServers) { - int boundedLimit = Math.max(1, Math.min(maxServers, 500)); - Date now = new Date(); - Date staleCutoff = new Date(now.getTime() - USAGE_STATS_TTL_MILLIS); - - List servers = serverRepository.findUsageRefreshCandidates(staleCutoff, boundedLimit); - for (Server server : servers) { - getOrComputeUsageStats(server, now, false); - } - } - - private ComputedUsage getOrComputeUsageStats(Server server, Date now, boolean forceRefresh) { - long cachedUsers = server.getUserCount() != null ? server.getUserCount() : 0L; - long cachedTickets = server.getTicketCount() != null ? server.getTicketCount() : 0L; - - if (!forceRefresh && isUsageStatsCacheFresh(server, now)) { - return new ComputedUsage(cachedUsers, cachedTickets, server.getLastStatsUpdatedAt(), true); - } - - if (server.getDatabaseName() == null || server.getDatabaseName().isBlank()) { - persistUsageStats(server.getId(), cachedUsers, cachedTickets, now); - return new ComputedUsage(cachedUsers, cachedTickets, now, false); - } - - Optional usageCounts = serverDatabaseRepository.readUsageCounts(server); - if (usageCounts.isPresent()) { - persistUsageStats(server.getId(), usageCounts.get().players(), usageCounts.get().tickets(), now); - return new ComputedUsage(usageCounts.get().players(), usageCounts.get().tickets(), now, false); - } - - log.warn("Failed to refresh usage stats for server {}", server.getServerName()); - Date updatedAt = server.getLastStatsUpdatedAt() != null ? server.getLastStatsUpdatedAt() : now; - return new ComputedUsage(cachedUsers, cachedTickets, updatedAt, true); - } - - private boolean isUsageStatsCacheFresh(Server server, Date now) { - if (server.getLastStatsUpdatedAt() == null) { - return false; - } - - long ageMillis = now.getTime() - server.getLastStatsUpdatedAt().getTime(); - return ageMillis >= 0 && ageMillis <= USAGE_STATS_TTL_MILLIS; - } - - private void persistUsageStats(String serverId, long userCount, long ticketCount, Date updatedAt) { - serverRepository.updateUsageStats(serverId, userCount, ticketCount, updatedAt); - } - - public Map getUsageStatsForServerIds(List serverIds, boolean forceRefresh) { - if (serverIds == null || serverIds.isEmpty()) { - return Map.of(); - } - - List filteredIds = serverIds.stream() - .filter(Objects::nonNull) - .map(String::trim) - .filter(id -> !id.isEmpty()) - .distinct() - .limit(MAX_USAGE_BATCH_SIZE) - .toList(); - - if (filteredIds.isEmpty()) { - return Map.of(); - } - - Date now = new Date(); - List servers = serverRepository.findUsageTargetsByIds(filteredIds); - Map usageByServerId = new HashMap<>(); - - for (Server server : servers) { - ComputedUsage usage = getOrComputeUsageStats(server, now, forceRefresh); - usageByServerId.put(server.getId(), new UsageSummary( - usage.userCount(), - usage.ticketCount(), - usage.updatedAt(), - usage.fromCache() - )); - } - - return usageByServerId; - } - - public Server createServer(String serverName, String customDomain, String adminEmail, String plan) { - ServerPlan serverPlan = plan != null ? ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)) : ServerPlan.FREE; - return serverService.createServer(serverName, customDomain, adminEmail, null, serverPlan); - } - - public void changeAdminEmail(Server server, String newAdminEmail) { - String normalizedEmail = EmailAddressUtil.normalizeIfValid(newAdminEmail); - if (normalizedEmail == null) { - throw new ValidationException("A valid admin email is required"); - } - if (serverService.isAdminEmailInUse(normalizedEmail, server.getId())) { - throw new ValidationException("Admin email is already in use by another server"); - } - String previousAdminEmail = server.getAdminEmail(); - serverService.changeAdminEmail(server, normalizedEmail); - if (previousAdminEmail != null && !previousAdminEmail.equalsIgnoreCase(normalizedEmail) - && server.getDatabaseName() != null && !server.getDatabaseName().isBlank()) { - staffService.offboardPreviousAdminEmail(server, previousAdminEmail); - } - } - - public String exportServersCsv(String plan, String status) { - List servers = findServers(null, plan, status, "createdAt", "desc", 0, 10000); - StringBuilder csv = new StringBuilder(); - csv.append(CsvUtil.row("id", "serverName", "customDomain", "adminEmail", "plan", "provisioningStatus", "emailVerified", "createdAt")); - for (Server s : servers) { - csv.append(CsvUtil.row( - s.getId(), s.getServerName(), s.getCustomDomain(), s.getAdminEmail(), - s.getPlan(), s.getProvisioningStatus(), s.getEmailVerified(), s.getCreatedAt())); - } - return csv.toString(); - } - - public List findServers(String search, String plan, String status, String sortField, String sortOrder, int skip, int limit) { - return serverRepository.findAdminServers(search, plan, status, sortField, sortOrder, skip, limit); - } - - public Optional findById(String id) { - return serverRepository.findById(id); - } - - public Server save(Server server) { - Server saved = serverRepository.saveEntity(server); - serverService.evictAllServerCaches(); - return saved; - } - - public Server updateById(String id, Map updateData) { - return serverRepository.updateAllowedFields(id, updateData).orElse(null); - } - - public boolean deleteById(String id) { - return serverRepository.deleteByServerId(id); - } - - public long bulkDelete(List serverIds) { - return serverRepository.deleteByServerIds(serverIds); - } - - public long bulkSuspend(List serverIds) { - return serverRepository.bulkSuspend(serverIds, new Date()); - } - - public long bulkActivate(List serverIds) { - long modified = serverRepository.bulkActivate(serverIds, new Date()); - - List servers = serverRepository.findProvisioningCandidatesByIds(serverIds); - for (Server server : servers) { - if (server.getDatabaseName() == null) { - continue; - } - try { - provisioningService.provision(server); - serverRepository.markProvisioningCompleted(server.getId()); - } catch (Exception e) { - log.warn("Failed to provision server {}", server.getId(), e); - serverRepository.markProvisioningFailed(server.getId(), "Admin reprovision failed."); - } - } - - serverService.evictAllServerCaches(); - - return modified; - } - - public long bulkUpdatePlan(List serverIds, String plan) { - ServerPlan parsedPlan = ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)); - return serverRepository.bulkUpdatePlan(serverIds, parsedPlan, new Date()); - } - - public Map getServerStats(Server server) { - Map stats = new HashMap<>(); - Date now = new Date(); - - if (server.getDatabaseName() == null || server.getDatabaseName().isBlank()) { - long cachedUsers = server.getUserCount() != null ? server.getUserCount() : 0L; - long cachedTickets = server.getTicketCount() != null ? server.getTicketCount() : 0L; - stats.put("totalPlayers", cachedUsers); - stats.put("totalTickets", cachedTickets); - stats.put("totalLogs", 0); - stats.put("lastActivity", server.getUpdatedAt()); - stats.put("databaseSize", 0); - return stats; - } - - Optional databaseStats = serverDatabaseRepository.readStats(server); - if (databaseStats.isPresent()) { - ServerDatabaseMongoRepository.ServerDatabaseStats loadedStats = databaseStats.get(); - persistUsageStats(server.getId(), loadedStats.players(), loadedStats.tickets(), now); - - stats.put("totalPlayers", loadedStats.players()); - stats.put("totalTickets", loadedStats.tickets()); - stats.put("totalLogs", loadedStats.logs()); - stats.put("lastActivity", server.getLastActivityAt() != null ? server.getLastActivityAt() : server.getUpdatedAt()); - stats.put("databaseSize", loadedStats.storageSize()); - return stats; - } - - log.warn("Failed to get stats for server {}", server.getServerName()); - long cachedUsers = server.getUserCount() != null ? server.getUserCount() : 0L; - long cachedTickets = server.getTicketCount() != null ? server.getTicketCount() : 0L; - stats.put("totalPlayers", cachedUsers); - stats.put("totalTickets", cachedTickets); - stats.put("totalLogs", 0); - stats.put("lastActivity", server.getUpdatedAt()); - stats.put("databaseSize", 0); - return stats; - } - - public void resetServerDatabase(Server server) { - if (server.getDatabaseName() != null) { - if (serverDatabaseRepository.dropDatabase(server)) { - log.info("Dropped database {} for server {}", server.getDatabaseName(), server.getServerName()); - } else { - log.warn("Failed to drop database {}", server.getDatabaseName()); - } - } - - serverRepository.resetAfterDatabaseDrop(server.getId(), new Date()); - } - - private record ComputedUsage(long userCount, long ticketCount, Date updatedAt, boolean fromCache) {} - - public record UsageSummary(long userCount, long ticketCount, Date updatedAt, boolean fromCache) {} -} +package gg.modl.backend.admin.service; + +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.database.mongo.repository.ServerAdminRepository; +import gg.modl.backend.database.mongo.repository.ServerDatabaseMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerProvisioningRepository; +import gg.modl.backend.database.mongo.repository.ServerUsageRepository; +import gg.modl.backend.email.EmailAddressUtil; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.util.CsvUtil; +import gg.modl.backend.server.ServerService; +import gg.modl.backend.server.data.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import gg.modl.backend.server.service.ServerProvisioningService; +import gg.modl.backend.staff.service.StaffService; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AdminServerService { + private final ServerAdminRepository serverAdminRepository; + private final ServerProvisioningRepository serverProvisioningRepository; + private final ServerUsageRepository serverUsageRepository; + private final ServerDatabaseMongoRepository serverDatabaseRepository; + private final ServerProvisioningService provisioningService; + private final ServerService serverService; + private final StaffService staffService; + private static final long USAGE_STATS_TTL_MILLIS = 10 * 60 * 1000L; + private static final int MAX_USAGE_BATCH_SIZE = 50; + + public long countServers(String search, String plan, String status) { + return serverAdminRepository.countAdminServers(search, plan, status); + } + + @Async + public void refreshUsageStatsForActiveServers(int maxServers) { + int boundedLimit = Math.max(1, Math.min(maxServers, 500)); + Date now = new Date(); + Date staleCutoff = new Date(now.getTime() - USAGE_STATS_TTL_MILLIS); + + List servers = serverUsageRepository.findUsageRefreshCandidates(staleCutoff, boundedLimit); + for (Server server : servers) { + getOrComputeUsageStats(server, now, false); + } + } + + private ComputedUsage getOrComputeUsageStats(Server server, Date now, boolean forceRefresh) { + long cachedUsers = server.getUserCount() != null ? server.getUserCount() : 0L; + long cachedTickets = server.getTicketCount() != null ? server.getTicketCount() : 0L; + + if (!forceRefresh && isUsageStatsCacheFresh(server, now)) { + return new ComputedUsage(cachedUsers, cachedTickets, server.getLastStatsUpdatedAt(), true); + } + + if (server.getDatabaseName() == null || server.getDatabaseName().isBlank()) { + persistUsageStats(server.getId(), cachedUsers, cachedTickets, now); + return new ComputedUsage(cachedUsers, cachedTickets, now, false); + } + + Optional usageCounts = serverDatabaseRepository.readUsageCounts(server); + if (usageCounts.isPresent()) { + persistUsageStats(server.getId(), usageCounts.get().players(), usageCounts.get().tickets(), now); + return new ComputedUsage(usageCounts.get().players(), usageCounts.get().tickets(), now, false); + } + + log.warn("Failed to refresh usage stats for server {}", server.getServerName()); + Date updatedAt = server.getLastStatsUpdatedAt() != null ? server.getLastStatsUpdatedAt() : now; + return new ComputedUsage(cachedUsers, cachedTickets, updatedAt, true); + } + + private boolean isUsageStatsCacheFresh(Server server, Date now) { + if (server.getLastStatsUpdatedAt() == null) { + return false; + } + + long ageMillis = now.getTime() - server.getLastStatsUpdatedAt().getTime(); + return ageMillis >= 0 && ageMillis <= USAGE_STATS_TTL_MILLIS; + } + + private void persistUsageStats(String serverId, long userCount, long ticketCount, Date updatedAt) { + serverUsageRepository.updateUsageStats(serverId, userCount, ticketCount, updatedAt); + } + + public Map getUsageStatsForServerIds(List serverIds, boolean forceRefresh) { + if (serverIds == null || serverIds.isEmpty()) { + return Map.of(); + } + + List filteredIds = serverIds.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(id -> !id.isEmpty()) + .distinct() + .limit(MAX_USAGE_BATCH_SIZE) + .toList(); + + if (filteredIds.isEmpty()) { + return Map.of(); + } + + Date now = new Date(); + List servers = serverUsageRepository.findUsageTargetsByIds(filteredIds); + Map usageByServerId = new HashMap<>(); + + for (Server server : servers) { + ComputedUsage usage = getOrComputeUsageStats(server, now, forceRefresh); + usageByServerId.put(server.getId(), new UsageSummary( + usage.userCount(), + usage.ticketCount(), + usage.updatedAt(), + usage.fromCache() + )); + } + + return usageByServerId; + } + + public Server createServer(String serverName, String customDomain, String adminEmail, String plan) { + ServerPlan serverPlan = plan != null ? ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)) : ServerPlan.FREE; + return serverService.createServer(serverName, customDomain, adminEmail, null, serverPlan); + } + + public void changeAdminEmail(Server server, String newAdminEmail) { + String normalizedEmail = EmailAddressUtil.normalizeIfValid(newAdminEmail); + if (normalizedEmail == null) { + throw new ValidationException("A valid admin email is required"); + } + if (serverService.isAdminEmailInUse(normalizedEmail, server.getId())) { + throw new ValidationException("Admin email is already in use by another server"); + } + String previousAdminEmail = server.getAdminEmail(); + serverService.changeAdminEmail(server, normalizedEmail); + if (previousAdminEmail != null && !previousAdminEmail.equalsIgnoreCase(normalizedEmail) + && server.getDatabaseName() != null && !server.getDatabaseName().isBlank()) { + staffService.offboardPreviousAdminEmail(server, previousAdminEmail); + } + } + + public String exportServersCsv(String plan, String status) { + List servers = findServers(null, plan, status, "createdAt", "desc", 0, 10000); + StringBuilder csv = new StringBuilder(); + csv.append(CsvUtil.row("id", "serverName", "customDomain", "adminEmail", "plan", "provisioningStatus", "emailVerified", "createdAt")); + for (Server s : servers) { + csv.append(CsvUtil.row( + s.getId(), s.getServerName(), s.getCustomDomain(), s.getAdminEmail(), + s.getPlan(), s.getProvisioningStatus(), s.getEmailVerified(), s.getCreatedAt())); + } + return csv.toString(); + } + + public List findServers(String search, String plan, String status, String sortField, String sortOrder, int skip, int limit) { + return serverAdminRepository.findAdminServers(search, plan, status, sortField, sortOrder, skip, limit); + } + + public Optional findById(String id) { + return serverAdminRepository.findById(id); + } + + public Server updateById(String id, Map updateData) { + Update update = buildAllowedFieldsUpdate(updateData); + return serverAdminRepository.applyFieldUpdate(id, update).orElse(null); + } + + private Update buildAllowedFieldsUpdate(Map updateData) { + Update update = new Update(); + for (Map.Entry entry : updateData.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + if (value == null) { + continue; + } + switch (key) { + case ServerFields.ADMIN_EMAIL -> update.set(ServerFields.ADMIN_EMAIL, value); + case ServerFields.EMAIL_VERIFIED -> update.set(ServerFields.EMAIL_VERIFIED, value); + case ServerFields.PROVISIONING_STATUS -> update.set(ServerFields.PROVISIONING_STATUS, normalizeProvisioningStatus(value)); + case ServerFields.PROVISIONING_NOTES -> update.set(ServerFields.PROVISIONING_NOTES, value); + case ServerFields.PLAN -> update.set(ServerFields.PLAN, normalizePlan(value)); + case ServerFields.SUBSCRIPTION_STATUS -> update.set(ServerFields.SUBSCRIPTION_STATUS, normalizeSubscriptionStatus(value)); + case ServerFields.LAST_ACTIVITY_AT -> update.set(ServerFields.LAST_ACTIVITY_AT, normalizeDate(value)); + case ServerFields.UPDATED_AT -> update.set(ServerFields.UPDATED_AT, normalizeDate(value)); + default -> { + } + } + } + return update; + } + + private ServerPlan normalizePlan(Object value) { + if (value instanceof ServerPlan plan) { + return plan; + } + return ServerPlan.valueOf(String.valueOf(value).trim().toUpperCase(Locale.ROOT)); + } + + private ProvisioningStatus normalizeProvisioningStatus(Object value) { + if (value instanceof ProvisioningStatus provisioningStatus) { + return provisioningStatus; + } + return ProvisioningStatus.valueOf(String.valueOf(value).trim().toUpperCase(Locale.ROOT)); + } + + private SubscriptionStatus normalizeSubscriptionStatus(Object value) { + if (value instanceof SubscriptionStatus subscriptionStatus) { + return subscriptionStatus; + } + return SubscriptionStatus.valueOf(String.valueOf(value).trim().toUpperCase(Locale.ROOT)); + } + + private Date normalizeDate(Object value) { + if (value instanceof Date d) { + return d; + } + if (value instanceof Instant i) { + return Date.from(i); + } + if (value instanceof Number n) { + return new Date(n.longValue()); + } + if (value instanceof String s) { + return Date.from(Instant.parse(s.trim())); + } + throw new IllegalArgumentException("Unsupported value type for date field: " + + (value == null ? "null" : value.getClass())); + } + + public boolean deleteById(String id) { + return serverAdminRepository.deleteByServerId(id); + } + + public long bulkDelete(List serverIds) { + return serverAdminRepository.deleteByServerIds(serverIds); + } + + public long bulkSuspend(List serverIds) { + return serverAdminRepository.bulkSuspend(serverIds, new Date()); + } + + public long bulkActivate(List serverIds) { + long modified = serverAdminRepository.bulkActivate(serverIds, new Date()); + + List servers = serverProvisioningRepository.findProvisioningCandidatesByIds(serverIds); + for (Server server : servers) { + if (server.getDatabaseName() == null) { + continue; + } + try { + provisioningService.provision(server); + serverProvisioningRepository.markProvisioningCompleted(server.getId()); + } catch (Exception e) { + log.warn("Failed to provision server {}", server.getId(), e); + serverProvisioningRepository.markProvisioningFailed(server.getId(), Server.boundProvisioningNotes("Admin reprovision failed.")); + } + } + + serverService.evictAllServerCaches(); + + return modified; + } + + public long bulkUpdatePlan(List serverIds, String plan) { + ServerPlan parsedPlan = ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)); + return serverAdminRepository.bulkUpdatePlan(serverIds, parsedPlan, new Date()); + } + + public Map getServerStats(Server server) { + Map stats = new HashMap<>(); + Date now = new Date(); + + if (server.getDatabaseName() == null || server.getDatabaseName().isBlank()) { + putCachedStats(stats, server); + return stats; + } + + Optional databaseStats = serverDatabaseRepository.readStats(server); + if (databaseStats.isPresent()) { + ServerDatabaseMongoRepository.ServerDatabaseStats loadedStats = databaseStats.get(); + persistUsageStats(server.getId(), loadedStats.players(), loadedStats.tickets(), now); + + stats.put("totalPlayers", loadedStats.players()); + stats.put("totalTickets", loadedStats.tickets()); + stats.put("totalLogs", loadedStats.logs()); + stats.put("lastActivity", server.getLastActivityAt() != null ? server.getLastActivityAt() : server.getUpdatedAt()); + stats.put("databaseSize", loadedStats.storageSize()); + return stats; + } + + log.warn("Failed to get stats for server {}", server.getServerName()); + putCachedStats(stats, server); + return stats; + } + + private void putCachedStats(Map stats, Server server) { + long cachedUsers = server.getUserCount() != null ? server.getUserCount() : 0L; + long cachedTickets = server.getTicketCount() != null ? server.getTicketCount() : 0L; + stats.put("totalPlayers", cachedUsers); + stats.put("totalTickets", cachedTickets); + stats.put("totalLogs", 0); + stats.put("lastActivity", server.getUpdatedAt()); + stats.put("databaseSize", 0); + } + + public void resetServerDatabase(Server server) { + if (server.getDatabaseName() != null) { + if (serverDatabaseRepository.dropDatabase(server)) { + log.info("Dropped database {} for server {}", server.getDatabaseName(), server.getServerName()); + } else { + log.warn("Failed to drop database {}", server.getDatabaseName()); + } + } + + serverProvisioningRepository.resetAfterDatabaseDrop(server.getId(), new Date()); + } + + private record ComputedUsage(long userCount, long ticketCount, Date updatedAt, boolean fromCache) {} + + public record UsageSummary(long userCount, long ticketCount, Date updatedAt, boolean fromCache) {} +} diff --git a/src/main/java/gg/modl/backend/admin/service/GlobalSystemService.java b/src/main/java/gg/modl/backend/admin/service/GlobalSystemService.java index 9a5ba05..e053c57 100644 --- a/src/main/java/gg/modl/backend/admin/service/GlobalSystemService.java +++ b/src/main/java/gg/modl/backend/admin/service/GlobalSystemService.java @@ -6,11 +6,12 @@ import gg.modl.backend.admin.dto.request.UpdatePromptRequest; import gg.modl.backend.admin.dto.request.UpdateRateLimitsRequest; import gg.modl.backend.admin.dto.request.UpdateSystemConfigRequest; +import gg.modl.backend.admin.dto.response.AdminMaintenanceStatus; +import gg.modl.backend.admin.dto.response.AdminRateLimitStatus; import gg.modl.backend.ai.service.AITicketAnalysisService; import gg.modl.backend.database.mongo.repository.SystemConfigMongoRepository; import gg.modl.backend.database.mongo.repository.SystemPromptMongoRepository; import java.util.Date; -import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -38,15 +39,14 @@ public SystemConfig getOrCreateConfig() { .orElseGet(() -> systemConfigRepository.saveEntity(new SystemConfig())); } - public Map getMaintenanceStatus() { + public AdminMaintenanceStatus getMaintenanceStatus() { SystemConfig config = getOrCreateConfig(); - return Map.of( - "isActive", config.getGeneral().isMaintenanceMode(), - "message", config.getGeneral().getMaintenanceMessage() - ); + return new AdminMaintenanceStatus( + config.getGeneral().isMaintenanceMode(), + config.getGeneral().getMaintenanceMessage()); } - public Map toggleMaintenance(ToggleMaintenanceRequest request) { + public AdminMaintenanceStatus toggleMaintenance(ToggleMaintenanceRequest request) { SystemConfig config = getOrCreateConfig(); config.getGeneral().setMaintenanceMode(request.enabled()); if (request.message() != null) { @@ -54,19 +54,17 @@ public Map toggleMaintenance(ToggleMaintenanceRequest request) { } config.setUpdatedAt(new Date()); SystemConfig saved = systemConfigRepository.saveEntity(config); - return Map.of( - "isActive", saved.getGeneral().isMaintenanceMode(), - "message", saved.getGeneral().getMaintenanceMessage() - ); + return new AdminMaintenanceStatus( + saved.getGeneral().isMaintenanceMode(), + saved.getGeneral().getMaintenanceMessage()); } - public Map getRateLimitStatus() { + public AdminRateLimitStatus getRateLimitStatus() { SystemConfig config = getOrCreateConfig(); - return Map.of( - "current", config.getPerformance(), - "active", true, - "resetTime", new Date(System.currentTimeMillis() + 15 * 60 * 1000) - ); + return new AdminRateLimitStatus( + config.getPerformance(), + true, + new Date(System.currentTimeMillis() + 15 * 60 * 1000)); } public SystemConfig.PerformanceConfig updateRateLimits(UpdateRateLimitsRequest request) { diff --git a/src/main/java/gg/modl/backend/ai/LLMConfiguration.java b/src/main/java/gg/modl/backend/ai/LLMConfiguration.java index 73181e6..b26b4a5 100644 --- a/src/main/java/gg/modl/backend/ai/LLMConfiguration.java +++ b/src/main/java/gg/modl/backend/ai/LLMConfiguration.java @@ -1,20 +1,20 @@ -package gg.modl.backend.ai; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; -import org.springframework.validation.annotation.Validated; - -@Component -@ConfigurationProperties(prefix = "modl.llm") -@Validated -@Getter -@Setter -public class LLMConfiguration { - private String geminiModelId; - private String geminiApiKey; - private float geminiTemperature; - private int geminiMaxOutputTokens; - private float geminiTopP; -} +package gg.modl.backend.ai; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Component +@ConfigurationProperties(prefix = "modl.llm") +@Validated +@Getter +@Setter +public class LLMConfiguration { + private String geminiModelId; + private String geminiApiKey; + private float geminiTemperature; + private int geminiMaxOutputTokens; + private float geminiTopP; +} diff --git a/src/main/java/gg/modl/backend/ai/LLMService.java b/src/main/java/gg/modl/backend/ai/LLMService.java index 1eb6230..8087e62 100644 --- a/src/main/java/gg/modl/backend/ai/LLMService.java +++ b/src/main/java/gg/modl/backend/ai/LLMService.java @@ -1,40 +1,40 @@ -package gg.modl.backend.ai; - -import gg.modl.backend.ai.external.GeminiLLMProvider; -import gg.modl.backend.ai.external.LLMProvider; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class LLMService { - private final LLMConfiguration config; - private LLMProvider llmProvider; - - @PostConstruct - public void init() { - if (config.getGeminiApiKey() != null && !config.getGeminiApiKey().isBlank()) { - this.llmProvider = new GeminiLLMProvider(config); - log.info("LLM service initialized with Gemini provider"); - } else { - log.warn("LLM service not initialized: Gemini API key not configured"); - } - } - - @NotNull - public String generate(@NotNull String systemInstruction, @NotNull String userContent) { - if (!isAvailable()) { - throw new IllegalStateException("LLM provider not initialized. Check API key configuration."); - } - - return llmProvider.generate(systemInstruction, userContent); - } - - public boolean isAvailable() { - return llmProvider != null && llmProvider.isConnected(); - } -} +package gg.modl.backend.ai; + +import gg.modl.backend.ai.external.GeminiLLMProvider; +import gg.modl.backend.ai.external.LLMProvider; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class LLMService { + private final LLMConfiguration config; + private LLMProvider llmProvider; + + @PostConstruct + public void init() { + if (config.getGeminiApiKey() != null && !config.getGeminiApiKey().isBlank()) { + this.llmProvider = new GeminiLLMProvider(config); + log.info("LLM service initialized with Gemini provider"); + } else { + log.warn("LLM service not initialized: Gemini API key not configured"); + } + } + + @NotNull + public String generate(@NotNull String systemInstruction, @NotNull String userContent) { + if (!isAvailable()) { + throw new IllegalStateException("LLM provider not initialized. Check API key configuration."); + } + + return llmProvider.generate(systemInstruction, userContent); + } + + public boolean isAvailable() { + return llmProvider != null && llmProvider.isConnected(); + } +} diff --git a/src/main/java/gg/modl/backend/ai/data/AIAnalysisResult.java b/src/main/java/gg/modl/backend/ai/data/AIAnalysisResult.java index cf7fa9b..9934353 100644 --- a/src/main/java/gg/modl/backend/ai/data/AIAnalysisResult.java +++ b/src/main/java/gg/modl/backend/ai/data/AIAnalysisResult.java @@ -1,33 +1,33 @@ -package gg.modl.backend.ai.data; - -import java.util.Date; -import lombok.Data; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@Data -public class AIAnalysisResult { - @NotNull - private final String analysis; - @Nullable - private final SuggestedAction suggestedAction; - @NotNull - private final Date createdAt; - @NotNull - private final String rawResponse; - @Nullable - private Double confidence; - private boolean wasAppliedAutomatically; - private boolean dismissed = false; - - public boolean hasViolation() { - return suggestedAction != null; - } - - @Data - public static class SuggestedAction { - private final int punishmentTypeId; - @NotNull - private final String severity; - } -} +package gg.modl.backend.ai.data; + +import java.util.Date; +import lombok.Data; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Data +public class AIAnalysisResult { + @NotNull + private final String analysis; + @Nullable + private final SuggestedAction suggestedAction; + @NotNull + private final Date createdAt; + @NotNull + private final String rawResponse; + @Nullable + private Double confidence; + private boolean wasAppliedAutomatically; + private boolean dismissed = false; + + public boolean hasViolation() { + return suggestedAction != null; + } + + @Data + public static class SuggestedAction { + private final int punishmentTypeId; + @NotNull + private final String severity; + } +} diff --git a/src/main/java/gg/modl/backend/ai/external/GeminiLLMProvider.java b/src/main/java/gg/modl/backend/ai/external/GeminiLLMProvider.java index e884990..3cd2e17 100644 --- a/src/main/java/gg/modl/backend/ai/external/GeminiLLMProvider.java +++ b/src/main/java/gg/modl/backend/ai/external/GeminiLLMProvider.java @@ -1,64 +1,67 @@ -package gg.modl.backend.ai.external; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.genai.Client; -import com.google.genai.types.Content; -import com.google.genai.types.GenerateContentConfig; -import com.google.genai.types.GenerateContentResponse; -import com.google.genai.types.Part; -import com.google.genai.types.Schema; -import gg.modl.backend.ai.LLMConfiguration; -import gg.modl.backend.ai.data.DefaultPrompts; -import java.util.Objects; -import org.jetbrains.annotations.NotNull; - -public class GeminiLLMProvider implements LLMProvider { - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private final Client client; - private final String geminiModelId; - private final Schema responseSchema; - private final float temperature; - private final float topP; - private final int maxOutputTokens; - - public GeminiLLMProvider(LLMConfiguration config) { - this.client = Client.builder() - .apiKey(config.getGeminiApiKey()) - .build(); - this.responseSchema = parseSchema(DefaultPrompts.JSON_FORMAT); - this.temperature = config.getGeminiTemperature(); - this.topP = config.getGeminiTopP(); - this.maxOutputTokens = config.getGeminiMaxOutputTokens(); - this.geminiModelId = config.getGeminiModelId(); - } - - private static Schema parseSchema(String jsonSchema) { - try { - return MAPPER.readValue(jsonSchema, Schema.class); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid JSON schema: " + e.getMessage(), e); - } - } - - @Override - public @NotNull String generate(@NotNull String systemInstruction, @NotNull String userContent) { - final GenerateContentConfig requestConfig = GenerateContentConfig.builder() - .temperature(temperature) - .topP(topP) - .maxOutputTokens(maxOutputTokens) - .responseMimeType("application/json") - .responseSchema(responseSchema) - .systemInstruction(Content.fromParts(Part.fromText(systemInstruction))) - .build(); - - final GenerateContentResponse result = client.models.generateContent(geminiModelId, userContent, requestConfig); - - return Objects.requireNonNull(result.text(), "Failed to get response from Gemini API."); - } - - @Override - public boolean isConnected() { - return client != null; - } -} +package gg.modl.backend.ai.external; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.genai.Client; +import com.google.genai.types.Content; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.GenerateContentResponse; +import com.google.genai.types.Part; +import com.google.genai.types.Schema; +import gg.modl.backend.ai.LLMConfiguration; +import gg.modl.backend.ai.data.DefaultPrompts; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +public class GeminiLLMProvider implements LLMProvider { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final Client client; + private final boolean connected; + private final String geminiModelId; + private final Schema responseSchema; + private final float temperature; + private final float topP; + private final int maxOutputTokens; + + public GeminiLLMProvider(LLMConfiguration config) { + String apiKey = config.getGeminiApiKey(); + this.client = Client.builder() + .apiKey(apiKey) + .build(); + this.connected = apiKey != null && !apiKey.isBlank(); + this.responseSchema = parseSchema(DefaultPrompts.JSON_FORMAT); + this.temperature = config.getGeminiTemperature(); + this.topP = config.getGeminiTopP(); + this.maxOutputTokens = config.getGeminiMaxOutputTokens(); + this.geminiModelId = config.getGeminiModelId(); + } + + private static Schema parseSchema(String jsonSchema) { + try { + return MAPPER.readValue(jsonSchema, Schema.class); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid JSON schema: " + e.getMessage(), e); + } + } + + @Override + public @NotNull String generate(@NotNull String systemInstruction, @NotNull String userContent) { + final GenerateContentConfig requestConfig = GenerateContentConfig.builder() + .temperature(temperature) + .topP(topP) + .maxOutputTokens(maxOutputTokens) + .responseMimeType("application/json") + .responseSchema(responseSchema) + .systemInstruction(Content.fromParts(Part.fromText(systemInstruction))) + .build(); + + final GenerateContentResponse result = client.models.generateContent(geminiModelId, userContent, requestConfig); + + return Objects.requireNonNull(result.text(), "Failed to get response from Gemini API."); + } + + @Override + public boolean isConnected() { + return connected; + } +} diff --git a/src/main/java/gg/modl/backend/ai/external/LLMProvider.java b/src/main/java/gg/modl/backend/ai/external/LLMProvider.java index 61e80e8..ecd3b65 100644 --- a/src/main/java/gg/modl/backend/ai/external/LLMProvider.java +++ b/src/main/java/gg/modl/backend/ai/external/LLMProvider.java @@ -1,10 +1,10 @@ -package gg.modl.backend.ai.external; - -import org.jetbrains.annotations.NotNull; - -public interface LLMProvider { - @NotNull - String generate(@NotNull String systemInstruction, @NotNull String userContent); - - boolean isConnected(); -} +package gg.modl.backend.ai.external; + +import org.jetbrains.annotations.NotNull; + +public interface LLMProvider { + @NotNull + String generate(@NotNull String systemInstruction, @NotNull String userContent); + + boolean isConnected(); +} diff --git a/src/main/java/gg/modl/backend/ai/service/AITicketAnalysisService.java b/src/main/java/gg/modl/backend/ai/service/AITicketAnalysisService.java index bc8dc42..0011f28 100644 --- a/src/main/java/gg/modl/backend/ai/service/AITicketAnalysisService.java +++ b/src/main/java/gg/modl/backend/ai/service/AITicketAnalysisService.java @@ -1,512 +1,343 @@ -package gg.modl.backend.ai.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import gg.modl.backend.admin.data.SystemPrompt; -import gg.modl.backend.ai.LLMService; -import gg.modl.backend.ai.data.AIAnalysisResult; -import gg.modl.backend.ai.data.DefaultPrompts; -import gg.modl.backend.billing.service.UsageTrackingService; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.database.mongo.repository.SystemPromptMongoRepository; -import gg.modl.backend.database.mongo.repository.TicketMongoRepository; -import gg.modl.backend.limits.ServerLimitPolicy; -import gg.modl.backend.limits.ServerLimits; -import gg.modl.backend.player.dto.request.CreatePunishmentRequest; -import gg.modl.backend.player.service.PunishmentLifecycleService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.AIModerationSettings; -import gg.modl.backend.settings.data.AIModerationSettings.AIPunishmentConfig; -import gg.modl.backend.settings.service.AIModerationSettingsService; -import gg.modl.backend.settings.service.PunishmentTypeService; -import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketCategory; -import gg.modl.backend.ticket.data.TicketNote; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.data.TicketStatus; -import java.security.SecureRandom; -import java.util.ArrayList; -import java.util.Date; -import java.util.HexFormat; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class AITicketAnalysisService { - private final LLMService llmService; - private final AIModerationSettingsService aiModerationSettingsService; - private final TicketMongoRepository ticketRepository; - private final ServerMongoRepository serverRepository; - private final PunishmentLifecycleService punishmentLifecycleService; - private final PunishmentTypeService punishmentTypeService; - private final UsageTrackingService usageTrackingService; - private final ServerLimitPolicy serverLimitPolicy; - private final ObjectMapper objectMapper; - private final SystemPromptMongoRepository systemPromptRepository; - private final StaffMongoRepository staffRepository; - public static final String AI_MODERATOR = "AI Moderator"; - private static final String DEFAULT_ISSUER_NAME = "Staff"; - private static final double AUTOMATED_ACTION_CONFIDENCE_THRESHOLD = 0.85; - private static final String REPORTED_PLAYER_REFERENCE = "the reported player identified in the untrusted chat data"; - private static final SecureRandom NONCE_RANDOM = new SecureRandom(); - - @Async - public void analyzeTicketAsync(@NotNull Server server, @NotNull String ticketId) { - final AIModerationSettings settings = resolveActiveModerationSettings(server); - if (settings == null) { - log.debug("Skipping AI analysis for ticket {}: preconditions not met", ticketId); - return; - } - - final Ticket ticket = ticketRepository.findById(server, ticketId).orElse(null); - - if (ticket == null) { - log.debug("Ticket {} not found for AI analysis", ticketId); - return; - } - - if (!isChatReport(ticket)) { - log.debug("Skipping AI analysis for ticket {}: not a chat report", ticketId); - return; - } - - if (ticket.getChatMessages() == null || ticket.getChatMessages().isEmpty()) { - log.debug("Skipping AI analysis for ticket {}: no chat messages", ticketId); - return; - } - - final ModerationPrompt prompt = buildModerationPrompt(ticket, settings); - if (prompt == null) { - return; - } - - final String rawResponse; - try { - rawResponse = llmService.generate(prompt.systemInstruction(), prompt.userContent()); - } catch (Exception e) { - log.error("LLM generation failed for ticket {}", ticketId, e); - return; - } - final AIAnalysisResult result = parseResponse(rawResponse); - if (result == null) { - return; - } - - usageTrackingService.incrementAiRequests(server.getId(), 1); - - ticket.setAiAnalysis(result); - if (result.getSuggestedAction() != null && settings.isEnableAutomatedActions()) { - executeAutomatedAction(server, ticket, result, settings); - } - ticket.setUpdatedAt(new Date()); - ticketRepository.saveEntity(server, ticket); - } - - @Nullable - private AIModerationSettings resolveActiveModerationSettings(@NotNull Server server) { - if (!llmService.isAvailable()) { - log.debug("LLM service not available"); - return null; - } - - final ServerLimits limits = serverLimitPolicy.resolve(server); - if (!limits.isAiModerationEnabled()) { - return null; - } - - final AIModerationSettings settings = aiModerationSettingsService.getAIModerationSettings(server); - if (!settings.isEnableAIReview() - || settings.getAiPunishmentConfigs() == null - || settings.getAiPunishmentConfigs().isEmpty()) { - return null; - } - - final ServerMongoRepository.AIUsageSnapshot usageSnapshot = serverRepository.findAIUsageSnapshotById(server.getId()).orElse(null); - if (usageSnapshot != null) { - long currentUsage = usageSnapshot.aiRequestsCurrentPeriod(); - long limit = limits.getAiRequestLimit(); - if (currentUsage >= limit) { - log.debug("Server {} has reached AI request limit ({}/{})", server.getServerName(), currentUsage, limit); - return null; - } - } - - return settings; - } - - private boolean isChatReport(Ticket ticket) { - return ticket.getType() == TicketCategory.CHAT; - } - - private void executeAutomatedAction(Server server, Ticket ticket, AIAnalysisResult result, AIModerationSettings settings) { - final AIAnalysisResult.SuggestedAction suggestion = result.getSuggestedAction(); - if (suggestion == null) { - return; - } - - if (!settings.isEnableAutomatedActions()) { - return; - } - - final AIAnalysisResult existing = ticket.getAiAnalysis(); - if (existing != null && (existing.isWasAppliedAutomatically() || existing.isDismissed())) { - return; - } - - if (ticket.getStatus() == TicketStatus.CLOSED || ticket.isLocked()) { - return; - } - - final Double confidence = result.getConfidence(); - if (confidence == null || confidence < AUTOMATED_ACTION_CONFIDENCE_THRESHOLD) { - log.info("Skipping automated action for ticket {}: confidence {} below threshold {}. Leaving suggestion for human review.", - ticket.getId(), confidence, AUTOMATED_ACTION_CONFIDENCE_THRESHOLD); - return; - } - - if (settings.getAiPunishmentConfigs() == null) { - log.warn("No punishment configs available for automated action on ticket {}", ticket.getId()); - return; - } - - final String typeKey = String.valueOf(suggestion.getPunishmentTypeId()); - final AIPunishmentConfig punishmentConfig = settings.getAiPunishmentConfigs().get(typeKey); - - if (punishmentConfig == null || !punishmentConfig.isEnabled()) { - log.warn("Punishment config not found or disabled for type ordinal {}", typeKey); - return; - } - - final UUID playerUuid = parsePlayerUuid(ticket.getReportedPlayerUuid()); - if (playerUuid == null) { - log.warn("Cannot execute automated action: no valid reported player UUID for ticket {}", ticket.getId()); - return; - } - - try { - applyPunishmentAndCloseTicket(server, ticket, result, playerUuid, AI_MODERATOR); - result.setWasAppliedAutomatically(true); - } catch (Exception e) { - log.error("Failed to apply automated punishment for ticket {}", ticket.getId(), e); - } - } - - @Nullable - private UUID parsePlayerUuid(@Nullable String raw) { - if (raw == null || raw.isBlank()) { - return null; - } - try { - return UUID.fromString(raw); - } catch (IllegalArgumentException e) { - return null; - } - } - - private void applyPunishmentAndCloseTicket(Server server, Ticket ticket, AIAnalysisResult aiAnalysis, UUID playerUuid, String staffName) { - AIAnalysisResult.SuggestedAction suggestion = aiAnalysis.getSuggestedAction(); - String reason = aiAnalysis.getAnalysis(); - - CreatePunishmentRequest request = new CreatePunishmentRequest( - staffName, - null, - suggestion.getPunishmentTypeId(), - null, null, - List.of(ticket.getId()), - suggestion.getSeverity(), - "active", - Map.of("aiGenerated", true), - reason, null - ); - - punishmentLifecycleService.createPunishment(server, playerUuid, request); - - Date now = new Date(); - String typeName = punishmentTypeService.getPunishmentTypeName(server, suggestion.getPunishmentTypeId()); - - TicketReply systemReply = TicketReply.builder() - .id(UUID.randomUUID().toString()) - .name(staffName) - .content("This report has been reviewed and appropriate action has been taken. Thank you for your report.") - .type("system") - .created(now) - .staff(true) - .action("Close") - .attachments(new ArrayList<>()) - .build(); - - TicketNote staffNote = TicketNote.builder() - .text("AI Analysis by " + staffName + ": " + typeName + " (" + suggestion.getSeverity() + "). Reason: " + reason) - .issuerName(staffName) - .date(now) - .build(); - - if (ticket.getReplies() == null) { - ticket.setReplies(new ArrayList<>()); - } - if (ticket.getNotes() == null) { - ticket.setNotes(new ArrayList<>()); - } - ticket.getReplies().add(systemReply); - ticket.getNotes().add(staffNote); - ticket.setStatus(TicketStatus.CLOSED); - ticket.setLocked(true); - ticket.setUpdatedAt(now); - } - - private String getSystemPrompt() { - SystemPrompt prompt = systemPromptRepository.findActive().orElse(null); - - if (prompt != null && prompt.getPrompt() != null && !prompt.getPrompt().isBlank()) { - return prompt.getPrompt(); - } - - return getDefaultPrompt(); - } - - @NotNull - public static String getDefaultPrompt() { - return DefaultPrompts.MINECRAFT; - } - - @Nullable - private ModerationPrompt buildModerationPrompt(@NotNull Ticket ticket, @NotNull AIModerationSettings settings) { - final String nonce = generateNonce(); - final String beginMarker = "===BEGIN_UNTRUSTED_CHAT_DATA:" + nonce + "==="; - final String endMarker = "===END_UNTRUSTED_CHAT_DATA:" + nonce + "==="; - - final String chatJson; - try { - chatJson = objectMapper.writeValueAsString(buildChatPayload(ticket)); - } catch (JsonProcessingException e) { - log.error("Failed to serialize chat data for ticket {}", ticket.getId(), e); - return null; - } - - final String userContent = beginMarker + "\n" + chatJson + "\n" + endMarker; - final String systemInstruction = getSystemPrompt() - .replace("{{REPORTED_PLAYER}}", REPORTED_PLAYER_REFERENCE) - .replace("{{PUNISHMENT_TYPES}}", formatPunishmentTypes(settings)) - .replace("{{CHAT_LOG}}", "") - + "\n\n" - + DefaultPrompts.UNTRUSTED_DATA_DIRECTIVE.formatted(beginMarker, endMarker); - - return new ModerationPrompt(systemInstruction, userContent); - } - - @NotNull - private Map buildChatPayload(@NotNull Ticket ticket) { - final List> messages = new ArrayList<>(); - for (Ticket.ChatMessage message : ticket.getChatMessages()) { - final Map entry = new LinkedHashMap<>(); - entry.put("sender", message.getSender()); - entry.put("content", message.getContent()); - messages.add(entry); - } - - final Map payload = new LinkedHashMap<>(); - payload.put("reportedPlayer", ticket.getReportedPlayer()); - payload.put("messages", messages); - return payload; - } - - @NotNull - private static String generateNonce() { - final byte[] bytes = new byte[16]; - NONCE_RANDOM.nextBytes(bytes); - return HexFormat.of().formatHex(bytes); - } - - @NotNull - private String formatPunishmentTypes(@NotNull AIModerationSettings settings) { - if (settings.getAiPunishmentConfigs() == null || settings.getAiPunishmentConfigs().isEmpty()) { - return "No punishment types configured"; - } - - return settings.getAiPunishmentConfigs().values() - .stream() - .filter(AIPunishmentConfig::isEnabled) - .map(config -> { - String description = config.getAiDescription(); - return "%s: (%s) %s".formatted( - config.getId(), - config.getName(), - description != null && !description.isBlank() ? description : config.getName() - ); - }) - .collect(Collectors.joining("\n")); - } - - @Nullable - private AIAnalysisResult parseResponse(@NotNull String rawResponse) { - try { - final String jsonContent = extractJson(rawResponse); - final JsonNode json = objectMapper.readTree(jsonContent); - final String analysis = json.has("analysis") ? json.get("analysis").asText() : null; - - if (analysis == null) { - return null; - } - - AIAnalysisResult.SuggestedAction suggestedAction = null; - if (json.has("suggestedAction") && !json.get("suggestedAction").isNull()) { - JsonNode actionNode = json.get("suggestedAction"); - final Integer punishmentTypeId = parseIntField(actionNode, "punishmentTypeId"); - final JsonNode sevNode = actionNode.path("severity"); - final String severity = (sevNode.isMissingNode() || sevNode.isNull()) ? null : sevNode.asText(); - - if (punishmentTypeId != null && severity != null) { - suggestedAction = new AIAnalysisResult.SuggestedAction(punishmentTypeId, severity); - } - } - - final AIAnalysisResult result = new AIAnalysisResult(analysis, suggestedAction, new Date(), rawResponse); - result.setConfidence(parseDoubleField(json, "confidence")); - return result; - } catch (Exception e) { - log.error("Failed to parse AI response: {}", rawResponse, e); - return null; - } - } - - @Nullable - private Double parseDoubleField(@Nullable JsonNode node, @Nullable String field) { - if (node == null || field == null || !node.has(field) || node.get(field).isNull()) { - return null; - } - - final JsonNode value = node.get(field); - if (value.isNumber()) { - return value.asDouble(); - } - - if (value.isTextual()) { - try { - return Double.parseDouble(value.asText()); - } catch (NumberFormatException e) { - log.warn("Non-numeric value for {}: {}", field, value.asText()); - return null; - } - } - - return null; - } - - @Nullable - private Integer parseIntField(@Nullable JsonNode node, @Nullable String field) { - if (node == null || field == null || !node.has(field) || node.get(field).isNull()) { - return null; - } - - final JsonNode value = node.get(field); - if (value.isNumber()) { - return value.asInt(); - } - - if (value.isTextual()) { - try { - return Integer.parseInt(value.asText()); - } catch (NumberFormatException e) { - log.warn("Non-numeric value for {}: {}", field, value.asText()); - return null; - } - } - - return null; - } - - @NotNull - private String extractJson(@NotNull String response) { - final String trimmed = response.trim(); - final int start = trimmed.indexOf('{'); - final int end = trimmed.lastIndexOf('}'); - - if (start != -1 && end != -1 && end > start) { - return trimmed.substring(start, end + 1); - } - - return trimmed; - } - - @NotNull - public AISuggestionResult applyAISuggestion(@NotNull Server server, @NotNull String ticketId, @Nullable String actingEmail) { - final Ticket ticket = ticketRepository.findById(server, ticketId).orElse(null); - - if (ticket == null) { - return new AISuggestionResult(false, "Ticket not found"); - } - - final AIAnalysisResult aiAnalysis = ticket.getAiAnalysis(); - if (aiAnalysis == null || aiAnalysis.getSuggestedAction() == null) { - return new AISuggestionResult(false, "No AI suggestion to apply"); - } - - if (aiAnalysis.isWasAppliedAutomatically() || aiAnalysis.isDismissed()) { - return new AISuggestionResult(false, "AI suggestion already handled"); - } - - if (ticket.getStatus() == TicketStatus.CLOSED || ticket.isLocked()) { - return new AISuggestionResult(false, "Ticket already closed"); - } - - final UUID playerUuid = parsePlayerUuid(ticket.getReportedPlayerUuid()); - if (playerUuid == null) { - return new AISuggestionResult(false, "No valid reported player UUID"); - } - - punishmentLifecycleService.validatePunishmentPermission(server, actingEmail, aiAnalysis.getSuggestedAction().getPunishmentTypeId()); - - applyPunishmentAndCloseTicket(server, ticket, aiAnalysis, playerUuid, resolveIssuerName(server, actingEmail)); - ticketRepository.saveEntity(server, ticket); - - return new AISuggestionResult(true, null); - } - - @NotNull - private String resolveIssuerName(@NotNull Server server, @Nullable String email) { - if (email == null) { - return DEFAULT_ISSUER_NAME; - } - return staffRepository.findByEmailIgnoreCase(server, email) - .map(Staff::getUsername) - .filter(name -> name != null && !name.isBlank()) - .orElse(DEFAULT_ISSUER_NAME); - } - - @NotNull - public AISuggestionResult dismissAISuggestion(@NotNull Server server, @NotNull String ticketId) { - final Ticket ticket = ticketRepository.findById(server, ticketId).orElse(null); - - if (ticket == null) { - return new AISuggestionResult(false, "Ticket not found"); - } - - if (ticket.getAiAnalysis() == null) { - return new AISuggestionResult(false, "No AI analysis to dismiss"); - } - - ticket.getAiAnalysis().setDismissed(true); - ticket.setUpdatedAt(new Date()); - ticketRepository.saveEntity(server, ticket); - - return new AISuggestionResult(true, null); - } - - public record AISuggestionResult(boolean success, String error) {} - - private record ModerationPrompt(String systemInstruction, String userContent) {} -} +package gg.modl.backend.ai.service; + +import gg.modl.backend.admin.data.SystemPrompt; +import gg.modl.backend.ai.LLMService; +import gg.modl.backend.ai.data.AIAnalysisResult; +import gg.modl.backend.ai.data.DefaultPrompts; +import gg.modl.backend.billing.service.UsageTrackingService; +import gg.modl.backend.database.mongo.repository.ServerUsageRepository; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.database.mongo.repository.SystemPromptMongoRepository; +import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.limits.ServerLimitPolicy; +import gg.modl.backend.limits.ServerLimits; +import gg.modl.backend.player.dto.request.CreatePunishmentRequest; +import gg.modl.backend.player.service.PunishmentLifecycleService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.AIModerationSettings; +import gg.modl.backend.settings.data.AIModerationSettings.AIPunishmentConfig; +import gg.modl.backend.settings.service.AIModerationSettingsService; +import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.backend.staff.data.Staff; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketCategory; +import gg.modl.backend.ticket.data.TicketNote; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.data.TicketStatus; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AITicketAnalysisService { + private final LLMService llmService; + private final AIModerationSettingsService aiModerationSettingsService; + private final TicketMongoRepository ticketRepository; + private final ServerUsageRepository serverUsageRepository; + private final PunishmentLifecycleService punishmentLifecycleService; + private final PunishmentTypeService punishmentTypeService; + private final UsageTrackingService usageTrackingService; + private final ServerLimitPolicy serverLimitPolicy; + private final ChatModerationPromptBuilder promptBuilder; + private final AiAnalysisResponseParser responseParser; + private final SystemPromptMongoRepository systemPromptRepository; + private final StaffMongoRepository staffRepository; + private static final String AI_MODERATOR = "AI Moderator"; + private static final String DEFAULT_ISSUER_NAME = "Staff"; + private static final double AUTOMATED_ACTION_CONFIDENCE_THRESHOLD = 0.85; + + @Async + public void analyzeTicketAsync(@NotNull Server server, @NotNull String ticketId) { + final AIModerationSettings settings = resolveActiveModerationSettings(server); + if (settings == null) { + log.debug("Skipping AI analysis for ticket {}: preconditions not met", ticketId); + return; + } + + final Ticket ticket = ticketRepository.findById(server, ticketId).orElse(null); + + if (ticket == null) { + log.debug("Ticket {} not found for AI analysis", ticketId); + return; + } + + if (!isChatReport(ticket)) { + log.debug("Skipping AI analysis for ticket {}: not a chat report", ticketId); + return; + } + + if (ticket.getChatMessages() == null || ticket.getChatMessages().isEmpty()) { + log.debug("Skipping AI analysis for ticket {}: no chat messages", ticketId); + return; + } + + final ChatModerationPromptBuilder.ModerationPrompt prompt = promptBuilder.buildModerationPrompt(ticket, settings, this::getSystemPrompt); + if (prompt == null) { + return; + } + + final String rawResponse; + try { + rawResponse = llmService.generate(prompt.systemInstruction(), prompt.userContent()); + } catch (Exception e) { + log.error("LLM generation failed for ticket {}", ticketId, e); + return; + } + final AIAnalysisResult result = responseParser.parseResponse(rawResponse); + if (result == null) { + return; + } + + usageTrackingService.incrementAiRequests(server.getId(), 1); + + ticket.setAiAnalysis(result); + if (result.getSuggestedAction() != null && settings.isEnableAutomatedActions()) { + executeAutomatedAction(server, ticket, result, settings); + } + ticket.setUpdatedAt(new Date()); + ticketRepository.saveEntity(server, ticket); + } + + @Nullable + private AIModerationSettings resolveActiveModerationSettings(@NotNull Server server) { + if (!llmService.isAvailable()) { + log.debug("LLM service not available"); + return null; + } + + final ServerLimits limits = serverLimitPolicy.resolve(server); + if (!limits.isAiModerationEnabled()) { + return null; + } + + final AIModerationSettings settings = aiModerationSettingsService.getAIModerationSettings(server); + if (!settings.isEnableAIReview() + || settings.getAiPunishmentConfigs() == null + || settings.getAiPunishmentConfigs().isEmpty()) { + return null; + } + + final ServerUsageRepository.AIUsageSnapshot usageSnapshot = serverUsageRepository.findAIUsageSnapshotById(server.getId()).orElse(null); + if (usageSnapshot != null) { + long currentUsage = usageSnapshot.aiRequestsCurrentPeriod(); + long limit = limits.getAiRequestLimit(); + if (currentUsage >= limit) { + log.debug("Server {} has reached AI request limit ({}/{})", server.getServerName(), currentUsage, limit); + return null; + } + } + + return settings; + } + + private boolean isChatReport(Ticket ticket) { + return ticket.getType() == TicketCategory.CHAT; + } + + private void executeAutomatedAction(Server server, Ticket ticket, AIAnalysisResult result, AIModerationSettings settings) { + final AIAnalysisResult.SuggestedAction suggestion = result.getSuggestedAction(); + if (suggestion == null) { + return; + } + + if (!settings.isEnableAutomatedActions()) { + return; + } + + final AIAnalysisResult existing = ticket.getAiAnalysis(); + if (existing != null && (existing.isWasAppliedAutomatically() || existing.isDismissed())) { + return; + } + + if (ticket.getStatus() == TicketStatus.CLOSED || ticket.isLocked()) { + return; + } + + final Double confidence = result.getConfidence(); + if (confidence == null || confidence < AUTOMATED_ACTION_CONFIDENCE_THRESHOLD) { + log.info("Skipping automated action for ticket {}: confidence {} below threshold {}. Leaving suggestion for human review.", + ticket.getId(), confidence, AUTOMATED_ACTION_CONFIDENCE_THRESHOLD); + return; + } + + if (settings.getAiPunishmentConfigs() == null) { + log.warn("No punishment configs available for automated action on ticket {}", ticket.getId()); + return; + } + + final String typeKey = String.valueOf(suggestion.getPunishmentTypeId()); + final AIPunishmentConfig punishmentConfig = settings.getAiPunishmentConfigs().get(typeKey); + + if (punishmentConfig == null || !punishmentConfig.isEnabled()) { + log.warn("Punishment config not found or disabled for type ordinal {}", typeKey); + return; + } + + final UUID playerUuid = parsePlayerUuid(ticket.getReportedPlayerUuid()); + if (playerUuid == null) { + log.warn("Cannot execute automated action: no valid reported player UUID for ticket {}", ticket.getId()); + return; + } + + try { + applyPunishmentAndCloseTicket(server, ticket, result, playerUuid, AI_MODERATOR); + result.setWasAppliedAutomatically(true); + } catch (Exception e) { + log.error("Failed to apply automated punishment for ticket {}", ticket.getId(), e); + } + } + + @Nullable + private UUID parsePlayerUuid(@Nullable String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException e) { + return null; + } + } + + private void applyPunishmentAndCloseTicket(Server server, Ticket ticket, AIAnalysisResult aiAnalysis, UUID playerUuid, String staffName) { + AIAnalysisResult.SuggestedAction suggestion = aiAnalysis.getSuggestedAction(); + String reason = aiAnalysis.getAnalysis(); + + CreatePunishmentRequest request = new CreatePunishmentRequest( + staffName, + null, + suggestion.getPunishmentTypeId(), + null, null, + List.of(ticket.getId()), + suggestion.getSeverity(), + "active", + Map.of("aiGenerated", true), + reason, null + ); + + punishmentLifecycleService.createPunishment(server, playerUuid, request); + + Date now = new Date(); + String typeName = punishmentTypeService.getPunishmentTypeName(server, suggestion.getPunishmentTypeId()); + + TicketReply systemReply = TicketReply.builder() + .id(UUID.randomUUID().toString()) + .name(staffName) + .content("This report has been reviewed and appropriate action has been taken. Thank you for your report.") + .type("system") + .created(now) + .staff(true) + .action("Close") + .attachments(new ArrayList<>()) + .build(); + + TicketNote staffNote = TicketNote.builder() + .text("AI Analysis by " + staffName + ": " + typeName + " (" + suggestion.getSeverity() + "). Reason: " + reason) + .issuerName(staffName) + .date(now) + .build(); + + if (ticket.getReplies() == null) { + ticket.setReplies(new ArrayList<>()); + } + if (ticket.getNotes() == null) { + ticket.setNotes(new ArrayList<>()); + } + ticket.getReplies().add(systemReply); + ticket.getNotes().add(staffNote); + ticket.setStatus(TicketStatus.CLOSED); + ticket.setLocked(true); + ticket.setUpdatedAt(now); + } + + private String getSystemPrompt() { + SystemPrompt prompt = systemPromptRepository.findActive().orElse(null); + + if (prompt != null && prompt.getPrompt() != null && !prompt.getPrompt().isBlank()) { + return prompt.getPrompt(); + } + + return getDefaultPrompt(); + } + + @NotNull + public static String getDefaultPrompt() { + return DefaultPrompts.MINECRAFT; + } + + @NotNull + public AISuggestionResult applyAISuggestion(@NotNull Server server, @NotNull String ticketId, @Nullable String actingEmail) { + final Ticket ticket = ticketRepository.findById(server, ticketId).orElse(null); + + if (ticket == null) { + return new AISuggestionResult(false, "Ticket not found"); + } + + final AIAnalysisResult aiAnalysis = ticket.getAiAnalysis(); + if (aiAnalysis == null || aiAnalysis.getSuggestedAction() == null) { + return new AISuggestionResult(false, "No AI suggestion to apply"); + } + + if (aiAnalysis.isWasAppliedAutomatically() || aiAnalysis.isDismissed()) { + return new AISuggestionResult(false, "AI suggestion already handled"); + } + + if (ticket.getStatus() == TicketStatus.CLOSED || ticket.isLocked()) { + return new AISuggestionResult(false, "Ticket already closed"); + } + + final UUID playerUuid = parsePlayerUuid(ticket.getReportedPlayerUuid()); + if (playerUuid == null) { + return new AISuggestionResult(false, "No valid reported player UUID"); + } + + punishmentLifecycleService.validatePunishmentPermission(server, actingEmail, aiAnalysis.getSuggestedAction().getPunishmentTypeId()); + + applyPunishmentAndCloseTicket(server, ticket, aiAnalysis, playerUuid, resolveIssuerName(server, actingEmail)); + ticketRepository.saveEntity(server, ticket); + + return new AISuggestionResult(true, null); + } + + @NotNull + private String resolveIssuerName(@NotNull Server server, @Nullable String email) { + if (email == null) { + return DEFAULT_ISSUER_NAME; + } + return staffRepository.findByEmailIgnoreCase(server, email) + .map(Staff::getUsername) + .filter(name -> name != null && !name.isBlank()) + .orElse(DEFAULT_ISSUER_NAME); + } + + @NotNull + public AISuggestionResult dismissAISuggestion(@NotNull Server server, @NotNull String ticketId) { + final Ticket ticket = ticketRepository.findById(server, ticketId).orElse(null); + + if (ticket == null) { + return new AISuggestionResult(false, "Ticket not found"); + } + + if (ticket.getAiAnalysis() == null) { + return new AISuggestionResult(false, "No AI analysis to dismiss"); + } + + ticket.getAiAnalysis().setDismissed(true); + ticket.setUpdatedAt(new Date()); + ticketRepository.saveEntity(server, ticket); + + return new AISuggestionResult(true, null); + } + + public record AISuggestionResult(boolean success, String error) {} +} diff --git a/src/main/java/gg/modl/backend/ai/service/AiAnalysisResponseParser.java b/src/main/java/gg/modl/backend/ai/service/AiAnalysisResponseParser.java new file mode 100644 index 0000000..7bac5f5 --- /dev/null +++ b/src/main/java/gg/modl/backend/ai/service/AiAnalysisResponseParser.java @@ -0,0 +1,89 @@ +package gg.modl.backend.ai.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.ai.data.AIAnalysisResult; +import java.util.Date; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AiAnalysisResponseParser { + private final ObjectMapper objectMapper; + + @Nullable + public AIAnalysisResult parseResponse(@NotNull String rawResponse) { + try { + final String jsonContent = extractJson(rawResponse); + final JsonNode json = objectMapper.readTree(jsonContent); + final String analysis = json.has("analysis") ? json.get("analysis").asText() : null; + + if (analysis == null) { + return null; + } + + AIAnalysisResult.SuggestedAction suggestedAction = null; + if (json.has("suggestedAction") && !json.get("suggestedAction").isNull()) { + JsonNode actionNode = json.get("suggestedAction"); + final Integer punishmentTypeId = parseNumber(actionNode, "punishmentTypeId", JsonNode::asInt, Integer::parseInt); + final JsonNode sevNode = actionNode.path("severity"); + final String severity = (sevNode.isMissingNode() || sevNode.isNull()) ? null : sevNode.asText(); + + if (punishmentTypeId != null && severity != null) { + suggestedAction = new AIAnalysisResult.SuggestedAction(punishmentTypeId, severity); + } + } + + final AIAnalysisResult result = new AIAnalysisResult(analysis, suggestedAction, new Date(), rawResponse); + result.setConfidence(parseNumber(json, "confidence", JsonNode::asDouble, Double::parseDouble)); + return result; + } catch (Exception e) { + log.error("Failed to parse AI response: {}", rawResponse, e); + return null; + } + } + + @Nullable + private T parseNumber(@Nullable JsonNode node, @Nullable String field, + @NotNull Function fromNumber, + @NotNull Function fromText) { + if (node == null || field == null || !node.has(field) || node.get(field).isNull()) { + return null; + } + + final JsonNode value = node.get(field); + if (value.isNumber()) { + return fromNumber.apply(value); + } + + if (value.isTextual()) { + try { + return fromText.apply(value.asText()); + } catch (NumberFormatException e) { + log.warn("Non-numeric value for {}: {}", field, value.asText()); + return null; + } + } + + return null; + } + + @NotNull + private String extractJson(@NotNull String response) { + final String trimmed = response.trim(); + final int start = trimmed.indexOf('{'); + final int end = trimmed.lastIndexOf('}'); + + if (start != -1 && end != -1 && end > start) { + return trimmed.substring(start, end + 1); + } + + return trimmed; + } +} diff --git a/src/main/java/gg/modl/backend/ai/service/ChatModerationPromptBuilder.java b/src/main/java/gg/modl/backend/ai/service/ChatModerationPromptBuilder.java new file mode 100644 index 0000000..15c3740 --- /dev/null +++ b/src/main/java/gg/modl/backend/ai/service/ChatModerationPromptBuilder.java @@ -0,0 +1,101 @@ +package gg.modl.backend.ai.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.ai.data.DefaultPrompts; +import gg.modl.backend.settings.data.AIModerationSettings; +import gg.modl.backend.settings.data.AIModerationSettings.AIPunishmentConfig; +import gg.modl.backend.ticket.data.Ticket; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatModerationPromptBuilder { + private static final String REPORTED_PLAYER_REFERENCE = "the reported player identified in the untrusted chat data"; + private static final SecureRandom NONCE_RANDOM = new SecureRandom(); + + private final ObjectMapper objectMapper; + + @Nullable + public ModerationPrompt buildModerationPrompt(@NotNull Ticket ticket, @NotNull AIModerationSettings settings, @NotNull Supplier systemPrompt) { + final String nonce = generateNonce(); + final String beginMarker = "===BEGIN_UNTRUSTED_CHAT_DATA:" + nonce + "==="; + final String endMarker = "===END_UNTRUSTED_CHAT_DATA:" + nonce + "==="; + + final String chatJson; + try { + chatJson = objectMapper.writeValueAsString(buildChatPayload(ticket)); + } catch (JsonProcessingException e) { + log.error("Failed to serialize chat data for ticket {}", ticket.getId(), e); + return null; + } + + final String userContent = beginMarker + "\n" + chatJson + "\n" + endMarker; + final String systemInstruction = systemPrompt.get() + .replace("{{REPORTED_PLAYER}}", REPORTED_PLAYER_REFERENCE) + .replace("{{PUNISHMENT_TYPES}}", formatPunishmentTypes(settings)) + .replace("{{CHAT_LOG}}", "") + + "\n\n" + + DefaultPrompts.UNTRUSTED_DATA_DIRECTIVE.formatted(beginMarker, endMarker); + + return new ModerationPrompt(systemInstruction, userContent); + } + + @NotNull + private Map buildChatPayload(@NotNull Ticket ticket) { + final List> messages = new ArrayList<>(); + for (Ticket.ChatMessage message : ticket.getChatMessages()) { + final Map entry = new LinkedHashMap<>(); + entry.put("sender", message.getSender()); + entry.put("content", message.getContent()); + messages.add(entry); + } + + final Map payload = new LinkedHashMap<>(); + payload.put("reportedPlayer", ticket.getReportedPlayer()); + payload.put("messages", messages); + return payload; + } + + @NotNull + private static String generateNonce() { + final byte[] bytes = new byte[16]; + NONCE_RANDOM.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + @NotNull + private String formatPunishmentTypes(@NotNull AIModerationSettings settings) { + if (settings.getAiPunishmentConfigs() == null || settings.getAiPunishmentConfigs().isEmpty()) { + return "No punishment types configured"; + } + + return settings.getAiPunishmentConfigs().values() + .stream() + .filter(AIPunishmentConfig::isEnabled) + .map(config -> { + String description = config.getAiDescription(); + return "%s: (%s) %s".formatted( + config.getId(), + config.getName(), + description != null && !description.isBlank() ? description : config.getName() + ); + }) + .collect(Collectors.joining("\n")); + } + + public record ModerationPrompt(String systemInstruction, String userContent) {} +} diff --git a/src/main/java/gg/modl/backend/alert/controller/AdminSystemAlertController.java b/src/main/java/gg/modl/backend/alert/controller/AdminSystemAlertController.java index 0c03815..51f2754 100644 --- a/src/main/java/gg/modl/backend/alert/controller/AdminSystemAlertController.java +++ b/src/main/java/gg/modl/backend/alert/controller/AdminSystemAlertController.java @@ -3,16 +3,16 @@ import gg.modl.backend.alert.data.SystemAlert; import gg.modl.backend.alert.service.SystemAlertService; import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.filter.AdminAuthFilter; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.proto.modl.v1.AdminSystemAlertResponse; import gg.modl.proto.modl.v1.AdminSystemAlertsResponse; import gg.modl.proto.modl.v1.CreateSystemAlertRequest; import gg.modl.proto.modl.v1.UpdateSystemAlertRequest; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -33,13 +33,16 @@ public ResponseEntity getAlerts() { } @PostMapping - public ResponseEntity createAlert(@RequestBody CreateSystemAlertRequest request) { + public ResponseEntity createAlert( + @RequestBody CreateSystemAlertRequest request, + HttpServletRequest httpRequest + ) { SystemAlert alert = alertService.createAlert( request.getMessage(), AlertProtoMapper.parseSeverity(request.getSeverity()), AlertProtoMapper.parseAudience(request.getAudience()), AlertProtoMapper.toExpiresAt(request.getExpiresAt()), - getAdminEmail() + getAdminEmail(httpRequest) ); return ResponseEntity.status(HttpStatus.CREATED).body(AlertProtoMapper.toAdminAlert(alert)); } @@ -47,7 +50,8 @@ public ResponseEntity createAlert(@RequestBody CreateS @PutMapping("/{id}") public ResponseEntity updateAlert( @PathVariable String id, - @RequestBody UpdateSystemAlertRequest request + @RequestBody UpdateSystemAlertRequest request, + HttpServletRequest httpRequest ) { SystemAlert alert = alertService.updateAlert( id, @@ -56,16 +60,14 @@ public ResponseEntity updateAlert( request.hasAudience() ? AlertProtoMapper.parseAudienceStrict(request.getAudience()) : null, request.hasExpiresAt(), request.hasExpiresAt() ? AlertProtoMapper.toExpiresAt(request.getExpiresAt()) : null, - getAdminEmail() + getAdminEmail(httpRequest) ).orElseThrow(() -> new ResourceNotFoundException("Alert not found")); return ResponseEntity.ok(AlertProtoMapper.toAdminAlert(alert)); } - private String getAdminEmail() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || authentication.getName() == null || authentication.getName().isBlank()) { - return "unknown"; - } - return authentication.getName(); + private String getAdminEmail(HttpServletRequest request) { + return AdminAuthFilter.actingEmail(request) + .filter(email -> !email.isBlank()) + .orElse("unknown"); } } diff --git a/src/main/java/gg/modl/backend/alert/controller/PanelSystemAlertController.java b/src/main/java/gg/modl/backend/alert/controller/PanelSystemAlertController.java index 50acaf1..34f2ee4 100644 --- a/src/main/java/gg/modl/backend/alert/controller/PanelSystemAlertController.java +++ b/src/main/java/gg/modl/backend/alert/controller/PanelSystemAlertController.java @@ -1,6 +1,8 @@ package gg.modl.backend.alert.controller; import gg.modl.backend.alert.service.SystemAlertService; +import gg.modl.backend.infrastructure.authorization.PanelAccessRule; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.role.service.PermissionService; @@ -22,6 +24,7 @@ public class PanelSystemAlertController { private final PermissionService permissionService; @GetMapping("/alerts") + @RequiresPanelPermission(rule = PanelAccessRule.PERMIT_ALL) public ResponseEntity getAlerts(HttpServletRequest request) { Server server = RequestUtil.getRequestServer(request); String email = RequestUtil.getSessionEmail(request); diff --git a/src/main/java/gg/modl/backend/analytics/controller/AnalyticsController.java b/src/main/java/gg/modl/backend/analytics/controller/AnalyticsController.java index a897631..4dec46b 100644 --- a/src/main/java/gg/modl/backend/analytics/controller/AnalyticsController.java +++ b/src/main/java/gg/modl/backend/analytics/controller/AnalyticsController.java @@ -1,78 +1,80 @@ -package gg.modl.backend.analytics.controller; - -import gg.modl.backend.analytics.dto.response.AuditLogsAnalyticsResponse; -import gg.modl.backend.analytics.dto.response.OverviewResponse; -import gg.modl.backend.analytics.dto.response.PlayerActivityResponse; -import gg.modl.backend.analytics.dto.response.PunishmentAnalyticsResponse; -import gg.modl.backend.analytics.dto.response.TicketAnalyticsResponse; -import gg.modl.backend.analytics.service.AnalyticsService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.AnalyticsOverviewResponse; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_ANALYTICS) -@RequiredArgsConstructor -public class AnalyticsController { - private final AnalyticsService analyticsService; - - @GetMapping("/overview") - public ResponseEntity getOverview(HttpServletRequest request) { - final Server server = RequestUtil.getRequestServer(request); - final OverviewResponse overview = analyticsService.getOverview(server); - - return ResponseEntity.ok(AnalyticsProtoMapper.toOverviewResponse(overview)); - } - - @GetMapping("/tickets") - public ResponseEntity getTicketAnalytics( - @RequestParam(defaultValue = "30d") String period, - HttpServletRequest request - ) { - final Server server = RequestUtil.getRequestServer(request); - final TicketAnalyticsResponse analytics = analyticsService.getTicketAnalytics(server, period); - - return ResponseEntity.ok(AnalyticsProtoMapper.toTicketAnalytics(analytics)); - } - - @GetMapping("/punishments") - public ResponseEntity getPunishmentAnalytics( - @RequestParam(defaultValue = "30d") String period, - HttpServletRequest request - ) { - final Server server = RequestUtil.getRequestServer(request); - final PunishmentAnalyticsResponse analytics = analyticsService.getPunishmentAnalytics(server, period); - - return ResponseEntity.ok(AnalyticsProtoMapper.toPunishmentAnalytics(analytics)); - } - - @GetMapping("/audit-logs") - public ResponseEntity getAuditLogsAnalytics( - @RequestParam(defaultValue = "7d") String period, - HttpServletRequest request - ) { - final Server server = RequestUtil.getRequestServer(request); - final AuditLogsAnalyticsResponse analytics = analyticsService.getAuditLogsAnalytics(server, period); - - return ResponseEntity.ok(AnalyticsProtoMapper.toAuditLogsAnalytics(analytics)); - } - - @GetMapping("/player-activity") - public ResponseEntity getPlayerActivityAnalytics( - @RequestParam(defaultValue = "30d") String period, - HttpServletRequest request - ) { - final Server server = RequestUtil.getRequestServer(request); - final PlayerActivityResponse analytics = analyticsService.getPlayerActivityAnalytics(server, period); - - return ResponseEntity.ok(AnalyticsProtoMapper.toPlayerActivity(analytics)); - } -} +package gg.modl.backend.analytics.controller; + +import gg.modl.backend.analytics.dto.response.AuditLogsAnalyticsResponse; +import gg.modl.backend.analytics.dto.response.OverviewResponse; +import gg.modl.backend.analytics.dto.response.PlayerActivityResponse; +import gg.modl.backend.analytics.dto.response.PunishmentAnalyticsResponse; +import gg.modl.backend.analytics.dto.response.TicketAnalyticsResponse; +import gg.modl.backend.analytics.service.AnalyticsService; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.AnalyticsOverviewResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_ANALYTICS) +@RequiresPanelPermission("admin.audit.view.analytics") +@RequiredArgsConstructor +public class AnalyticsController { + private final AnalyticsService analyticsService; + + @GetMapping("/overview") + public ResponseEntity getOverview(HttpServletRequest request) { + final Server server = RequestUtil.getRequestServer(request); + final OverviewResponse overview = analyticsService.getOverview(server); + + return ResponseEntity.ok(AnalyticsProtoMapper.toOverviewResponse(overview)); + } + + @GetMapping("/tickets") + public ResponseEntity getTicketAnalytics( + @RequestParam(defaultValue = "30d") String period, + HttpServletRequest request + ) { + final Server server = RequestUtil.getRequestServer(request); + final TicketAnalyticsResponse analytics = analyticsService.getTicketAnalytics(server, period); + + return ResponseEntity.ok(AnalyticsProtoMapper.toTicketAnalytics(analytics)); + } + + @GetMapping("/punishments") + public ResponseEntity getPunishmentAnalytics( + @RequestParam(defaultValue = "30d") String period, + HttpServletRequest request + ) { + final Server server = RequestUtil.getRequestServer(request); + final PunishmentAnalyticsResponse analytics = analyticsService.getPunishmentAnalytics(server, period); + + return ResponseEntity.ok(AnalyticsProtoMapper.toPunishmentAnalytics(analytics)); + } + + @GetMapping("/audit-logs") + public ResponseEntity getAuditLogsAnalytics( + @RequestParam(defaultValue = "7d") String period, + HttpServletRequest request + ) { + final Server server = RequestUtil.getRequestServer(request); + final AuditLogsAnalyticsResponse analytics = analyticsService.getAuditLogsAnalytics(server, period); + + return ResponseEntity.ok(AnalyticsProtoMapper.toAuditLogsAnalytics(analytics)); + } + + @GetMapping("/player-activity") + public ResponseEntity getPlayerActivityAnalytics( + @RequestParam(defaultValue = "30d") String period, + HttpServletRequest request + ) { + final Server server = RequestUtil.getRequestServer(request); + final PlayerActivityResponse analytics = analyticsService.getPlayerActivityAnalytics(server, period); + + return ResponseEntity.ok(AnalyticsProtoMapper.toPlayerActivity(analytics)); + } +} diff --git a/src/main/java/gg/modl/backend/analytics/data/MetricSnapshot.java b/src/main/java/gg/modl/backend/analytics/data/MetricSnapshot.java index 90cf83e..ab142ab 100644 --- a/src/main/java/gg/modl/backend/analytics/data/MetricSnapshot.java +++ b/src/main/java/gg/modl/backend/analytics/data/MetricSnapshot.java @@ -16,7 +16,7 @@ public class MetricSnapshot { private String id; @Field - private Date date; // truncated to hour (UTC) + private Date date; @Field private long activeServers; diff --git a/src/main/java/gg/modl/backend/analytics/dto/response/AuditLogsAnalyticsResponse.java b/src/main/java/gg/modl/backend/analytics/dto/response/AuditLogsAnalyticsResponse.java index ff6cfc9..9d3c164 100644 --- a/src/main/java/gg/modl/backend/analytics/dto/response/AuditLogsAnalyticsResponse.java +++ b/src/main/java/gg/modl/backend/analytics/dto/response/AuditLogsAnalyticsResponse.java @@ -1,12 +1,12 @@ -package gg.modl.backend.analytics.dto.response; - -import java.util.List; - -public record AuditLogsAnalyticsResponse( - List byLevel, - List hourlyTrend -) { - public record LevelCount(String level, int count) {} - - public record HourlyCount(String hour, int count) {} -} +package gg.modl.backend.analytics.dto.response; + +import java.util.List; + +public record AuditLogsAnalyticsResponse( + List byLevel, + List hourlyTrend +) { + public record LevelCount(String level, int count) {} + + public record HourlyCount(String hour, int count) {} +} diff --git a/src/main/java/gg/modl/backend/analytics/dto/response/OverviewResponse.java b/src/main/java/gg/modl/backend/analytics/dto/response/OverviewResponse.java index 4ae03a1..24faf59 100644 --- a/src/main/java/gg/modl/backend/analytics/dto/response/OverviewResponse.java +++ b/src/main/java/gg/modl/backend/analytics/dto/response/OverviewResponse.java @@ -1,11 +1,11 @@ -package gg.modl.backend.analytics.dto.response; - -public record OverviewResponse( - long totalTickets, - long totalPlayers, - long totalStaff, - long activeTickets, - int ticketChange, - int playerChange -) { -} +package gg.modl.backend.analytics.dto.response; + +public record OverviewResponse( + long totalTickets, + long totalPlayers, + long totalStaff, + long activeTickets, + int ticketChange, + int playerChange +) { +} diff --git a/src/main/java/gg/modl/backend/analytics/dto/response/PlayerActivityResponse.java b/src/main/java/gg/modl/backend/analytics/dto/response/PlayerActivityResponse.java index 46d0207..d02ea3d 100644 --- a/src/main/java/gg/modl/backend/analytics/dto/response/PlayerActivityResponse.java +++ b/src/main/java/gg/modl/backend/analytics/dto/response/PlayerActivityResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.analytics.dto.response; - -import java.util.List; - -public record PlayerActivityResponse( - List newPlayersTrend, - List loginsByCountry, - SuspiciousActivity suspiciousActivity -) { - public record DailyCount(String date, int count) {} - - public record CountryCount(String country, int count) {} - - public record SuspiciousActivity(int proxyCount, int hostingCount) {} -} +package gg.modl.backend.analytics.dto.response; + +import java.util.List; + +public record PlayerActivityResponse( + List newPlayersTrend, + List loginsByCountry, + SuspiciousActivity suspiciousActivity +) { + public record DailyCount(String date, int count) {} + + public record CountryCount(String country, int count) {} + + public record SuspiciousActivity(int proxyCount, int hostingCount) {} +} diff --git a/src/main/java/gg/modl/backend/analytics/dto/response/PunishmentAnalyticsResponse.java b/src/main/java/gg/modl/backend/analytics/dto/response/PunishmentAnalyticsResponse.java index 47dd8ba..cba2808 100644 --- a/src/main/java/gg/modl/backend/analytics/dto/response/PunishmentAnalyticsResponse.java +++ b/src/main/java/gg/modl/backend/analytics/dto/response/PunishmentAnalyticsResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.analytics.dto.response; - -import java.util.List; - -public record PunishmentAnalyticsResponse( - List byType, - List dailyPunishments, - List byStaff -) { - public record TypeCount(String type, int count) {} - - public record DailyPunishment(String date, int count) {} - - public record StaffPunishment(String username, int count) {} -} +package gg.modl.backend.analytics.dto.response; + +import java.util.List; + +public record PunishmentAnalyticsResponse( + List byType, + List dailyPunishments, + List byStaff +) { + public record TypeCount(String type, int count) {} + + public record DailyPunishment(String date, int count) {} + + public record StaffPunishment(String username, int count) {} +} diff --git a/src/main/java/gg/modl/backend/analytics/dto/response/TicketAnalyticsResponse.java b/src/main/java/gg/modl/backend/analytics/dto/response/TicketAnalyticsResponse.java index 8a8d49e..304d20a 100644 --- a/src/main/java/gg/modl/backend/analytics/dto/response/TicketAnalyticsResponse.java +++ b/src/main/java/gg/modl/backend/analytics/dto/response/TicketAnalyticsResponse.java @@ -1,18 +1,18 @@ -package gg.modl.backend.analytics.dto.response; - -import java.util.List; - -public record TicketAnalyticsResponse( - List byStatus, - List byCategory, - List avgResolutionByCategory, - List dailyTickets -) { - public record StatusCount(String status, int count) {} - - public record CategoryCount(String category, int count) {} - - public record CategoryResolutionTime(String category, double avgHours) {} - - public record DailyTicket(String date, int count) {} -} +package gg.modl.backend.analytics.dto.response; + +import java.util.List; + +public record TicketAnalyticsResponse( + List byStatus, + List byCategory, + List avgResolutionByCategory, + List dailyTickets +) { + public record StatusCount(String status, int count) {} + + public record CategoryCount(String category, int count) {} + + public record CategoryResolutionTime(String category, double avgHours) {} + + public record DailyTicket(String date, int count) {} +} diff --git a/src/main/java/gg/modl/backend/analytics/service/AnalyticsService.java b/src/main/java/gg/modl/backend/analytics/service/AnalyticsService.java index 11e863b..c0cd8fc 100644 --- a/src/main/java/gg/modl/backend/analytics/service/AnalyticsService.java +++ b/src/main/java/gg/modl/backend/analytics/service/AnalyticsService.java @@ -1,7 +1,5 @@ package gg.modl.backend.analytics.service; -import static gg.modl.backend.infrastructure.util.SafeConvertUtil.toInt; - import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import gg.modl.backend.analytics.dto.response.AuditLogsAnalyticsResponse; @@ -11,6 +9,8 @@ import gg.modl.backend.analytics.dto.response.TicketAnalyticsResponse; import gg.modl.backend.database.mongo.repository.AnalyticsMongoRepository; import gg.modl.backend.database.mongo.repository.AnalyticsMongoRepository.IdCountResult; +import gg.modl.backend.database.mongo.repository.AnalyticsMongoRepository.PlayerActivityFacet; +import gg.modl.backend.database.mongo.repository.AnalyticsMongoRepository.PunishmentAnalyticsFacet; import gg.modl.backend.player.service.IssuerNameResolver; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.service.PunishmentTypeIndex; @@ -18,15 +18,14 @@ import gg.modl.backend.staff.service.StaffService; import gg.modl.backend.ticket.data.TicketCategory; import gg.modl.backend.ticket.data.TicketStatus; +import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; import gg.modl.backend.infrastructure.util.DateRangeUtil; import java.time.Duration; import java.time.Instant; -import java.time.LocalDate; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; @@ -34,7 +33,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.TimeZone; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.bson.Document; @@ -45,7 +43,8 @@ @RequiredArgsConstructor @Slf4j public class AnalyticsService { - private static final String ANALYTICS_TIME_ZONE = TimeZone.getDefault().getID(); + private static final String ANALYTICS_TIME_ZONE = "UTC"; + private static final double MILLIS_PER_HOUR = 1000.0 * 60 * 60; private final AnalyticsMongoRepository analyticsRepository; private final PunishmentTypeService punishmentTypeService; @@ -82,7 +81,9 @@ private OverviewResponse computeOverview(Server server) { final int ticketChange = stats.previousTickets() > 0 ? (int) Math.round(((double) (stats.recentTickets() - stats.previousTickets()) / stats.previousTickets()) * 100) : 0; - final int playerChange = 0; // TODO: implement or remove + final int playerChange = stats.previousPlayers() > 0 + ? (int) Math.round(((double) (stats.recentPlayers() - stats.previousPlayers()) / stats.previousPlayers()) * 100) + : 0; return new OverviewResponse( stats.totalTickets(), @@ -111,32 +112,21 @@ private TicketAnalyticsResponse computeTicketAnalytics(Server server, String per .map(result -> new TicketAnalyticsResponse.CategoryCount(normalizeCategory(result.id()), result.count())) .toList(); - final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMM dd"); final List dailyTickets = analyticsRepository.aggregateDailyTicketCounts(server, startDate, ANALYTICS_TIME_ZONE) .stream() - .map(result -> new TicketAnalyticsResponse.DailyTicket( - formatDateLabel(result.id(), dateFormatter), - result.count() - )) + .map(result -> new TicketAnalyticsResponse.DailyTicket(result.id(), result.count())) .toList(); - final List avgResolution = Collections.emptyList(); + final List avgResolution = + analyticsRepository.aggregateAvgResolutionByCategory(server, startDate).stream() + .map(result -> new TicketAnalyticsResponse.CategoryResolutionTime( + normalizeCategory(result.id()), + result.avgMillis() / MILLIS_PER_HOUR)) + .toList(); return new TicketAnalyticsResponse(byStatus, byCategory, avgResolution, dailyTickets); } - private String formatDateLabel(String dateKey, DateTimeFormatter formatter) { - if (dateKey == null || dateKey.isBlank()) { - return "Unknown"; - } - - try { - return LocalDate.parse(dateKey).format(formatter); - } catch (Exception ignored) { - return dateKey; - } - } - private String normalizeCategory(String category) { try { return TicketCategory.fromCanonicalId(category).getDisplayName(); @@ -153,51 +143,47 @@ private String normalizeStatus(String status) { } } - public PunishmentAnalyticsResponse getPunishmentAnalytics(Server server, String period) { + @NotNull + public PunishmentAnalyticsResponse getPunishmentAnalytics(@NotNull Server server, @NotNull String period) { return punishmentAnalyticsCache.get(cacheKey(server, period), key -> computePunishmentAnalytics(server, period)); } private PunishmentAnalyticsResponse computePunishmentAnalytics(Server server, String period) { Date startDate = DateRangeUtil.getStartDate(period); - Document facetResults = analyticsRepository.aggregatePunishmentAnalytics(server, startDate, ANALYTICS_TIME_ZONE); - if (facetResults == null) { + PunishmentAnalyticsFacet facet = analyticsRepository.aggregatePunishmentAnalytics(server, startDate, ANALYTICS_TIME_ZONE); + if (facet == null) { return new PunishmentAnalyticsResponse(List.of(), List.of(), List.of()); } Map punishmentTypeNames = resolvePunishmentTypeNames(server); - List byType = toDocumentList(facetResults.get("byType")).stream() - .map(doc -> { - Object rawTypeOrdinal = doc.get("_id"); - Integer typeOrdinal = rawTypeOrdinal instanceof Number number ? number.intValue() : null; + List byType = facet.byType().stream() + .map(entry -> { + Integer typeOrdinal = entry.typeOrdinal(); String typeName = typeOrdinal != null ? punishmentTypeNames.getOrDefault(typeOrdinal, "Unknown") : "Unknown"; - return new PunishmentAnalyticsResponse.TypeCount(typeName, toInt(doc.get("count"))); + return new PunishmentAnalyticsResponse.TypeCount(typeName, entry.count()); }) .sorted((a, b) -> Integer.compare(b.count(), a.count())) .toList(); - // Collect potential staff IDs for batch resolution - List byStaffDocs = toDocumentList(facetResults.get("byStaff")); Set potentialIds = new HashSet<>(); - for (Document document : byStaffDocs) { - Object rawId = document.get("_id"); - if (rawId instanceof String s && !s.isBlank()) { - potentialIds.add(s); + for (var entry : facet.byStaff()) { + if (entry.issuerId() != null && !entry.issuerId().isBlank()) { + potentialIds.add(entry.issuerId()); } } Map resolvedStaff = issuerNameResolver.batchResolve(potentialIds, server); Map staffCountMap = new HashMap<>(); - for (Document document : byStaffDocs) { - Object rawId = document.get("_id"); + for (var entry : facet.byStaff()) { String staffName; - if (rawId instanceof String s && resolvedStaff.containsKey(s)) { - staffName = resolvedStaff.get(s); + if (entry.issuerId() != null && resolvedStaff.containsKey(entry.issuerId())) { + staffName = resolvedStaff.get(entry.issuerId()); } else { - staffName = normalizeStaffName(rawId); + staffName = normalizeStaffName(entry.issuerId()); } - staffCountMap.merge(staffName, toInt(document.get("count")), Integer::sum); + staffCountMap.merge(staffName, entry.count(), Integer::sum); } List byStaff = staffCountMap.entrySet() .stream() @@ -207,9 +193,8 @@ private PunishmentAnalyticsResponse computePunishmentAnalytics(Server server, St .toList(); Map dailyPunishmentMap = new LinkedHashMap<>(); - for (Document document : toDocumentList(facetResults.get("daily"))) { - String dayLabel = formatPunishmentDay(document.getString("_id")); - dailyPunishmentMap.merge(dayLabel, toInt(document.get("count")), Integer::sum); + for (var entry : facet.daily()) { + dailyPunishmentMap.merge(entry.date(), entry.count(), Integer::sum); } List dailyPunishments = dailyPunishmentMap.entrySet() .stream() @@ -226,20 +211,6 @@ private Map resolvePunishmentTypeNames(Server server) { return typeNames; } - private List toDocumentList(Object value) { - if (!(value instanceof List rawList)) { - return List.of(); - } - - List documents = new ArrayList<>(); - for (Object item : rawList) { - if (item instanceof Document document) { - documents.add(document); - } - } - return documents; - } - private String normalizeStaffName(Object rawStaffName) { if (rawStaffName == null) { return "Unknown"; @@ -249,13 +220,8 @@ private String normalizeStaffName(Object rawStaffName) { return normalized.isBlank() ? "Unknown" : normalized; } - private static final DateTimeFormatter SHORT_DATE_FORMATTER = DateTimeFormatter.ofPattern("MMM dd"); - - private String formatPunishmentDay(String dateKey) { - return formatDateLabel(dateKey, SHORT_DATE_FORMATTER); - } - - public AuditLogsAnalyticsResponse getAuditLogsAnalytics(Server server, String period) { + @NotNull + public AuditLogsAnalyticsResponse getAuditLogsAnalytics(@NotNull Server server, @NotNull String period) { return auditLogsAnalyticsCache.get(cacheKey(server, period), key -> computeAuditLogsAnalytics(server, period)); } @@ -280,7 +246,7 @@ private AuditLogsAnalyticsResponse computeAuditLogsAnalytics(Server server, Stri if (bucketKey == null) { continue; } - hourlyMap.merge(bucketKey, toInt(doc.get("count")), Integer::sum); + hourlyMap.merge(bucketKey, ProtoMapperSupport.intValueOrZero(doc.get("count")), Integer::sum); } ZoneId zone = ZoneId.of(ANALYTICS_TIME_ZONE); @@ -297,46 +263,34 @@ private AuditLogsAnalyticsResponse computeAuditLogsAnalytics(Server server, Stri return new AuditLogsAnalyticsResponse(byLevel, hourlyTrend); } - public PlayerActivityResponse getPlayerActivityAnalytics(Server server, String period) { + @NotNull + public PlayerActivityResponse getPlayerActivityAnalytics(@NotNull Server server, @NotNull String period) { return playerActivityCache.get(cacheKey(server, period), key -> computePlayerActivityAnalytics(server, period)); } private PlayerActivityResponse computePlayerActivityAnalytics(Server server, String period) { Date startDate = DateRangeUtil.getStartDate(period); - Document facetResults = analyticsRepository.aggregatePlayerActivity(server, startDate, ANALYTICS_TIME_ZONE); - if (facetResults == null) { + PlayerActivityFacet facet = analyticsRepository.aggregatePlayerActivity(server, startDate, ANALYTICS_TIME_ZONE); + if (facet == null) { return new PlayerActivityResponse(List.of(), List.of(), new PlayerActivityResponse.SuspiciousActivity(0, 0)); } - DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMM dd"); - - List newPlayersTrend = toDocumentList(facetResults.get("newPlayers")).stream() - .map(doc -> new PlayerActivityResponse.DailyCount( - formatDateLabel(doc.getString("_id"), dateFormatter), - toInt(doc.get("count")))) + List newPlayersTrend = facet.newPlayers().stream() + .map(entry -> new PlayerActivityResponse.DailyCount(entry.date(), entry.count())) .toList(); - List loginsByCountry = toDocumentList(facetResults.get("byCountry")).stream() - .map(doc -> new PlayerActivityResponse.CountryCount( - doc.getString("_id"), - toInt(doc.get("count")))) + List loginsByCountry = facet.byCountry().stream() + .map(entry -> new PlayerActivityResponse.CountryCount(entry.country(), entry.count())) .toList(); - List suspiciousList = toDocumentList(facetResults.get("suspicious")); - int proxyCount = 0; - int hostingCount = 0; - if (!suspiciousList.isEmpty()) { - Document suspicious = suspiciousList.getFirst(); - proxyCount = toInt(suspicious.get("proxyCount")); - hostingCount = toInt(suspicious.get("hostingCount")); - } - return new PlayerActivityResponse( newPlayersTrend, loginsByCountry, - new PlayerActivityResponse.SuspiciousActivity(proxyCount, hostingCount) + new PlayerActivityResponse.SuspiciousActivity( + facet.suspicious().proxyCount(), + facet.suspicious().hostingCount()) ); } diff --git a/src/main/java/gg/modl/backend/analytics/service/MetricSnapshotService.java b/src/main/java/gg/modl/backend/analytics/service/MetricSnapshotService.java index ed09831..df86d74 100644 --- a/src/main/java/gg/modl/backend/analytics/service/MetricSnapshotService.java +++ b/src/main/java/gg/modl/backend/analytics/service/MetricSnapshotService.java @@ -2,7 +2,7 @@ import gg.modl.backend.database.mongo.repository.MetricSnapshotMongoRepository; import gg.modl.backend.database.mongo.repository.ServerInstanceSnapshotMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; @@ -15,7 +15,9 @@ @RequiredArgsConstructor @Slf4j public class MetricSnapshotService { - private final ServerMongoRepository serverRepository; + private static final long FIVE_MINUTE_BOUNDARY_SECONDS = 300; + + private final ServerMetricsRepository serverMetricsRepository; private final MetricSnapshotMongoRepository metricSnapshotRepository; private final ServerInstanceSnapshotMongoRepository serverInstanceSnapshotRepository; @@ -24,12 +26,12 @@ public void takeSnapshot() { try { Instant nowInstant = Instant.now(); Date now = Date.from(nowInstant); - // Truncate to 5-minute boundary long epochSeconds = nowInstant.getEpochSecond(); - Date fiveTruncated = Date.from(Instant.ofEpochSecond((epochSeconds / 300) * 300)); + Date fiveTruncated = Date.from(Instant.ofEpochSecond( + (epochSeconds / FIVE_MINUTE_BOUNDARY_SECONDS) * FIVE_MINUTE_BOUNDARY_SECONDS)); Date fiveMinutesAgo = Date.from(nowInstant.minus(5, ChronoUnit.MINUTES)); - long activeServers = serverRepository.countActiveSince(fiveMinutesAgo); + long activeServers = serverMetricsRepository.countActiveSince(fiveMinutesAgo); metricSnapshotRepository.upsertSnapshot( fiveTruncated, diff --git a/src/main/java/gg/modl/backend/appeal/controller/PanelAppealController.java b/src/main/java/gg/modl/backend/appeal/controller/PanelAppealController.java index 8f81184..b043f41 100644 --- a/src/main/java/gg/modl/backend/appeal/controller/PanelAppealController.java +++ b/src/main/java/gg/modl/backend/appeal/controller/PanelAppealController.java @@ -1,97 +1,102 @@ -package gg.modl.backend.appeal.controller; - -import gg.modl.backend.appeal.service.AppealService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.appeal.dto.request.UpdateAppealStatusRequest; -import gg.modl.backend.log.service.PanelActionAuditor; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.dto.response.TicketResponse; -import gg.modl.proto.modl.v1.AddTicketReplyResponse; -import gg.modl.proto.modl.v1.AppealTicketsResponse; -import gg.modl.proto.modl.v1.PanelResource; -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_APPEALS) -@RequiredArgsConstructor -public class PanelAppealController { - private final AppealService appealService; - private final RealtimeEventPublisher realtimeEventPublisher; - private final PanelActionAuditor panelActionAuditor; - - @GetMapping("/punishment/{punishmentId}") - public ResponseEntity getAppealsByPunishment( - @PathVariable String punishmentId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List appeals = appealService.getAppealsByPunishment(server, punishmentId); - - if (appeals.isEmpty()) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.ok(PanelAppealProtoMapper.toAppealTicketsResponse(appeals)); - } - - @GetMapping("/{id}") - public ResponseEntity getAppealById( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelAppealProtoMapper.toTicketResponse(appealService.getAppealById(server, id))); - } - - @PostMapping("/{id}/replies") - public ResponseEntity addReply( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.AddAppealReplyRequest replyRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - TicketReply reply = appealService.addReply(server, id, PanelAppealProtoMapper.fromAddAppealReplyRequest(replyRequest)); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, id); - return ResponseEntity.status(HttpStatus.CREATED).body(PanelAppealProtoMapper.toAddReplyResponse(reply)); - } - - @PatchMapping("/{id}/status") - public ResponseEntity updateStatus( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.UpdateAppealStatusRequest statusRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - UpdateAppealStatusRequest command = PanelAppealProtoMapper.fromUpdateAppealStatusRequest(statusRequest); - TicketResponse appeal = appealService.updateStatus(server, id, command); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, id); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_PUNISHMENTS); - String actorEmail = RequestUtil.getSessionEmail(request); - if (command.status() != null) { - panelActionAuditor.recordModerationAction(server, actorEmail, describeAppealDecision(id, appeal, command)); - } else { - panelActionAuditor.recordStaffAction(server, actorEmail, "Updated appeal " + id); - } - return ResponseEntity.ok(PanelAppealProtoMapper.toTicketResponse(appeal)); - } - - private static String describeAppealDecision(String appealId, TicketResponse appeal, UpdateAppealStatusRequest command) { - String resolution = command.resolution(); - String resolutionSuffix = resolution != null && !resolution.isBlank() ? " (" + resolution + ")" : ""; - return "Appeal " + appealId + " " + appeal.status() + resolutionSuffix; - } -} +package gg.modl.backend.appeal.controller; + +import gg.modl.backend.appeal.service.AppealService; +import gg.modl.backend.infrastructure.authorization.PanelAccessRule; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.appeal.dto.request.UpdateAppealStatusRequest; +import gg.modl.backend.log.service.PanelActionAuditor; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.dto.response.TicketResponse; +import gg.modl.proto.modl.v1.AddAppealReplyRequest; +import gg.modl.proto.modl.v1.AddTicketReplyResponse; +import gg.modl.proto.modl.v1.AppealTicketsResponse; +import gg.modl.proto.modl.v1.PanelResource; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_APPEALS) +@RequiresPanelPermission(view = "ticket.view.all", modify = "appeal.modify") +@RequiredArgsConstructor +public class PanelAppealController { + private final AppealService appealService; + private final RealtimeEventPublisher realtimeEventPublisher; + private final PanelActionAuditor panelActionAuditor; + + @GetMapping("/punishment/{punishmentId}") + public ResponseEntity getAppealsByPunishment( + @PathVariable String punishmentId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List appeals = appealService.getAppealsByPunishment(server, punishmentId); + + if (appeals.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(PanelAppealProtoMapper.toAppealTicketsResponse(appeals)); + } + + @GetMapping("/{id}") + public ResponseEntity getAppealById( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelAppealProtoMapper.toTicketResponse(appealService.getAppealById(server, id))); + } + + @PostMapping("/{id}/replies") + @RequiresPanelPermission(rule = PanelAccessRule.APPEAL_REPLY) + public ResponseEntity addReply( + @PathVariable String id, + @RequestBody AddAppealReplyRequest replyRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + TicketReply reply = appealService.addReply(server, id, PanelAppealProtoMapper.fromAddAppealReplyRequest(replyRequest)); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, id); + return ResponseEntity.status(HttpStatus.CREATED).body(PanelAppealProtoMapper.toAddReplyResponse(reply)); + } + + @PatchMapping("/{id}/status") + public ResponseEntity updateStatus( + @PathVariable String id, + @RequestBody gg.modl.proto.modl.v1.UpdateAppealStatusRequest statusRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + UpdateAppealStatusRequest command = PanelAppealProtoMapper.fromUpdateAppealStatusRequest(statusRequest); + TicketResponse appeal = appealService.updateStatus(server, id, command); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, id); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_PUNISHMENTS); + String actorEmail = RequestUtil.getSessionEmail(request); + if (command.status() != null) { + panelActionAuditor.recordModerationAction(server, actorEmail, describeAppealDecision(id, appeal, command)); + } else { + panelActionAuditor.recordStaffAction(server, actorEmail, "Updated appeal " + id); + } + return ResponseEntity.ok(PanelAppealProtoMapper.toTicketResponse(appeal)); + } + + private static String describeAppealDecision(String appealId, TicketResponse appeal, UpdateAppealStatusRequest command) { + String resolution = command.resolution(); + String resolutionSuffix = resolution != null && !resolution.isBlank() ? " (" + resolution + ")" : ""; + return "Appeal " + appealId + " " + appeal.status() + resolutionSuffix; + } +} diff --git a/src/main/java/gg/modl/backend/appeal/controller/PanelAppealProtoMapper.java b/src/main/java/gg/modl/backend/appeal/controller/PanelAppealProtoMapper.java index cfda64c..e94dab8 100644 --- a/src/main/java/gg/modl/backend/appeal/controller/PanelAppealProtoMapper.java +++ b/src/main/java/gg/modl/backend/appeal/controller/PanelAppealProtoMapper.java @@ -2,9 +2,10 @@ import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.addAll; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.structToMap; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.valuesToObjects; -import com.google.protobuf.Value; import gg.modl.backend.appeal.dto.request.AddAppealReplyRequest; +import gg.modl.backend.appeal.dto.request.CreateAppealRequest; import gg.modl.backend.appeal.dto.request.UpdateAppealStatusRequest; import gg.modl.backend.ticket.controller.PanelTicketProtoMapper; import gg.modl.backend.ticket.data.TicketReply; @@ -34,17 +35,17 @@ static AddTicketReplyResponse toAddReplyResponse(TicketReply reply) { .build(); } - static gg.modl.backend.appeal.dto.request.CreateAppealRequest fromCreateAppealRequest( + static CreateAppealRequest fromCreateAppealRequest( gg.modl.proto.modl.v1.CreateAppealRequest request ) { - return new gg.modl.backend.appeal.dto.request.CreateAppealRequest( + return new CreateAppealRequest( request.getPunishmentId(), request.getPlayerUuid(), request.getEmail(), request.hasReason() ? request.getReason() : null, request.hasEvidence() ? request.getEvidence() : null, request.hasAdditionalData() ? structToMap(request.getAdditionalData()) : null, - valueListToObjects(request.getAttachmentsList()), + valuesToObjects(request.getAttachmentsList()), request.getFieldLabelsMap().isEmpty() ? null : java.util.Map.copyOf(request.getFieldLabelsMap()) ); } @@ -57,7 +58,7 @@ static AddAppealReplyRequest fromAddAppealReplyRequest(gg.modl.proto.modl.v1.Add request.getStaff(), request.hasAction() ? request.getAction() : null, request.hasAvatar() ? request.getAvatar() : null, - valueListToObjects(request.getAttachmentsList()) + valuesToObjects(request.getAttachmentsList()) ); } @@ -70,23 +71,4 @@ static UpdateAppealStatusRequest fromUpdateAppealStatusRequest(gg.modl.proto.mod ); } - static List valueListToObjects(List values) { - if (values.isEmpty()) { - return null; - } - return values.stream().map(PanelAppealProtoMapper::valueToObject).toList(); - } - - private static Object valueToObject(Value value) { - return switch (value.getKindCase()) { - case NULL_VALUE, KIND_NOT_SET -> null; - case NUMBER_VALUE -> value.getNumberValue(); - case STRING_VALUE -> value.getStringValue(); - case BOOL_VALUE -> value.getBoolValue(); - case STRUCT_VALUE -> structToMap(value.getStructValue()); - case LIST_VALUE -> value.getListValue().getValuesList().stream() - .map(PanelAppealProtoMapper::valueToObject) - .toList(); - }; - } } diff --git a/src/main/java/gg/modl/backend/appeal/controller/PublicAppealController.java b/src/main/java/gg/modl/backend/appeal/controller/PublicAppealController.java index 430c130..be9d16d 100644 --- a/src/main/java/gg/modl/backend/appeal/controller/PublicAppealController.java +++ b/src/main/java/gg/modl/backend/appeal/controller/PublicAppealController.java @@ -1,128 +1,133 @@ -package gg.modl.backend.appeal.controller; - -import gg.modl.backend.appeal.service.AppealService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.controller.PublicVerificationProtoMapper; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.dto.response.TicketResponse; -import gg.modl.backend.ticket.service.PublicRecordAccessService; -import gg.modl.backend.ticket.service.PublicRecordAccessService.Access; -import gg.modl.backend.ticket.service.PublicRecordAccessService.AccessResult; -import gg.modl.backend.ticket.service.PublicRecordVerificationService; -import gg.modl.proto.modl.v1.CreatePublicAppealResponse; -import gg.modl.proto.modl.v1.PanelResource; -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PUBLIC_APPEALS) -@RequiredArgsConstructor -public class PublicAppealController { - private final AppealService appealService; - private final PublicRecordAccessService recordAccessService; - private final PublicRecordVerificationService recordVerificationService; - private final RealtimeEventPublisher realtimeEventPublisher; - - @GetMapping("/{id}") - public ResponseEntity getAppeal( - @PathVariable String id, - @RequestParam(value = "token", required = false) String appealToken, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Ticket appeal = appealService.getAppealRaw(server, id).orElse(null); - AccessResult access = recordAccessService.authorize(server, appeal, appealToken); - if (access.access() == Access.NOT_FOUND) { - return ResponseEntity.notFound().build(); - } - if (access.access() == Access.TOKEN_REQUIRED) { - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(PublicVerificationProtoMapper.toVerificationRequiredResponse(id, access.emailHint())); - } - - TicketResponse appealResponse = appealService.toResponse(appeal); - return ResponseEntity.ok(PublicAppealProtoMapper.toPublicAppealResponse(appealResponse)); - } - - @PostMapping - public ResponseEntity createAppeal( - @RequestBody gg.modl.proto.modl.v1.CreateAppealRequest createRequest, - @RequestParam(value = "token", required = false) String appealToken, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - TicketResponse appeal = appealService.createAppeal(server, PanelAppealProtoMapper.fromCreateAppealRequest(createRequest), appealToken); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, appeal.id()); - return ResponseEntity.status(HttpStatus.CREATED).body(PublicAppealProtoMapper.toCreateAppealResponse(appeal)); - } - - @PostMapping("/{id}/replies") - public ResponseEntity addReply( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.AddAppealReplyRequest replyRequest, - @RequestParam(value = "token", required = false) String appealToken, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Ticket appeal = appealService.getAppealRaw(server, id).orElse(null); - AccessResult access = recordAccessService.authorize(server, appeal, appealToken); - if (access.access() == Access.NOT_FOUND) { - return ResponseEntity.notFound().build(); - } - if (access.access() == Access.TOKEN_REQUIRED) { - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(PublicVerificationProtoMapper.toVerificationRequiredResponse(id, access.emailHint())); - } - - List attachments = PanelAppealProtoMapper.valueListToObjects(replyRequest.getAttachmentsList()); - TicketReply reply = appealService.addPublicReply(server, id, replyRequest.getContent(), attachments); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, id); - return ResponseEntity.status(HttpStatus.CREATED).body(PublicAppealProtoMapper.toAddReplyResponse(reply)); - } - - @PostMapping("/{id}/request-verification") - public ResponseEntity requestVerification(@PathVariable String id, HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - - Ticket appeal = appealService.getAppealRaw(server, id).filter(a -> !a.isHidden()).orElse(null); - if (appeal == null) { - return ResponseEntity.notFound().build(); - } - - String emailHint = recordVerificationService.sendVerificationCode(server, appeal); - return ResponseEntity.ok(PublicVerificationProtoMapper.toRequestVerificationResponse(emailHint)); - } - - @PostMapping("/{id}/verify") - public ResponseEntity verifyCode( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.VerifyTicketCodeRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - if (appealService.getAppealRaw(server, id).filter(a -> !a.isHidden()).isEmpty()) { - return ResponseEntity.notFound().build(); - } - - String token = recordVerificationService.verifyCode(server, id, body.getCode()); - return ResponseEntity.ok(PublicVerificationProtoMapper.toVerifyResponse(token)); - } -} +package gg.modl.backend.appeal.controller; + +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.valuesToObjects; + +import gg.modl.backend.appeal.service.AppealService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.controller.PublicVerificationProtoMapper; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.dto.response.TicketResponse; +import gg.modl.backend.ticket.service.PublicRecordAccessService; +import gg.modl.backend.ticket.service.PublicRecordAccessService.Access; +import gg.modl.backend.ticket.service.PublicRecordAccessService.AccessResult; +import gg.modl.backend.ticket.service.PublicRecordVerificationService; +import gg.modl.proto.modl.v1.AddAppealReplyRequest; +import gg.modl.proto.modl.v1.CreateAppealRequest; +import gg.modl.proto.modl.v1.CreatePublicAppealResponse; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.VerifyTicketCodeRequest; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PUBLIC_APPEALS) +@RequiredArgsConstructor +public class PublicAppealController { + private final AppealService appealService; + private final PublicRecordAccessService recordAccessService; + private final PublicRecordVerificationService recordVerificationService; + private final RealtimeEventPublisher realtimeEventPublisher; + + @GetMapping("/{id}") + public ResponseEntity getAppeal( + @PathVariable String id, + @RequestParam(value = "token", required = false) String appealToken, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + Ticket appeal = appealService.getAppealRaw(server, id).orElse(null); + AccessResult access = recordAccessService.authorize(server, appeal, appealToken); + if (access.access() == Access.NOT_FOUND) { + return ResponseEntity.notFound().build(); + } + if (access.access() == Access.TOKEN_REQUIRED) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(PublicVerificationProtoMapper.toVerificationRequiredResponse(id, access.emailHint())); + } + + TicketResponse appealResponse = appealService.toResponse(appeal); + return ResponseEntity.ok(PublicAppealProtoMapper.toPublicAppealResponse(appealResponse)); + } + + @PostMapping + public ResponseEntity createAppeal( + @RequestBody CreateAppealRequest createRequest, + @RequestParam(value = "token", required = false) String appealToken, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + TicketResponse appeal = appealService.createAppeal(server, PanelAppealProtoMapper.fromCreateAppealRequest(createRequest), appealToken); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, appeal.id()); + return ResponseEntity.status(HttpStatus.CREATED).body(PublicAppealProtoMapper.toCreateAppealResponse(appeal)); + } + + @PostMapping("/{id}/replies") + public ResponseEntity addReply( + @PathVariable String id, + @RequestBody AddAppealReplyRequest replyRequest, + @RequestParam(value = "token", required = false) String appealToken, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + Ticket appeal = appealService.getAppealRaw(server, id).orElse(null); + AccessResult access = recordAccessService.authorize(server, appeal, appealToken); + if (access.access() == Access.NOT_FOUND) { + return ResponseEntity.notFound().build(); + } + if (access.access() == Access.TOKEN_REQUIRED) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(PublicVerificationProtoMapper.toVerificationRequiredResponse(id, access.emailHint())); + } + + List attachments = valuesToObjects(replyRequest.getAttachmentsList()); + TicketReply reply = appealService.addPublicReply(server, id, replyRequest.getContent(), attachments); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, id); + return ResponseEntity.status(HttpStatus.CREATED).body(PublicAppealProtoMapper.toAddReplyResponse(reply)); + } + + @PostMapping("/{id}/request-verification") + public ResponseEntity requestVerification(@PathVariable String id, HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + + Ticket appeal = appealService.getAppealRaw(server, id).filter(a -> !a.isHidden()).orElse(null); + if (appeal == null) { + return ResponseEntity.notFound().build(); + } + + String emailHint = recordVerificationService.sendVerificationCode(server, appeal); + return ResponseEntity.ok(PublicVerificationProtoMapper.toRequestVerificationResponse(emailHint)); + } + + @PostMapping("/{id}/verify") + public ResponseEntity verifyCode( + @PathVariable String id, + @RequestBody VerifyTicketCodeRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + if (appealService.getAppealRaw(server, id).filter(a -> !a.isHidden()).isEmpty()) { + return ResponseEntity.notFound().build(); + } + + String token = recordVerificationService.verifyCode(server, id, body.getCode()); + return ResponseEntity.ok(PublicVerificationProtoMapper.toVerifyResponse(token)); + } +} diff --git a/src/main/java/gg/modl/backend/appeal/service/AppealService.java b/src/main/java/gg/modl/backend/appeal/service/AppealService.java index 49342c7..eb79341 100644 --- a/src/main/java/gg/modl/backend/appeal/service/AppealService.java +++ b/src/main/java/gg/modl/backend/appeal/service/AppealService.java @@ -33,13 +33,13 @@ import java.util.Date; import java.util.HashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import gg.modl.backend.infrastructure.util.IdGenerator; +import gg.modl.backend.infrastructure.util.UuidUtils; import org.springframework.stereotype.Service; @Service @@ -109,7 +109,7 @@ public TicketResponse toResponse(Ticket appeal) { } public TicketResponse createAppeal(Server server, CreateAppealRequest request, String presentedToken) { - String playerUuid = normalizeUuid(request.playerUuid()); + String playerUuid = UuidUtils.normalize(request.playerUuid()); Player player = findPlayerWithPunishment(server, playerUuid, request.punishmentId()); if (player == null) { throw new ResourceNotFoundException("Punishment not found for the specified player"); @@ -356,7 +356,7 @@ private void addAppealRejectedNote(Server server, Ticket appeal, String staffUse } String punishmentId = (String) data.get("punishmentId"); - String playerUuid = normalizeUuid((String) data.get("playerUuid")); + String playerUuid = UuidUtils.normalize((String) data.get("playerUuid")); if (punishmentId == null || playerUuid == null) { return; @@ -373,12 +373,7 @@ private void addAppealRejectedNote(Server server, Ticket appeal, String staffUse null ); - Map dataUpdates = Map.of( - "data.appealOutcome", "Rejected", - "data.appealTicketId", appeal.getId() - ); - - punishmentMutationService.addPunishmentNote(server, playerUuid, punishmentId, appealRejectedNote, dataUpdates); + punishmentMutationService.recordAppealRejection(server, playerUuid, punishmentId, appealRejectedNote, appeal.getId()); } private void pardonPunishment(Server server, Ticket appeal, String staffUsername) { @@ -388,7 +383,7 @@ private void pardonPunishment(Server server, Ticket appeal, String staffUsername } String punishmentId = (String) data.get("punishmentId"); - String playerUuid = normalizeUuid((String) data.get("playerUuid")); + String playerUuid = UuidUtils.normalize((String) data.get("playerUuid")); if (punishmentId == null || playerUuid == null) { return; @@ -427,8 +422,7 @@ private void pardonPunishment(Server server, Ticket appeal, String staffUsername .filter(p -> punishmentId.equals(p.getId())) .findFirst() .orElse(null); - if (appealedPunishment != null && appealedPunishment.getData() != null - && Boolean.TRUE.equals(appealedPunishment.getData().get("altBlocking"))) { + if (appealedPunishment != null && appealedPunishment.data().altBlocking()) { punishmentLifecycleService.cascadePardonLinkedBans(server, punishmentId); } }); @@ -445,8 +439,4 @@ private TicketReply createSystemReply(String staffUsername, String content, Stri .action(action) .build(); } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } } diff --git a/src/main/java/gg/modl/backend/audit/controller/AuditController.java b/src/main/java/gg/modl/backend/audit/controller/AuditController.java index c6ce232..4e00287 100644 --- a/src/main/java/gg/modl/backend/audit/controller/AuditController.java +++ b/src/main/java/gg/modl/backend/audit/controller/AuditController.java @@ -1,235 +1,244 @@ -package gg.modl.backend.audit.controller; - -import gg.modl.backend.audit.service.AuditService; -import gg.modl.backend.audit.service.StaffPerformanceService; -import gg.modl.backend.infrastructure.exception.ForbiddenException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import gg.modl.backend.audit.dto.response.ActivePunishmentResponse; -import gg.modl.backend.audit.dto.response.PunishmentAuditResponse; -import gg.modl.backend.audit.dto.response.StaffDetailsResponse; -import gg.modl.backend.audit.dto.response.StaffPerformanceResponse; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.ActivePunishmentsAuditResponse; -import gg.modl.proto.modl.v1.AuditBulkOperationResponse; -import gg.modl.proto.modl.v1.AuditDatabaseTableResponse; -import gg.modl.proto.modl.v1.AuditRollbackResponse; -import gg.modl.proto.modl.v1.BulkPunishmentActionRequest; -import gg.modl.proto.modl.v1.DateRangeRollbackRequest; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.PunishmentAuditListResponse; -import gg.modl.proto.modl.v1.RollbackRequest; -import gg.modl.proto.modl.v1.StaffPerformanceListResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.Date; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_AUDIT) -@RequiredArgsConstructor -@Validated -public class AuditController { - private final AuditService auditService; - private final StaffPerformanceService staffPerformanceService; - private final PermissionService permissionService; - private final RealtimeEventPublisher realtimeEventPublisher; - - @GetMapping("/staff-performance") - public ResponseEntity getStaffPerformance( - @RequestParam(defaultValue = "30d") String period, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List performance = staffPerformanceService.getStaffPerformance(server, period); - return ResponseEntity.ok(AuditProtoMapper.toStaffPerformanceList(performance)); - } - - @GetMapping("/staff/{username}/details") - public ResponseEntity getStaffDetails( - @PathVariable String username, - @RequestParam(defaultValue = "30d") String period, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - StaffDetailsResponse details = staffPerformanceService.getStaffDetails(server, username, period); - return ResponseEntity.ok(AuditProtoMapper.toStaffDetails(details)); - } - - @GetMapping("/punishments/active") - public ResponseEntity getActivePunishments( - @RequestParam(defaultValue = "active") String status, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List punishments = auditService.getPunishmentsList(server, status); - return ResponseEntity.ok(AuditProtoMapper.toActivePunishments(punishments)); - } - - @GetMapping("/punishments") - public ResponseEntity getPunishments( - @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - @RequestParam(defaultValue = "false") boolean canRollback, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List punishments = auditService.getPunishments(server, limit, canRollback); - return ResponseEntity.ok(AuditProtoMapper.toPunishmentAuditList(punishments)); - } - - @PostMapping("/punishments/{id}/rollback") - public ResponseEntity rollbackPunishment( - @PathVariable String id, - @RequestBody(required = false) RollbackRequest rollbackRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - String performerUsername = RequestUtil.getCurrentUsername(request); - - String reason = rollbackRequest != null && rollbackRequest.hasReason() - ? rollbackRequest.getReason() : "Admin rollback"; - boolean success = auditService.rollbackPunishment(server, id, reason, performerUsername); - - if (success) { - invalidateAudit(server); - return ResponseEntity.ok( - AuditProtoMapper.toRollbackResponse(true, "Punishment rolled back successfully")); - } - return ResponseEntity.notFound().build(); - } - - @PostMapping("/staff/{username}/rollback-all") - public ResponseEntity rollbackAllByStaff( - @PathVariable String username, - @RequestBody(required = false) RollbackRequest rollbackRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - String performerUsername = RequestUtil.getCurrentUsername(request); - - String reason = rollbackRequest != null && rollbackRequest.hasReason() - ? rollbackRequest.getReason() : "Bulk rollback by admin"; - int count = auditService.rollbackAllPunishmentsByStaff(server, username, reason, performerUsername); - - invalidateAudit(server); - return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( - true, count, "Successfully rolled back " + count + " punishments")); - } - - @PostMapping("/staff/{username}/rollback-date-range") - public ResponseEntity rollbackByDateRange( - @PathVariable String username, - @RequestBody DateRangeRollbackRequest rollbackRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - String performerUsername = RequestUtil.getCurrentUsername(request); - - Date startDate = AuditProtoMapper.toDate(rollbackRequest.getStartDate()); - Date endDate = AuditProtoMapper.toDate(rollbackRequest.getEndDate()); - if (startDate == null || endDate == null) { - throw new ValidationException("Start date and end date are required"); - } - - String reason = rollbackRequest.hasReason() ? rollbackRequest.getReason() : "Bulk rollback by admin"; - int count = auditService.rollbackPunishmentsByDateRange( - server, - username, - startDate, - endDate, - reason, - performerUsername - ); - - invalidateAudit(server); - return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( - true, count, "Successfully rolled back " + count + " punishments")); - } - - @PostMapping("/punishments/bulk-pardon") - public ResponseEntity bulkPardon( - @RequestBody BulkPunishmentActionRequest actionRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - - String performerUsername = RequestUtil.getCurrentUsername(request); - int count = auditService.bulkPardonByType( - server, actionRequest.getTypeOrdinalsList(), actionRequest.getReason(), performerUsername); - - invalidateAudit(server); - return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( - true, count, "Successfully pardoned " + count + " punishments")); - } - - @PostMapping("/punishments/bulk-set-expiration") - public ResponseEntity bulkSetExpiration( - @RequestBody BulkPunishmentActionRequest actionRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - - if (!actionRequest.hasNewDurationMs()) { - throw new ValidationException("newDurationMs is required for set-expiration"); - } - - String performerUsername = RequestUtil.getCurrentUsername(request); - int count = auditService.bulkSetExpirationByType( - server, actionRequest.getTypeOrdinalsList(), actionRequest.getNewDurationMs(), - actionRequest.getReason(), performerUsername); - - invalidateAudit(server); - return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( - true, count, "Successfully updated expiration for " + count + " punishments")); - } - - private void requireSuperAdmin(Server server, HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (!permissionService.isSuperAdmin(server, email)) { - throw new ForbiddenException("Only super admins can perform this action"); - } - } - - private void invalidateAudit(Server server) { - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_AUDIT); - } - - @GetMapping("/database/{table}") - public ResponseEntity getDatabaseTable( - @PathVariable String table, - @RequestParam(defaultValue = "100") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - @RequestParam(defaultValue = "0") @Min(0) int skip, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - - if (!AuditService.ALLOWED_TABLES.contains(table)) { - throw new ValidationException("Invalid table name"); - } - - Map result = auditService.getDatabaseTable(server, table, limit, skip); - return ResponseEntity.ok(AuditProtoMapper.toDatabaseTableResponse(result)); - } -} +package gg.modl.backend.audit.controller; + +import gg.modl.backend.audit.service.AdminDatabaseBrowserService; +import gg.modl.backend.audit.service.AuditService; +import gg.modl.backend.audit.service.StaffPerformanceService; +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.backend.audit.dto.response.ActivePunishmentResponse; +import gg.modl.backend.audit.dto.response.PunishmentAuditResponse; +import gg.modl.backend.audit.dto.response.StaffDetailsResponse; +import gg.modl.backend.audit.dto.response.StaffPerformanceResponse; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.ActivePunishmentsAuditResponse; +import gg.modl.proto.modl.v1.AuditBulkOperationResponse; +import gg.modl.proto.modl.v1.AuditDatabaseTableResponse; +import gg.modl.proto.modl.v1.AuditRollbackResponse; +import gg.modl.proto.modl.v1.BulkPunishmentActionRequest; +import gg.modl.proto.modl.v1.DateRangeRollbackRequest; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.PunishmentAuditListResponse; +import gg.modl.proto.modl.v1.RollbackRequest; +import gg.modl.proto.modl.v1.StaffPerformanceListResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.Date; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_AUDIT) +@RequiresPanelPermission("admin.audit.view.logs") +@RequiredArgsConstructor +@Validated +public class AuditController { + private final AuditService auditService; + private final AdminDatabaseBrowserService adminDatabaseBrowserService; + private final StaffPerformanceService staffPerformanceService; + private final PermissionService permissionService; + private final RealtimeEventPublisher realtimeEventPublisher; + + @GetMapping("/staff-performance") + public ResponseEntity getStaffPerformance( + @RequestParam(defaultValue = "30d") String period, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List performance = staffPerformanceService.getStaffPerformance(server, period); + return ResponseEntity.ok(AuditProtoMapper.toStaffPerformanceList(performance)); + } + + @GetMapping("/staff/{username}/details") + public ResponseEntity getStaffDetails( + @PathVariable String username, + @RequestParam(defaultValue = "30d") String period, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + StaffDetailsResponse details = staffPerformanceService.getStaffDetails(server, username, period); + return ResponseEntity.ok(AuditProtoMapper.toStaffDetails(details)); + } + + @GetMapping("/punishments/active") + public ResponseEntity getActivePunishments( + @RequestParam(defaultValue = "active") String status, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List punishments = auditService.getPunishmentsList(server, status); + return ResponseEntity.ok(AuditProtoMapper.toActivePunishments(punishments)); + } + + @GetMapping("/punishments") + public ResponseEntity getPunishments( + @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + @RequestParam(defaultValue = "false") boolean canRollback, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List punishments = auditService.getPunishments(server, limit, canRollback); + return ResponseEntity.ok(AuditProtoMapper.toPunishmentAuditList(punishments)); + } + + @GetMapping("/database/{table}") + public ResponseEntity getDatabaseTable( + @PathVariable String table, + @RequestParam(defaultValue = "100") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + @RequestParam(defaultValue = "0") @Min(0) int skip, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + + if (!AdminDatabaseBrowserService.ALLOWED_TABLES.contains(table)) { + throw new ValidationException("Invalid table name"); + } + + Map result = adminDatabaseBrowserService.getDatabaseTable(server, table, limit, skip); + return ResponseEntity.ok(AuditProtoMapper.toDatabaseTableResponse(result)); + } + + @PostMapping("/punishments/{id}/rollback") + @RequiresPanelPermission(PermissionService.ADMIN_AUDIT_ROLLBACK) + public ResponseEntity rollbackPunishment( + @PathVariable String id, + @RequestBody(required = false) RollbackRequest rollbackRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + String performerUsername = RequestUtil.getCurrentUsername(request); + + String reason = rollbackRequest != null && rollbackRequest.hasReason() + ? rollbackRequest.getReason() : "Admin rollback"; + boolean success = auditService.rollbackPunishment(server, id, reason, performerUsername); + + if (success) { + invalidateAudit(server); + return ResponseEntity.ok( + AuditProtoMapper.toRollbackResponse(true, "Punishment rolled back successfully")); + } + return ResponseEntity.notFound().build(); + } + + @PostMapping("/staff/{username}/rollback-all") + @RequiresPanelPermission(PermissionService.ADMIN_AUDIT_ROLLBACK) + public ResponseEntity rollbackAllByStaff( + @PathVariable String username, + @RequestBody(required = false) RollbackRequest rollbackRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + String performerUsername = RequestUtil.getCurrentUsername(request); + + String reason = rollbackRequest != null && rollbackRequest.hasReason() + ? rollbackRequest.getReason() : "Bulk rollback by admin"; + int count = auditService.rollbackAllPunishmentsByStaff(server, username, reason, performerUsername); + + invalidateAudit(server); + return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( + true, count, "Successfully rolled back " + count + " punishments")); + } + + @PostMapping("/staff/{username}/rollback-date-range") + @RequiresPanelPermission(PermissionService.ADMIN_AUDIT_ROLLBACK) + public ResponseEntity rollbackByDateRange( + @PathVariable String username, + @RequestBody DateRangeRollbackRequest rollbackRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + String performerUsername = RequestUtil.getCurrentUsername(request); + + Date startDate = AuditProtoMapper.toDate(rollbackRequest.getStartDate()); + Date endDate = AuditProtoMapper.toDate(rollbackRequest.getEndDate()); + if (startDate == null || endDate == null) { + throw new ValidationException("Start date and end date are required"); + } + + String reason = rollbackRequest.hasReason() ? rollbackRequest.getReason() : "Bulk rollback by admin"; + int count = auditService.rollbackPunishmentsByDateRange( + server, + username, + startDate, + endDate, + reason, + performerUsername + ); + + invalidateAudit(server); + return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( + true, count, "Successfully rolled back " + count + " punishments")); + } + + @PostMapping("/punishments/bulk-pardon") + @RequiresPanelPermission(PermissionService.ADMIN_AUDIT_ROLLBACK) + public ResponseEntity bulkPardon( + @RequestBody BulkPunishmentActionRequest actionRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + + String performerUsername = RequestUtil.getCurrentUsername(request); + int count = auditService.bulkPardonByType( + server, actionRequest.getTypeOrdinalsList(), actionRequest.getReason(), performerUsername); + + invalidateAudit(server); + return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( + true, count, "Successfully pardoned " + count + " punishments")); + } + + @PostMapping("/punishments/bulk-set-expiration") + @RequiresPanelPermission(PermissionService.ADMIN_AUDIT_ROLLBACK) + public ResponseEntity bulkSetExpiration( + @RequestBody BulkPunishmentActionRequest actionRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + + if (!actionRequest.hasNewDurationMs()) { + throw new ValidationException("newDurationMs is required for set-expiration"); + } + + String performerUsername = RequestUtil.getCurrentUsername(request); + int count = auditService.bulkSetExpirationByType( + server, actionRequest.getTypeOrdinalsList(), actionRequest.getNewDurationMs(), + actionRequest.getReason(), performerUsername); + + invalidateAudit(server); + return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( + true, count, "Successfully updated expiration for " + count + " punishments")); + } + + private void requireSuperAdmin(Server server, HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (!permissionService.isSuperAdmin(server, email)) { + throw new ForbiddenException("Only super admins can perform this action"); + } + } + + private void invalidateAudit(Server server) { + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_AUDIT); + } +} diff --git a/src/main/java/gg/modl/backend/audit/data/AuditLog.java b/src/main/java/gg/modl/backend/audit/data/AuditLog.java index 760394b..6d52d27 100644 --- a/src/main/java/gg/modl/backend/audit/data/AuditLog.java +++ b/src/main/java/gg/modl/backend/audit/data/AuditLog.java @@ -1,44 +1,44 @@ -package gg.modl.backend.audit.data; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; -import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@GenerateMongoFields -@MongoFieldAliases({ - @MongoFieldAlias(name = "METADATA_ROLLED_BACK", path = "metadata.rolledBack"), - @MongoFieldAlias(name = "METADATA_ROLLBACK_DATE", path = "metadata.rollbackDate"), - @MongoFieldAlias(name = "METADATA_ROLLBACK_BY", path = "metadata.rollbackBy"), - @MongoFieldAlias(name = "METADATA_CAN_ROLLBACK", path = "metadata.canRollback") -}) -public class AuditLog { - @Id - private String id; - - @Field("created") - private Date created; - @Field("level") - private String level; - @Field("source") - private String source; - @Field("description") - private String description; - @Field("metadata") - @Builder.Default - private Map metadata = new HashMap<>(); -} +package gg.modl.backend.audit.data; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; +import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@GenerateMongoFields +@MongoFieldAliases({ + @MongoFieldAlias(name = "METADATA_ROLLED_BACK", path = "metadata.rolledBack"), + @MongoFieldAlias(name = "METADATA_ROLLBACK_DATE", path = "metadata.rollbackDate"), + @MongoFieldAlias(name = "METADATA_ROLLBACK_BY", path = "metadata.rollbackBy"), + @MongoFieldAlias(name = "METADATA_CAN_ROLLBACK", path = "metadata.canRollback") +}) +public class AuditLog { + @Id + private String id; + + @Field("created") + private Date created; + @Field("level") + private String level; + @Field("source") + private String source; + @Field("description") + private String description; + @Field("metadata") + @Builder.Default + private Map metadata = new HashMap<>(); +} diff --git a/src/main/java/gg/modl/backend/audit/dto/response/ActivePunishmentResponse.java b/src/main/java/gg/modl/backend/audit/dto/response/ActivePunishmentResponse.java index 66692ef..9a8dbe0 100644 --- a/src/main/java/gg/modl/backend/audit/dto/response/ActivePunishmentResponse.java +++ b/src/main/java/gg/modl/backend/audit/dto/response/ActivePunishmentResponse.java @@ -1,31 +1,31 @@ -package gg.modl.backend.audit.dto.response; - -import java.util.Date; -import java.util.List; - -public record ActivePunishmentResponse( - String id, - String playerId, - String playerName, - String type, - int typeOrdinal, - String category, - String staffName, - String reason, - Long duration, - Date issued, - Date started, - Date expires, - boolean active, - boolean hasEvidence, - int evidenceCount, - List evidence, - List attachedTicketIds -) { - public record EvidenceItem( - String text, - String url, - String type, - String fileName - ) {} -} +package gg.modl.backend.audit.dto.response; + +import java.util.Date; +import java.util.List; + +public record ActivePunishmentResponse( + String id, + String playerId, + String playerName, + String type, + int typeOrdinal, + String category, + String staffName, + String reason, + Long duration, + Date issued, + Date started, + Date expires, + boolean active, + boolean hasEvidence, + int evidenceCount, + List evidence, + List attachedTicketIds +) { + public record EvidenceItem( + String text, + String url, + String type, + String fileName + ) {} +} diff --git a/src/main/java/gg/modl/backend/audit/dto/response/PunishmentAuditResponse.java b/src/main/java/gg/modl/backend/audit/dto/response/PunishmentAuditResponse.java index f305bcf..cf2b9e0 100644 --- a/src/main/java/gg/modl/backend/audit/dto/response/PunishmentAuditResponse.java +++ b/src/main/java/gg/modl/backend/audit/dto/response/PunishmentAuditResponse.java @@ -1,17 +1,17 @@ -package gg.modl.backend.audit.dto.response; - -import java.util.Date; - -public record PunishmentAuditResponse( - String id, - String type, - String playerId, - String playerName, - String staffId, - String staffName, - String reason, - String duration, - Date timestamp, - boolean canRollback -) { -} +package gg.modl.backend.audit.dto.response; + +import java.util.Date; + +public record PunishmentAuditResponse( + String id, + String type, + String playerId, + String playerName, + String staffId, + String staffName, + String reason, + String duration, + Date timestamp, + boolean canRollback +) { +} diff --git a/src/main/java/gg/modl/backend/audit/dto/response/StaffDetailsResponse.java b/src/main/java/gg/modl/backend/audit/dto/response/StaffDetailsResponse.java index ac413f7..4104985 100644 --- a/src/main/java/gg/modl/backend/audit/dto/response/StaffDetailsResponse.java +++ b/src/main/java/gg/modl/backend/audit/dto/response/StaffDetailsResponse.java @@ -1,55 +1,55 @@ -package gg.modl.backend.audit.dto.response; - -import java.util.Date; -import java.util.List; - -public record StaffDetailsResponse( - String username, - String period, - List punishments, - List tickets, - List dailyActivity, - List punishmentTypeBreakdown, - int evidenceUploads, - Summary summary -) { - public record PunishmentDetail( - String id, - String playerId, - String playerName, - String type, - String reason, - String duration, - Date issued, - boolean active, - boolean rolledBack - ) {} - - public record TicketDetail( - String id, - String subject, - String category, - String status, - Date lastActivity, - int responseTime - ) {} - - public record DailyActivity( - String date, - int punishments, - int tickets, - int evidence - ) {} - - public record PunishmentTypeBreakdown( - String type, - int count - ) {} - - public record Summary( - int totalPunishments, - int totalTickets, - int avgResponseTime, - int evidenceUploads - ) {} -} +package gg.modl.backend.audit.dto.response; + +import java.util.Date; +import java.util.List; + +public record StaffDetailsResponse( + String username, + String period, + List punishments, + List tickets, + List dailyActivity, + List punishmentTypeBreakdown, + int evidenceUploads, + Summary summary +) { + public record PunishmentDetail( + String id, + String playerId, + String playerName, + String type, + String reason, + String duration, + Date issued, + boolean active, + boolean rolledBack + ) {} + + public record TicketDetail( + String id, + String subject, + String category, + String status, + Date lastActivity, + int responseTime + ) {} + + public record DailyActivity( + String date, + int punishments, + int tickets, + int evidence + ) {} + + public record PunishmentTypeBreakdown( + String type, + int count + ) {} + + public record Summary( + int totalPunishments, + int totalTickets, + int avgResponseTime, + int evidenceUploads + ) {} +} diff --git a/src/main/java/gg/modl/backend/audit/dto/response/StaffPerformanceResponse.java b/src/main/java/gg/modl/backend/audit/dto/response/StaffPerformanceResponse.java index a7c8716..f756633 100644 --- a/src/main/java/gg/modl/backend/audit/dto/response/StaffPerformanceResponse.java +++ b/src/main/java/gg/modl/backend/audit/dto/response/StaffPerformanceResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.audit.dto.response; - -import java.util.Date; - -public record StaffPerformanceResponse( - String id, - String username, - String role, - int totalActions, - int ticketResponses, - int punishmentsIssued, - int avgResponseTime, - Date lastActive -) { -} +package gg.modl.backend.audit.dto.response; + +import java.util.Date; + +public record StaffPerformanceResponse( + String id, + String username, + String role, + int totalActions, + int ticketResponses, + int punishmentsIssued, + int avgResponseTime, + Date lastActive +) { +} diff --git a/src/main/java/gg/modl/backend/audit/service/AdminDatabaseBrowserService.java b/src/main/java/gg/modl/backend/audit/service/AdminDatabaseBrowserService.java new file mode 100644 index 0000000..5b83af6 --- /dev/null +++ b/src/main/java/gg/modl/backend/audit/service/AdminDatabaseBrowserService.java @@ -0,0 +1,97 @@ +package gg.modl.backend.audit.service; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.repository.AdminDatabaseBrowserRepository; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.server.data.Server; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.bson.Document; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminDatabaseBrowserService { + + private final AdminDatabaseBrowserRepository databaseBrowserRepository; + + public static final Set ALLOWED_TABLES = Set.of( + CollectionName.PLAYERS, + CollectionName.SETTINGS, + CollectionName.STAFF, + CollectionName.STAFF_ROLES, + CollectionName.TICKETS, + CollectionName.TICKET_VERIFICATIONS, + CollectionName.LOGS, + CollectionName.KNOWLEDGEBASE_CATEGORIES, + CollectionName.KNOWLEDGEBASE_ARTICLES, + CollectionName.HOMEPAGE_CARDS + ); + + private static final Set SAFE_SETTINGS_TYPES = Set.of( + "general", "punishmentTypes", "quickResponses", "replayRetention", + "statusThresholds", "ticketForms", "ticketLabels"); + private static final List SECRET_FIELD_NAMES = List.of( + "api_key", "ticket_api_key", "minecraft_api_key", "apiKey", "webhookUrl", "token", "secret", "password"); + private static final String REDACTED = "[REDACTED]"; + + public Map getDatabaseTable( + Server server, String table, int limit, int skip) { + if (!ALLOWED_TABLES.contains(table)) { + throw new ValidationException("Invalid table name"); + } + + List documents = databaseBrowserRepository.readTable(server, table, limit, skip); + long total = databaseBrowserRepository.countCollection(server, table); + + return Map.of( + "data", redactDocuments(table, documents), + "total", total, + "limit", limit, + "skip", skip + ); + } + + private List redactDocuments(String table, List docs) { + if (docs == null) { + return Collections.emptyList(); + } + List redacted = new ArrayList<>(docs.size()); + for (Document orig : docs) { + Document copy = (Document) redactSecretFields(orig); + if (CollectionName.SETTINGS.equals(table) && !SAFE_SETTINGS_TYPES.contains(copy.getString("type"))) { + copy.put("data", REDACTED); + } + redacted.add(copy); + } + return redacted; + } + + private Object redactSecretFields(Object value) { + if (value instanceof Document document) { + Document copy = new Document(); + for (Map.Entry entry : document.entrySet()) { + copy.put(entry.getKey(), + isSecretFieldName(entry.getKey()) ? REDACTED : redactSecretFields(entry.getValue())); + } + return copy; + } + if (value instanceof List list) { + List copy = new ArrayList<>(list.size()); + for (Object element : list) { + copy.add(redactSecretFields(element)); + } + return copy; + } + return value; + } + + private boolean isSecretFieldName(String key) { + String lowerKey = key.toLowerCase(); + return SECRET_FIELD_NAMES.stream().anyMatch(secret -> lowerKey.contains(secret.toLowerCase())); + } +} diff --git a/src/main/java/gg/modl/backend/audit/service/AuditProjectionKeys.java b/src/main/java/gg/modl/backend/audit/service/AuditProjectionKeys.java new file mode 100644 index 0000000..327e2f8 --- /dev/null +++ b/src/main/java/gg/modl/backend/audit/service/AuditProjectionKeys.java @@ -0,0 +1,17 @@ +package gg.modl.backend.audit.service; + +final class AuditProjectionKeys { + private AuditProjectionKeys() { + } + + static final String ISSUER_NAME = "issuerName"; + static final String ISSUER_ID = "issuerId"; + static final String TYPE_ORDINAL = "typeOrdinal"; + static final String PUNISHMENT_ID = "punishmentId"; + static final String PLAYER_ID = "playerId"; + static final String DATA = "data"; + static final String MODIFICATIONS = "modifications"; + static final String EVIDENCE = "evidence"; + static final String REASON = "reason"; + static final String DURATION = "duration"; +} diff --git a/src/main/java/gg/modl/backend/audit/service/AuditService.java b/src/main/java/gg/modl/backend/audit/service/AuditService.java index bf7427e..8ad3828 100644 --- a/src/main/java/gg/modl/backend/audit/service/AuditService.java +++ b/src/main/java/gg/modl/backend/audit/service/AuditService.java @@ -1,675 +1,524 @@ -package gg.modl.backend.audit.service; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import gg.modl.backend.audit.data.AuditLog; -import gg.modl.backend.audit.dto.response.ActivePunishmentResponse; -import gg.modl.backend.audit.dto.response.PunishmentAuditResponse; -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.repository.AuditMongoRepository; -import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentModification; -import gg.modl.backend.player.data.punishment.PunishmentModificationType; -import gg.modl.backend.player.service.PlayerStatusCalculator; -import gg.modl.backend.player.service.PunishmentLifecycleService; -import gg.modl.backend.player.service.PunishmentMutationService; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeIndex; -import gg.modl.backend.settings.service.PunishmentTypeService; -import gg.modl.backend.staff.dto.response.StaffResponse; -import gg.modl.backend.staff.service.StaffService; -import gg.modl.backend.infrastructure.util.DateRangeUtil; -import gg.modl.backend.infrastructure.util.IdGenerator; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.bson.Document; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class AuditService { - - private final AuditMongoRepository auditRepository; - private final PunishmentTypeService punishmentTypeService; - private final StaffService staffService; - private final PlayerStatusCalculator statusCalculator; - private final PunishmentLifecycleService punishmentLifecycleService; - private final PunishmentMutationService punishmentMutationService; - - private final Cache> activePunishmentsCache = Caffeine.newBuilder() - .expireAfterWrite(Duration.ofSeconds(60)) - .maximumSize(500) - .build(); - - public static final Set ALLOWED_TABLES = Set.of( - CollectionName.PLAYERS, - CollectionName.SETTINGS, - CollectionName.STAFF, - CollectionName.STAFF_ROLES, - CollectionName.TICKETS, - CollectionName.TICKET_VERIFICATIONS, - CollectionName.LOGS, - CollectionName.KNOWLEDGEBASE_CATEGORIES, - CollectionName.KNOWLEDGEBASE_ARTICLES, - CollectionName.HOMEPAGE_CARDS - ); - - private static final Set SAFE_SETTINGS_TYPES = Set.of( - "general", "punishmentTypes", "quickResponses", "replayRetention", - "statusThresholds", "ticketForms", "ticketLabels"); - private static final List SECRET_FIELD_NAMES = List.of( - "api_key", "ticket_api_key", "minecraft_api_key", "apiKey", "webhookUrl", "token", "secret", "password"); - private static final String REDACTED = "[REDACTED]"; - private static final long PERMANENT_PUNISHMENT_DURATION = -1L; - - public List getPunishments( - Server server, int limit, boolean canRollbackOnly) { - Date thirtyDaysAgo = DateRangeUtil.getStartDate("30d"); - List logs = - auditRepository.findPunishmentLogs(server, thirtyDaysAgo, limit, canRollbackOnly); - - return logs.stream().map(logEntry -> { - Map metadata = logEntry.getMetadata() != null - ? logEntry.getMetadata() : Collections.emptyMap(); - return new PunishmentAuditResponse( - logEntry.getId(), - extractPunishmentType(logEntry.getDescription()), - getStringFromMetadata(metadata, "playerId", "unknown"), - getStringFromMetadata(metadata, "playerName", "Unknown"), - getStringFromMetadata(metadata, "staffId", logEntry.getSource()), - logEntry.getSource(), - getStringFromMetadata(metadata, "reason", - logEntry.getDescription()), - getStringFromMetadata(metadata, "duration", null), - logEntry.getCreated(), - !Boolean.FALSE.equals(metadata.get("canRollback")) - ); - }).toList(); - } - - private String extractPunishmentType(String description) { - if (description == null) { - return "Unknown"; - } - String lower = description.toLowerCase(); - if (lower.contains("ban")) { - return "Ban"; - } - if (lower.contains("mute")) { - return "Mute"; - } - if (lower.contains("kick")) { - return "Kick"; - } - if (lower.contains("warn")) { - return "Warn"; - } - return "Unknown"; - } - - private String getStringFromMetadata( - Map metadata, String key, String defaultValue) { - Object value = metadata.get(key); - if (value instanceof String stringValue) { - return stringValue; - } - return defaultValue; - } - - public List getActivePunishments(Server server) { - return getPunishmentsList(server, "active"); - } - - public List getPunishmentsList(Server server, String statusFilter) { - List all = - activePunishmentsCache.get(server.getId(), key -> computeAllPunishments(server)); - - boolean filterActive = "active".equalsIgnoreCase(statusFilter); - boolean filterInactive = "inactive".equalsIgnoreCase(statusFilter); - - List results = new ArrayList<>(); - for (ActivePunishmentResponse punishment : all) { - if (filterActive && !punishment.active()) { - continue; - } - if (filterInactive && punishment.active()) { - continue; - } - results.add(punishment); - } - return results; - } - - private List computeAllPunishments(Server server) { - List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); - Map typesByOrdinal = PunishmentTypeIndex.byOrdinal(punishmentTypes); - List rows = auditRepository.aggregatePunishmentRows(server); - Map resolvedIssuers = resolveIssuerNames(server, rows); - - List results = new ArrayList<>(); - for (Document row : rows) { - Punishment punishment = reconstructPunishment(row); - boolean active = statusCalculator.isPunishmentActive(punishment); - results.add(mapToActivePunishmentResponse( - server, row, punishment, active, typesByOrdinal, resolvedIssuers)); - } - return results; - } - - private ActivePunishmentResponse mapToActivePunishmentResponse( - Server server, Document row, Punishment punishment, boolean active, - Map typesByOrdinal, Map resolvedIssuers) { - int typeOrdinal = row.getInteger("typeOrdinal", 0); - String typeName = punishmentTypeService.getPunishmentTypeName(server, typeOrdinal); - PunishmentType matchedType = typesByOrdinal.get(typeOrdinal); - String category = matchedType != null - ? (matchedType.getCategory() != null ? matchedType.getCategory() : "Administrative") - : "Administrative"; - - Document data = row.get("data", Document.class); - String reason = data != null ? data.getString("reason") : null; - Long duration = extractDuration(data); - List evidenceItems = extractEvidenceItems(row); - - List ticketIds = row.getList("attachedTicketIds", String.class); - if (ticketIds == null) { - ticketIds = Collections.emptyList(); - } - - return new ActivePunishmentResponse( - row.getString("punishmentId"), - row.getString("playerId"), - AuditDocumentUtil.extractPlayerNameFromDoc(row), - typeName, - typeOrdinal, - category, - resolveIssuerFromDoc( - row.getString("issuerId"), - row.getString("issuerName"), - resolvedIssuers), - reason, - duration, - row.getDate("issued"), - row.getDate("started"), - statusCalculator.getEffectiveExpiry(punishment), - active, - !evidenceItems.isEmpty(), - evidenceItems.size(), - evidenceItems, - ticketIds - ); - } - - private List extractEvidenceItems(Document row) { - List evidenceDocs = row.getList("evidence", Document.class); - if (evidenceDocs == null) { - return Collections.emptyList(); - } - - List items = new ArrayList<>(); - for (Document evidenceDoc : evidenceDocs) { - items.add(new ActivePunishmentResponse.EvidenceItem( - evidenceDoc.getString("text"), - evidenceDoc.getString("url"), - evidenceDoc.getString("type"), - evidenceDoc.getString("fileName") - )); - } - return items; - } - - private static String resolveIssuerFromDoc( - String issuerId, String issuerName, Map resolvedIssuers) { - if (issuerId != null && resolvedIssuers.containsKey(issuerId)) { - return resolvedIssuers.get(issuerId); - } - if (issuerName != null) { - return issuerName; - } - return issuerId != null ? "Unknown Staff" : "Console"; - } - - private Long extractDuration(Document data) { - if (data == null) { - return null; - } - Object durationObj = data.get("duration"); - if (durationObj instanceof Number number) { - return number.longValue(); - } - return null; - } - - private Map resolveIssuerNames(Server server, List rows) { - Set issuerIds = new HashSet<>(); - for (Document doc : rows) { - String issuerId = doc.getString("issuerId"); - if (issuerId != null) { - issuerIds.add(issuerId); - } - } - return auditRepository.mapStaffUsernamesByIds(server, issuerIds); - } - - private Punishment reconstructPunishment(Document doc) { - Punishment punishment = new Punishment(); - String reconstructedId = doc.getString("punishmentId"); - if (reconstructedId == null) { - reconstructedId = doc.getString("id"); - } - punishment.setId(reconstructedId); - punishment.setTypeOrdinal(doc.getInteger("typeOrdinal", 0)); - punishment.setIssuerName( - doc.getString("issuerName") != null ? doc.getString("issuerName") : "Unknown"); - punishment.setIssuerId(doc.getString("issuerId")); - punishment.setIssued( - doc.getDate("issued") != null ? doc.getDate("issued") : new Date()); - punishment.setStarted(doc.getDate("started")); - - Document data = doc.get("data", Document.class); - if (data != null) { - punishment.setData(new HashMap<>(data)); - } - - punishment.setModifications(extractModifications(doc)); - punishment.setNotes(Collections.emptyList()); - punishment.setEvidence(Collections.emptyList()); - punishment.setAttachedTicketIds(Collections.emptyList()); - - return punishment; - } - - private List extractModifications(Document doc) { - List modDocs = doc.getList("modifications", Document.class); - if (modDocs == null) { - return new ArrayList<>(); - } - - List mods = new ArrayList<>(); - for (Document modDoc : modDocs) { - Long effectiveDuration = null; - Object edObj = modDoc.get("effectiveDuration"); - if (edObj instanceof Number num) { - effectiveDuration = num.longValue(); - } - mods.add(new PunishmentModification( - modDoc.getString("id"), - modDoc.getString("type"), - modDoc.getDate("date"), - modDoc.getString("issuerName"), - modDoc.getString("issuerId"), - modDoc.getString("reason"), - effectiveDuration, - modDoc.getString("appealTicketId"), - null - )); - } - return mods; - } - - public boolean rollbackPunishment( - Server server, String punishmentId, String reason, String performerUsername) { - Document player = auditRepository.findPlayerByPunishmentId(server, punishmentId); - if (player == null) { - return false; - } - Document punishment = findPunishmentSubdocument(player, punishmentId); - if (punishment == null) { - return false; - } - if (AuditDocumentUtil.hasModificationType(punishment, PunishmentModificationType.ROLLBACK.name())) { - throw new ValidationException("This punishment cannot be rolled back"); - } - - Date now = new Date(); - String playerId = player.getString("_id"); - auditRepository.appendPunishmentModification( - server, playerId, punishmentId, buildRollbackModification(performerUsername, reason, now)); - saveRollbackAuditLog( - server, playerId, - AuditDocumentUtil.extractPlayerNameFromDoc(player), punishment, - reason, performerUsername, now, - false, Objects.toString(punishment.getString("issuerName"), "")); - return true; - } - - private Map buildRollbackModification(String performerUsername, String reason, Date now) { - Map modification = new HashMap<>(); - modification.put("id", IdGenerator.generateShortId()); - modification.put("type", PunishmentModificationType.ROLLBACK.name()); - modification.put("date", now); - modification.put("issuerName", performerUsername); - modification.put("reason", reason != null ? reason : "Rollback"); - return modification; - } - - private Document findPunishmentSubdocument(Document player, String punishmentId) { - List punishments = player.getList("punishments", Document.class); - if (punishments == null) { - return null; - } - for (Document punishment : punishments) { - if (punishmentId.equals(punishment.getString("id"))) { - return punishment; - } - } - return null; - } - - private void saveRollbackAuditLog( - Server server, String playerId, String playerName, Document punishment, - String reason, String performerUsername, Date now, boolean bulk, String issuerUsername) { - int typeOrdinal = punishment.getInteger("typeOrdinal", 0); - String typeName = punishmentTypeService.getPunishmentTypeName(server, typeOrdinal); - String punishmentId = punishment.getString("id"); - - String description = bulk - ? "Bulk rollback: " + typeName + " for " + playerName + " (issued by " + issuerUsername + ")" - : "Rolled back " + typeName + " for " + (playerName.isEmpty() ? "unknown player" : playerName); - - AuditLog rollbackLog = AuditLog.builder() - .created(now) - .level("moderation") - .source(performerUsername) - .description(description) - .metadata(Map.of( - "punishmentId", punishmentId != null ? punishmentId : "", - "playerId", playerId != null ? playerId : "", - "playerName", playerName, - "staffUsername", issuerUsername, - "rollbackReason", reason != null ? reason : (bulk ? "Bulk rollback" : "Admin rollback"), - "punishmentType", typeName, - "bulkRollback", bulk - )) - .build(); - - auditRepository.saveAuditLog(server, rollbackLog); - } - - public Map getDatabaseTable( - Server server, String table, int limit, int skip) { - if (!ALLOWED_TABLES.contains(table)) { - throw new ValidationException("Invalid table name"); - } - - List documents = auditRepository.readTable(server, table, limit, skip); - long total = auditRepository.countCollection(server, table); - - return Map.of( - "data", redactDocuments(table, documents), - "total", total, - "limit", limit, - "skip", skip - ); - } - - private List redactDocuments(String table, List docs) { - if (docs == null) { - return Collections.emptyList(); - } - List redacted = new ArrayList<>(docs.size()); - for (Document orig : docs) { - Document copy = (Document) redactSecretFields(orig); - if (CollectionName.SETTINGS.equals(table) && !SAFE_SETTINGS_TYPES.contains(copy.getString("type"))) { - copy.put("data", REDACTED); - } - redacted.add(copy); - } - return redacted; - } - - private Object redactSecretFields(Object value) { - if (value instanceof Document document) { - Document copy = new Document(); - for (Map.Entry entry : document.entrySet()) { - copy.put(entry.getKey(), - isSecretFieldName(entry.getKey()) ? REDACTED : redactSecretFields(entry.getValue())); - } - return copy; - } - if (value instanceof List list) { - List copy = new ArrayList<>(list.size()); - for (Object element : list) { - copy.add(redactSecretFields(element)); - } - return copy; - } - return value; - } - - private boolean isSecretFieldName(String key) { - String lowerKey = key.toLowerCase(); - return SECRET_FIELD_NAMES.stream().anyMatch(secret -> lowerKey.contains(secret.toLowerCase())); - } - - public int rollbackAllPunishmentsByStaff( - Server server, String staffUsername, String reason, String performerUsername) { - String staffId = staffService.getStaffByUsername(server, staffUsername) - .map(StaffResponse::id) - .orElse(null); - return rollbackPunishmentsInternal( - server, staffUsername, staffId, null, null, reason, performerUsername); - } - - private int rollbackPunishmentsInternal( - Server server, String staffUsername, String staffId, - Date startDate, Date endDate, String reason, String performerUsername) { - try { - List players = - auditRepository.findPlayersForRollback(server, staffUsername, staffId); - Date now = new Date(); - - int rollbackCount = 0; - for (Document player : players) { - rollbackCount += applyRollbackToPlayer( - server, player, staffUsername, staffId, - startDate, endDate, reason, performerUsername, now); - } - return rollbackCount; - } catch (Exception e) { - log.error("Error during bulk rollback for staff {}", staffUsername, e); - throw new ExternalServiceException("Failed to rollback punishments", e); - } - } - - private int applyRollbackToPlayer( - Server server, Document player, String staffUsername, String staffId, - Date startDate, Date endDate, String reason, String performerUsername, Date now) { - String playerId = player.getString("_id"); - List punishments = player.getList("punishments", Document.class); - if (punishments == null) { - return 0; - } - - String playerName = AuditDocumentUtil.extractPlayerNameFromDoc(player); - int count = 0; - - for (Document punishment : punishments) { - if (!matchesIssuer(punishment, staffUsername, staffId)) { - continue; - } - if (!isWithinDateRange(punishment.getDate("issued"), startDate, endDate)) { - continue; - } - if (AuditDocumentUtil.hasModificationType(punishment, PunishmentModificationType.ROLLBACK.name())) { - continue; - } - - auditRepository.appendPunishmentModification( - server, playerId, punishment.getString("id"), - buildRollbackModification(performerUsername, reason, now)); - saveRollbackAuditLog( - server, playerId, playerName, punishment, - reason, performerUsername, now, true, staffUsername); - count++; - } - - return count; - } - - private boolean matchesIssuer( - Document punishment, String staffUsername, String staffId) { - String issuerName = punishment.getString("issuerName"); - String issuerId = punishment.getString("issuerId"); - return (issuerName != null && issuerName.equalsIgnoreCase(staffUsername)) - || (staffId != null && staffId.equals(issuerId)); - } - - private boolean isWithinDateRange(Date issued, Date startDate, Date endDate) { - if (startDate == null || endDate == null) { - return true; - } - return issued != null && !issued.before(startDate) && !issued.after(endDate); - } - - public int rollbackPunishmentsByDateRange( - Server server, String staffUsername, Date startDate, Date endDate, - String reason, String performerUsername) { - String staffId = staffService.getStaffByUsername(server, staffUsername) - .map(StaffResponse::id) - .orElse(null); - return rollbackPunishmentsInternal( - server, staffUsername, staffId, startDate, endDate, reason, performerUsername); - } - - public int bulkPardonByType( - Server server, List typeOrdinals, String reason, String performerUsername) { - return processBulkPunishmentAction(server, typeOrdinals, reason, performerUsername, - "bulk pardon", (ctx) -> { - if (AuditDocumentUtil.hasModificationType(ctx.punishmentDoc, - PunishmentModificationType.MANUAL_PARDON.name(), - PunishmentModificationType.APPEAL_ACCEPT.name(), - PunishmentModificationType.SYSTEM_PARDON.name())) { - return false; - } - - PunishmentOperationResult result = punishmentLifecycleService.pardonPunishment( - server, ctx.punishmentId, performerUsername, null, reason); - if (!result.success()) { - return false; - } - - AuditLog pardonLog = buildBulkAuditLog(ctx, performerUsername, - "Bulk pardon: " + ctx.typeName + " for " + ctx.playerName, - Map.of("pardonReason", reason != null ? reason : "", "bulkPardon", true)); - auditRepository.saveAuditLog(server, pardonLog); - return true; - }); - } - - public int bulkSetExpirationByType( - Server server, List typeOrdinals, long newDurationMs, - String reason, String performerUsername) { - long effectiveDuration = newDurationMs <= 0 ? PERMANENT_PUNISHMENT_DURATION : newDurationMs; - - return processBulkPunishmentAction(server, typeOrdinals, reason, performerUsername, - "bulk set expiration", (ctx) -> { - PunishmentOperationResult result = punishmentMutationService.changeDuration( - server, ctx.punishmentId, effectiveDuration, performerUsername, null); - if (!result.success()) { - return false; - } - - AuditLog durationLog = buildBulkAuditLog(ctx, performerUsername, - "Bulk duration change: " + ctx.typeName + " for " + ctx.playerName, - Map.of("reason", reason != null ? reason : "", "newDurationMs", newDurationMs, - "bulkDurationChange", true)); - auditRepository.saveAuditLog(server, durationLog); - return true; - }); - } - - private int processBulkPunishmentAction( - Server server, List typeOrdinals, String reason, - String performerUsername, String operationName, BulkPunishmentAction action) { - try { - List players = auditRepository.findPlayersForBulkAction(server, typeOrdinals); - Date now = new Date(); - int count = 0; - - Map typeNameCache = new HashMap<>(); - for (int ordinal : typeOrdinals) { - typeNameCache.put(ordinal, punishmentTypeService.getPunishmentTypeName(server, ordinal)); - } - - for (Document player : players) { - String playerId = player.getString("_id"); - List punishments = player.getList("punishments", Document.class); - if (punishments == null) { - continue; - } - - String playerName = AuditDocumentUtil.extractPlayerNameFromDoc(player); - - for (Document punishmentDoc : punishments) { - int typeOrdinal = punishmentDoc.getInteger("typeOrdinal", 0); - if (!typeOrdinals.contains(typeOrdinal)) { - continue; - } - - Punishment punishment = reconstructPunishment(punishmentDoc); - if (!statusCalculator.isPunishmentActive(punishment)) { - continue; - } - - String punishmentId = punishmentDoc.getString("id"); - String typeName = typeNameCache.getOrDefault(typeOrdinal, "Unknown"); - - BulkActionContext ctx = new BulkActionContext( - playerId, playerName, punishmentId, punishmentDoc, typeName, typeOrdinal, now); - if (action.apply(ctx)) { - count++; - } - } - } - activePunishmentsCache.invalidate(server.getId()); - return count; - } catch (Exception e) { - log.error("Error during {}", operationName, e); - throw new ExternalServiceException("Failed to " + operationName, e); - } - } - - private AuditLog buildBulkAuditLog( - BulkActionContext ctx, String performerUsername, - String description, Map extraMetadata) { - Map metadata = new HashMap<>(); - metadata.put("punishmentId", ctx.punishmentId != null ? ctx.punishmentId : ""); - metadata.put("playerId", ctx.playerId != null ? ctx.playerId : ""); - metadata.put("playerName", ctx.playerName); - metadata.put("punishmentType", ctx.typeName); - metadata.putAll(extraMetadata); - - return AuditLog.builder() - .created(ctx.now) - .level("moderation") - .source(performerUsername) - .description(description) - .metadata(metadata) - .build(); - } - - private record BulkActionContext( - String playerId, String playerName, String punishmentId, - Document punishmentDoc, String typeName, int typeOrdinal, Date now) {} - - @FunctionalInterface - private interface BulkPunishmentAction { - boolean apply(BulkActionContext ctx); - } -} +package gg.modl.backend.audit.service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import gg.modl.backend.audit.data.AuditLog; +import gg.modl.backend.audit.dto.response.ActivePunishmentResponse; +import gg.modl.backend.audit.dto.response.PunishmentAuditResponse; +import gg.modl.backend.database.mongo.repository.AuditLogRepository; +import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentModificationType; +import gg.modl.backend.player.service.PlayerStatusCalculator; +import gg.modl.backend.player.service.PunishmentLifecycleService; +import gg.modl.backend.player.service.PunishmentMutationService; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeIndex; +import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.backend.staff.dto.response.StaffResponse; +import gg.modl.backend.staff.service.StaffService; +import gg.modl.backend.infrastructure.util.DateRangeUtil; +import gg.modl.backend.infrastructure.util.IdGenerator; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AuditService { + + private final AuditLogRepository auditLogRepository; + private final PunishmentMongoRepository punishmentRepository; + private final StaffMongoRepository staffMongoRepository; + private final PunishmentTypeService punishmentTypeService; + private final StaffService staffService; + private final PlayerStatusCalculator statusCalculator; + private final PunishmentLifecycleService punishmentLifecycleService; + private final PunishmentMutationService punishmentMutationService; + + private final Cache> activePunishmentsCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(60)) + .maximumSize(500) + .build(); + + private static final long PERMANENT_PUNISHMENT_DURATION = -1L; + + public List getPunishments( + Server server, int limit, boolean canRollbackOnly) { + Date thirtyDaysAgo = DateRangeUtil.getStartDate("30d"); + List logs = + auditLogRepository.findPunishmentLogs(server, thirtyDaysAgo, limit, canRollbackOnly); + + return logs.stream().map(logEntry -> { + Map metadata = logEntry.getMetadata() != null + ? logEntry.getMetadata() : Collections.emptyMap(); + return new PunishmentAuditResponse( + logEntry.getId(), + extractPunishmentType(logEntry.getDescription()), + getStringFromMetadata(metadata, "playerId", "unknown"), + getStringFromMetadata(metadata, "playerName", "Unknown"), + getStringFromMetadata(metadata, "staffId", logEntry.getSource()), + logEntry.getSource(), + getStringFromMetadata(metadata, "reason", + logEntry.getDescription()), + getStringFromMetadata(metadata, "duration", null), + logEntry.getCreated(), + !Boolean.FALSE.equals(metadata.get("canRollback")) + ); + }).toList(); + } + + private String extractPunishmentType(String description) { + if (description == null) { + return "Unknown"; + } + String lower = description.toLowerCase(); + if (lower.contains("ban")) { + return "Ban"; + } + if (lower.contains("mute")) { + return "Mute"; + } + if (lower.contains("kick")) { + return "Kick"; + } + if (lower.contains("warn")) { + return "Warn"; + } + return "Unknown"; + } + + private String getStringFromMetadata( + Map metadata, String key, String defaultValue) { + Object value = metadata.get(key); + if (value instanceof String stringValue) { + return stringValue; + } + return defaultValue; + } + + public List getPunishmentsList(Server server, String statusFilter) { + List all = + activePunishmentsCache.get(server.getId(), key -> computeAllPunishments(server)); + + boolean filterActive = "active".equalsIgnoreCase(statusFilter); + boolean filterInactive = "inactive".equalsIgnoreCase(statusFilter); + + List results = new ArrayList<>(); + for (ActivePunishmentResponse punishment : all) { + if (filterActive && !punishment.active()) { + continue; + } + if (filterInactive && punishment.active()) { + continue; + } + results.add(punishment); + } + return results; + } + + private List computeAllPunishments(Server server) { + List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); + Map typesByOrdinal = PunishmentTypeIndex.byOrdinal(punishmentTypes); + List rows = punishmentRepository.aggregatePunishmentRows(server); + Map resolvedIssuers = resolveIssuerNames(server, rows); + + List results = new ArrayList<>(); + for (Document row : rows) { + Punishment punishment = PunishmentDocumentMapper.reconstructPunishment(row); + boolean active = statusCalculator.isPunishmentActive(punishment); + results.add(mapToActivePunishmentResponse( + server, row, punishment, active, typesByOrdinal, resolvedIssuers)); + } + return results; + } + + private ActivePunishmentResponse mapToActivePunishmentResponse( + Server server, Document row, Punishment punishment, boolean active, + Map typesByOrdinal, Map resolvedIssuers) { + int typeOrdinal = row.getInteger(AuditProjectionKeys.TYPE_ORDINAL, 0); + String typeName = punishmentTypeService.getPunishmentTypeName(server, typeOrdinal); + PunishmentType matchedType = typesByOrdinal.get(typeOrdinal); + String category = matchedType != null + ? (matchedType.getCategory() != null ? matchedType.getCategory() : "Administrative") + : "Administrative"; + + Document data = row.get(AuditProjectionKeys.DATA, Document.class); + String reason = data != null ? data.getString(AuditProjectionKeys.REASON) : null; + Long duration = extractDuration(data); + List evidenceItems = PunishmentDocumentMapper.extractEvidenceItems(row); + + List ticketIds = row.getList("attachedTicketIds", String.class); + if (ticketIds == null) { + ticketIds = Collections.emptyList(); + } + + return new ActivePunishmentResponse( + row.getString(AuditProjectionKeys.PUNISHMENT_ID), + row.getString(AuditProjectionKeys.PLAYER_ID), + AuditDocumentUtil.extractPlayerNameFromDoc(row), + typeName, + typeOrdinal, + category, + resolveIssuerFromDoc( + row.getString(AuditProjectionKeys.ISSUER_ID), + row.getString(AuditProjectionKeys.ISSUER_NAME), + resolvedIssuers), + reason, + duration, + row.getDate("issued"), + row.getDate("started"), + statusCalculator.getEffectiveExpiry(punishment), + active, + !evidenceItems.isEmpty(), + evidenceItems.size(), + evidenceItems, + ticketIds + ); + } + + private static String resolveIssuerFromDoc( + String issuerId, String issuerName, Map resolvedIssuers) { + if (issuerId != null && resolvedIssuers.containsKey(issuerId)) { + return resolvedIssuers.get(issuerId); + } + if (issuerName != null) { + return issuerName; + } + return issuerId != null ? "Unknown Staff" : "Console"; + } + + private Long extractDuration(Document data) { + if (data == null) { + return null; + } + Object durationObj = data.get(AuditProjectionKeys.DURATION); + if (durationObj instanceof Number number) { + return number.longValue(); + } + return null; + } + + private Map resolveIssuerNames(Server server, List rows) { + Set issuerIds = new HashSet<>(); + for (Document doc : rows) { + String issuerId = doc.getString(AuditProjectionKeys.ISSUER_ID); + if (issuerId != null) { + issuerIds.add(issuerId); + } + } + return staffMongoRepository.findUsernamesByIds(server, issuerIds); + } + + public boolean rollbackPunishment( + Server server, String punishmentId, String reason, String performerUsername) { + Document player = punishmentRepository.findPlayerByPunishmentId(server, punishmentId); + if (player == null) { + return false; + } + Document punishment = findPunishmentSubdocument(player, punishmentId); + if (punishment == null) { + return false; + } + if (AuditDocumentUtil.hasModificationType(punishment, PunishmentModificationType.ROLLBACK.name())) { + throw new ValidationException("This punishment cannot be rolled back"); + } + + Date now = new Date(); + String playerId = player.getString("_id"); + punishmentRepository.appendPunishmentModification( + server, playerId, punishmentId, buildRollbackModification(performerUsername, reason, now)); + saveRollbackAuditLog( + server, playerId, + AuditDocumentUtil.extractPlayerNameFromDoc(player), punishment, + reason, performerUsername, now, + false, Objects.toString(punishment.getString("issuerName"), "")); + return true; + } + + private Map buildRollbackModification(String performerUsername, String reason, Date now) { + Map modification = new HashMap<>(); + modification.put("id", IdGenerator.generateShortId()); + modification.put("type", PunishmentModificationType.ROLLBACK.name()); + modification.put("date", now); + modification.put("issuerName", performerUsername); + modification.put("reason", reason != null ? reason : "Rollback"); + return modification; + } + + private Document findPunishmentSubdocument(Document player, String punishmentId) { + List punishments = player.getList("punishments", Document.class); + if (punishments == null) { + return null; + } + for (Document punishment : punishments) { + if (punishmentId.equals(punishment.getString("id"))) { + return punishment; + } + } + return null; + } + + private void saveRollbackAuditLog( + Server server, String playerId, String playerName, Document punishment, + String reason, String performerUsername, Date now, boolean bulk, String issuerUsername) { + int typeOrdinal = punishment.getInteger("typeOrdinal", 0); + String typeName = punishmentTypeService.getPunishmentTypeName(server, typeOrdinal); + String punishmentId = punishment.getString("id"); + + String description = bulk + ? "Bulk rollback: " + typeName + " for " + playerName + " (issued by " + issuerUsername + ")" + : "Rolled back " + typeName + " for " + (playerName.isEmpty() ? "unknown player" : playerName); + + AuditLog rollbackLog = AuditLog.builder() + .created(now) + .level("moderation") + .source(performerUsername) + .description(description) + .metadata(Map.of( + "punishmentId", punishmentId != null ? punishmentId : "", + "playerId", playerId != null ? playerId : "", + "playerName", playerName, + "staffUsername", issuerUsername, + "rollbackReason", reason != null ? reason : (bulk ? "Bulk rollback" : "Admin rollback"), + "punishmentType", typeName, + "bulkRollback", bulk + )) + .build(); + + auditLogRepository.saveAuditLog(server, rollbackLog); + } + + public int rollbackAllPunishmentsByStaff( + Server server, String staffUsername, String reason, String performerUsername) { + String staffId = staffService.getStaffByUsername(server, staffUsername) + .map(StaffResponse::id) + .orElse(null); + return rollbackPunishmentsInternal( + server, staffUsername, staffId, null, null, reason, performerUsername); + } + + private int rollbackPunishmentsInternal( + Server server, String staffUsername, String staffId, + Date startDate, Date endDate, String reason, String performerUsername) { + try { + List players = + punishmentRepository.findPlayersForRollback(server, staffUsername, staffId); + Date now = new Date(); + + int rollbackCount = 0; + for (Document player : players) { + rollbackCount += applyRollbackToPlayer( + server, player, staffUsername, staffId, + startDate, endDate, reason, performerUsername, now); + } + return rollbackCount; + } catch (Exception e) { + log.error("Error during bulk rollback for staff {}", staffUsername, e); + throw new ExternalServiceException("Failed to rollback punishments", e); + } + } + + private int applyRollbackToPlayer( + Server server, Document player, String staffUsername, String staffId, + Date startDate, Date endDate, String reason, String performerUsername, Date now) { + String playerId = player.getString("_id"); + List punishments = player.getList("punishments", Document.class); + if (punishments == null) { + return 0; + } + + String playerName = AuditDocumentUtil.extractPlayerNameFromDoc(player); + int count = 0; + + for (Document punishment : punishments) { + if (!matchesIssuer(punishment, staffUsername, staffId)) { + continue; + } + if (!isWithinDateRange(punishment.getDate("issued"), startDate, endDate)) { + continue; + } + if (AuditDocumentUtil.hasModificationType(punishment, PunishmentModificationType.ROLLBACK.name())) { + continue; + } + + punishmentRepository.appendPunishmentModification( + server, playerId, punishment.getString("id"), + buildRollbackModification(performerUsername, reason, now)); + saveRollbackAuditLog( + server, playerId, playerName, punishment, + reason, performerUsername, now, true, staffUsername); + count++; + } + + return count; + } + + private boolean matchesIssuer( + Document punishment, String staffUsername, String staffId) { + String issuerName = punishment.getString("issuerName"); + String issuerId = punishment.getString("issuerId"); + return (issuerName != null && issuerName.equalsIgnoreCase(staffUsername)) + || (staffId != null && staffId.equals(issuerId)); + } + + private boolean isWithinDateRange(Date issued, Date startDate, Date endDate) { + if (startDate == null || endDate == null) { + return true; + } + return issued != null && !issued.before(startDate) && !issued.after(endDate); + } + + public int rollbackPunishmentsByDateRange( + Server server, String staffUsername, Date startDate, Date endDate, + String reason, String performerUsername) { + String staffId = staffService.getStaffByUsername(server, staffUsername) + .map(StaffResponse::id) + .orElse(null); + return rollbackPunishmentsInternal( + server, staffUsername, staffId, startDate, endDate, reason, performerUsername); + } + + public int bulkPardonByType( + Server server, List typeOrdinals, String reason, String performerUsername) { + return processBulkPunishmentAction(server, typeOrdinals, reason, performerUsername, + "bulk pardon", (ctx) -> { + if (AuditDocumentUtil.hasModificationType(ctx.punishmentDoc, + PunishmentModificationType.MANUAL_PARDON.name(), + PunishmentModificationType.APPEAL_ACCEPT.name(), + PunishmentModificationType.SYSTEM_PARDON.name())) { + return false; + } + + PunishmentOperationResult result = punishmentLifecycleService.pardonPunishment( + server, ctx.punishmentId, performerUsername, null, reason); + if (!result.success()) { + return false; + } + + AuditLog pardonLog = buildBulkAuditLog(ctx, performerUsername, + "Bulk pardon: " + ctx.typeName + " for " + ctx.playerName, + Map.of("pardonReason", reason != null ? reason : "", "bulkPardon", true)); + auditLogRepository.saveAuditLog(server, pardonLog); + return true; + }); + } + + public int bulkSetExpirationByType( + Server server, List typeOrdinals, long newDurationMs, + String reason, String performerUsername) { + long effectiveDuration = newDurationMs <= 0 ? PERMANENT_PUNISHMENT_DURATION : newDurationMs; + + return processBulkPunishmentAction(server, typeOrdinals, reason, performerUsername, + "bulk set expiration", (ctx) -> { + PunishmentOperationResult result = punishmentMutationService.changeDuration( + server, ctx.punishmentId, effectiveDuration, performerUsername, null); + if (!result.success()) { + return false; + } + + AuditLog durationLog = buildBulkAuditLog(ctx, performerUsername, + "Bulk duration change: " + ctx.typeName + " for " + ctx.playerName, + Map.of("reason", reason != null ? reason : "", "newDurationMs", newDurationMs, + "bulkDurationChange", true)); + auditLogRepository.saveAuditLog(server, durationLog); + return true; + }); + } + + private int processBulkPunishmentAction( + Server server, List typeOrdinals, String reason, + String performerUsername, String operationName, BulkPunishmentAction action) { + try { + List players = punishmentRepository.findPlayersForBulkAction(server, typeOrdinals); + Date now = new Date(); + int count = 0; + + Map typeNameCache = new HashMap<>(); + for (int ordinal : typeOrdinals) { + typeNameCache.put(ordinal, punishmentTypeService.getPunishmentTypeName(server, ordinal)); + } + + for (Document player : players) { + String playerId = player.getString("_id"); + List punishments = player.getList("punishments", Document.class); + if (punishments == null) { + continue; + } + + String playerName = AuditDocumentUtil.extractPlayerNameFromDoc(player); + + for (Document punishmentDoc : punishments) { + int typeOrdinal = punishmentDoc.getInteger(AuditProjectionKeys.TYPE_ORDINAL, 0); + if (!typeOrdinals.contains(typeOrdinal)) { + continue; + } + + Punishment punishment = PunishmentDocumentMapper.reconstructPunishment(punishmentDoc); + if (!statusCalculator.isPunishmentActive(punishment)) { + continue; + } + + String punishmentId = punishmentDoc.getString("id"); + String typeName = typeNameCache.getOrDefault(typeOrdinal, "Unknown"); + + BulkActionContext ctx = new BulkActionContext( + playerId, playerName, punishmentId, punishmentDoc, typeName, typeOrdinal, now); + if (action.apply(ctx)) { + count++; + } + } + } + activePunishmentsCache.invalidate(server.getId()); + return count; + } catch (Exception e) { + log.error("Error during {}", operationName, e); + throw new ExternalServiceException("Failed to " + operationName, e); + } + } + + private AuditLog buildBulkAuditLog( + BulkActionContext ctx, String performerUsername, + String description, Map extraMetadata) { + Map metadata = new HashMap<>(); + metadata.put("punishmentId", ctx.punishmentId != null ? ctx.punishmentId : ""); + metadata.put("playerId", ctx.playerId != null ? ctx.playerId : ""); + metadata.put("playerName", ctx.playerName); + metadata.put("punishmentType", ctx.typeName); + metadata.putAll(extraMetadata); + + return AuditLog.builder() + .created(ctx.now) + .level("moderation") + .source(performerUsername) + .description(description) + .metadata(metadata) + .build(); + } + + private record BulkActionContext( + String playerId, String playerName, String punishmentId, + Document punishmentDoc, String typeName, int typeOrdinal, Date now) {} + + @FunctionalInterface + private interface BulkPunishmentAction { + boolean apply(BulkActionContext ctx); + } +} diff --git a/src/main/java/gg/modl/backend/audit/service/PunishmentDocumentMapper.java b/src/main/java/gg/modl/backend/audit/service/PunishmentDocumentMapper.java new file mode 100644 index 0000000..d31defd --- /dev/null +++ b/src/main/java/gg/modl/backend/audit/service/PunishmentDocumentMapper.java @@ -0,0 +1,91 @@ +package gg.modl.backend.audit.service; + +import gg.modl.backend.audit.dto.response.ActivePunishmentResponse; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentModification; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import org.bson.Document; + +final class PunishmentDocumentMapper { + private PunishmentDocumentMapper() { + } + + static Punishment reconstructPunishment(Document doc) { + Punishment punishment = new Punishment(); + String reconstructedId = doc.getString(AuditProjectionKeys.PUNISHMENT_ID); + if (reconstructedId == null) { + reconstructedId = doc.getString("id"); + } + punishment.setId(reconstructedId); + punishment.setTypeOrdinal(doc.getInteger(AuditProjectionKeys.TYPE_ORDINAL, 0)); + punishment.setIssuerName( + doc.getString(AuditProjectionKeys.ISSUER_NAME) != null + ? doc.getString(AuditProjectionKeys.ISSUER_NAME) : "Unknown"); + punishment.setIssuerId(doc.getString(AuditProjectionKeys.ISSUER_ID)); + punishment.setIssued( + doc.getDate("issued") != null ? doc.getDate("issued") : new Date()); + punishment.setStarted(doc.getDate("started")); + + Document data = doc.get(AuditProjectionKeys.DATA, Document.class); + if (data != null) { + punishment.replaceData(new HashMap<>(data)); + } + + punishment.setModifications(extractModifications(doc)); + punishment.setNotes(Collections.emptyList()); + punishment.setEvidence(Collections.emptyList()); + punishment.setAttachedTicketIds(Collections.emptyList()); + + return punishment; + } + + static List extractModifications(Document doc) { + List modDocs = doc.getList(AuditProjectionKeys.MODIFICATIONS, Document.class); + if (modDocs == null) { + return new ArrayList<>(); + } + + List mods = new ArrayList<>(); + for (Document modDoc : modDocs) { + Long effectiveDuration = null; + Object edObj = modDoc.get("effectiveDuration"); + if (edObj instanceof Number num) { + effectiveDuration = num.longValue(); + } + mods.add(new PunishmentModification( + modDoc.getString("id"), + modDoc.getString("type"), + modDoc.getDate("date"), + modDoc.getString(AuditProjectionKeys.ISSUER_NAME), + modDoc.getString(AuditProjectionKeys.ISSUER_ID), + modDoc.getString(AuditProjectionKeys.REASON), + effectiveDuration, + modDoc.getString("appealTicketId"), + null + )); + } + return mods; + } + + static List extractEvidenceItems(Document row) { + List evidenceDocs = row.getList(AuditProjectionKeys.EVIDENCE, Document.class); + if (evidenceDocs == null) { + return Collections.emptyList(); + } + + List items = new ArrayList<>(); + for (Document evidenceDoc : evidenceDocs) { + items.add(new ActivePunishmentResponse.EvidenceItem( + evidenceDoc.getString("text"), + evidenceDoc.getString("url"), + evidenceDoc.getString("type"), + evidenceDoc.getString("fileName") + )); + } + return items; + } +} diff --git a/src/main/java/gg/modl/backend/audit/service/StaffPerformanceService.java b/src/main/java/gg/modl/backend/audit/service/StaffPerformanceService.java index c330259..0c9b31c 100644 --- a/src/main/java/gg/modl/backend/audit/service/StaffPerformanceService.java +++ b/src/main/java/gg/modl/backend/audit/service/StaffPerformanceService.java @@ -2,10 +2,13 @@ import gg.modl.backend.audit.dto.response.StaffDetailsResponse; import gg.modl.backend.audit.dto.response.StaffPerformanceResponse; -import gg.modl.backend.database.mongo.repository.AuditMongoRepository; -import gg.modl.backend.database.mongo.repository.AuditMongoRepository.IdCountResult; -import gg.modl.backend.database.mongo.repository.AuditMongoRepository.OrdinalCountResult; -import gg.modl.backend.database.mongo.repository.AuditMongoRepository.StaffActivityResult; +import gg.modl.backend.database.mongo.repository.AuditLogRepository; +import gg.modl.backend.database.mongo.repository.StaffActivityAnalyticsRepository; +import gg.modl.backend.database.mongo.repository.StaffActivityAnalyticsRepository.IdCountResult; +import gg.modl.backend.database.mongo.repository.StaffActivityAnalyticsRepository.OrdinalCountResult; +import gg.modl.backend.database.mongo.repository.StaffActivityAnalyticsRepository.StaffActivityResult; +import gg.modl.backend.database.mongo.repository.StaffActivityAnalyticsRepository.StaffTicketResponseTime; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; import gg.modl.backend.infrastructure.util.DateRangeUtil; import gg.modl.backend.player.data.punishment.PunishmentModificationType; import gg.modl.backend.role.service.PermissionService; @@ -34,7 +37,9 @@ @Slf4j public class StaffPerformanceService { - private final AuditMongoRepository auditRepository; + private final StaffActivityAnalyticsRepository staffActivityAnalyticsRepository; + private final AuditLogRepository auditLogRepository; + private final StaffMongoRepository staffMongoRepository; private final PunishmentTypeService punishmentTypeService; private final StaffService staffService; private final PermissionService permissionService; @@ -42,14 +47,16 @@ public class StaffPerformanceService { public List getStaffPerformance(Server server, String period) { Date startDate = DateRangeUtil.getStartDate(period); - List allStaff = auditRepository.findAllStaff(server); + List allStaff = staffMongoRepository.findAllStaff(server); Map roleNamesById = permissionService.resolveRoleNames(server, allStaff.stream().map(Staff::getRoleId).toList()); Map activityByUsername = indexStaffActivity( - auditRepository.aggregateLogActivityBySource(server, startDate)); + staffActivityAnalyticsRepository.aggregateLogActivityBySource(server, startDate)); Map ticketResponsesByStaff = indexIdCounts( - auditRepository.aggregateTicketResponseCounts(server, startDate)); + staffActivityAnalyticsRepository.aggregateTicketResponseCounts(server, startDate)); Map punishmentsByStaff = countPunishmentsByStaff(server, startDate); + Map> ticketResponseTimesByStaff = indexTicketResponseTimes( + staffActivityAnalyticsRepository.aggregateTicketResponseTimesByStaff(server, startDate)); List performanceList = new ArrayList<>(); for (Staff staff : allStaff) { @@ -84,6 +91,10 @@ public List getStaffPerformance(Server server, String String roleName = staff.getRoleId() != null && !staff.getRoleId().isBlank() ? roleNamesById.getOrDefault(staff.getRoleId(), staff.getRoleId()) : "User"; + int avgResponseTime = averageOf( + ticketResponseTimesByStaff.getOrDefault(lowerUsername, List.of()).stream() + .map(source -> calculateResponseTimeMinutes(source.ticketCreated(), source.firstReply())) + .toList()); performanceList.add(new StaffPerformanceResponse( staff.getId(), username, @@ -91,7 +102,7 @@ public List getStaffPerformance(Server server, String totalActions, ticketActions, moderationActions, - 60, + avgResponseTime, lastActive != null ? lastActive : new Date() )); } @@ -111,6 +122,17 @@ private Map indexStaffActivity(List> indexTicketResponseTimes( + List results) { + Map> map = new HashMap<>(); + for (StaffTicketResponseTime result : results) { + if (result.staff() != null) { + map.computeIfAbsent(result.staff().toLowerCase(), key -> new ArrayList<>()).add(result); + } + } + return map; + } + private Map indexIdCounts(List results) { Map map = new HashMap<>(); for (IdCountResult result : results) { @@ -124,7 +146,7 @@ private Map indexIdCounts(List results) { private Map countPunishmentsByStaff(Server server, Date startDate) { Map counts = new HashMap<>(); List results = - auditRepository.aggregatePunishmentCountsByIssuer(server, startDate); + staffActivityAnalyticsRepository.aggregatePunishmentCountsByIssuer(server, startDate); Set issuerIdsToResolve = new HashSet<>(); for (IdCountResult result : results) { @@ -133,7 +155,7 @@ private Map countPunishmentsByStaff(Server server, Date startDa } } Map resolvedIds = - auditRepository.mapStaffUsernamesByIds(server, issuerIdsToResolve); + staffMongoRepository.findUsernamesByIds(server, issuerIdsToResolve); for (IdCountResult result : results) { if (result.id() == null) { @@ -172,13 +194,8 @@ public StaffDetailsResponse getStaffDetails(Server server, String username, Stri List typeBreakdown = getPunishmentTypeBreakdown(server, usernamesToSearch, staffId, startDate); - long evidenceUploads = auditRepository.countEvidenceUploads(server, username, startDate); - int avgResponseTime = tickets.isEmpty() - ? 0 - : (int) tickets.stream() - .mapToInt(StaffDetailsResponse.TicketDetail::responseTime) - .average() - .orElse(0); + long evidenceUploads = auditLogRepository.countEvidenceUploads(server, username, startDate); + int avgResponseTime = averageResponseTimeMinutes(tickets); StaffDetailsResponse.Summary summary = new StaffDetailsResponse.Summary( punishments.size(), @@ -203,16 +220,16 @@ private List getPunishmentDetails( Server server, List usernames, String staffId, Date startDate) { List details = new ArrayList<>(); List results = - auditRepository.aggregatePunishmentDetails(server, usernames, staffId, startDate); + staffActivityAnalyticsRepository.aggregatePunishmentDetails(server, usernames, staffId, startDate); for (Document doc : results) { - int typeOrdinal = doc.getInteger("typeOrdinal", 0); - String reason = doc.getString("reason"); - Object durationObj = doc.get("duration"); + int typeOrdinal = doc.getInteger(AuditProjectionKeys.TYPE_ORDINAL, 0); + String reason = doc.getString(AuditProjectionKeys.REASON); + Object durationObj = doc.get(AuditProjectionKeys.DURATION); details.add(new StaffDetailsResponse.PunishmentDetail( - doc.getString("punishmentId"), - doc.getString("playerId"), + doc.getString(AuditProjectionKeys.PUNISHMENT_ID), + doc.getString(AuditProjectionKeys.PLAYER_ID), AuditDocumentUtil.extractPlayerNameFromDoc(doc), punishmentTypeService.getPunishmentTypeName(server, typeOrdinal), reason != null ? reason : "No reason provided", @@ -229,7 +246,7 @@ private List getTicketDetails( Server server, String username, Date startDate) { List details = new ArrayList<>(); List results = - auditRepository.aggregateTicketDetails(server, username, startDate); + staffActivityAnalyticsRepository.aggregateTicketDetails(server, username, startDate); for (Document doc : results) { int responseTime = calculateResponseTimeMinutes( @@ -251,6 +268,22 @@ private List getTicketDetails( return details; } + private static int averageResponseTimeMinutes(List tickets) { + return averageOf(tickets.stream() + .map(StaffDetailsResponse.TicketDetail::responseTime) + .toList()); + } + + private static int averageOf(List responseTimes) { + if (responseTimes.isEmpty()) { + return 0; + } + return (int) responseTimes.stream() + .mapToInt(Integer::intValue) + .average() + .orElse(0); + } + private int calculateResponseTimeMinutes(Date ticketCreated, Date firstReply) { if (ticketCreated == null || firstReply == null) { return 0; @@ -264,7 +297,7 @@ private List getDailyActivity( Map activityByDate = new HashMap<>(); List punishmentResults = - auditRepository.aggregateDailyPunishmentCounts( + staffActivityAnalyticsRepository.aggregateDailyPunishmentCounts( server, usernames, staffId, startDate); for (IdCountResult result : punishmentResults) { activityByDate.put(result.id(), @@ -272,7 +305,7 @@ private List getDailyActivity( } List ticketResults = - auditRepository.aggregateDailyTicketResponseCounts( + staffActivityAnalyticsRepository.aggregateDailyTicketResponseCounts( server, usernames.get(0), startDate); for (IdCountResult result : ticketResults) { StaffDetailsResponse.DailyActivity existing = activityByDate.get(result.id()); @@ -295,7 +328,7 @@ private List getPunishmentTypeBrea Server server, List usernames, String staffId, Date startDate) { List breakdown = new ArrayList<>(); List results = - auditRepository.aggregatePunishmentTypeBreakdown( + staffActivityAnalyticsRepository.aggregatePunishmentTypeBreakdown( server, usernames, staffId, startDate); for (OrdinalCountResult result : results) { diff --git a/src/main/java/gg/modl/backend/auth/AuthResponseMessage.java b/src/main/java/gg/modl/backend/auth/AuthResponseMessage.java index 6ace3fd..88547fe 100644 --- a/src/main/java/gg/modl/backend/auth/AuthResponseMessage.java +++ b/src/main/java/gg/modl/backend/auth/AuthResponseMessage.java @@ -1,12 +1,8 @@ -package gg.modl.backend.auth; - -public final class AuthResponseMessage { - public static final String MISSING_EMAIL = "Valid email is required."; - public static final String UNAUTHORIZED_EMAIL = "This email is not authorized to access this panel."; - public static final String VERIFICATION_CODE_SENT = "Verification code sent to your email."; - public static final String MISSING_CODE = "Verification code is required."; - public static final String INVALID_CODE = "Invalid or expired verification code."; - public static final String LOGIN_SUCCESS = "Login successful."; - public static final String LOGOUT_SUCCESS = "Logged out successfully."; - public static final String EMAIL_SEND_ERROR = "Failed to send verification email. Please try again."; -} +package gg.modl.backend.auth; + +public final class AuthResponseMessage { + public static final String VERIFICATION_CODE_SENT = "Verification code sent to your email."; + public static final String INVALID_CODE = "Invalid or expired verification code."; + public static final String LOGIN_SUCCESS = "Login successful."; + public static final String LOGOUT_SUCCESS = "Logged out successfully."; +} diff --git a/src/main/java/gg/modl/backend/auth/AuthService.java b/src/main/java/gg/modl/backend/auth/AuthService.java index 52f8bb4..defae4d 100644 --- a/src/main/java/gg/modl/backend/auth/AuthService.java +++ b/src/main/java/gg/modl/backend/auth/AuthService.java @@ -1,109 +1,109 @@ -package gg.modl.backend.auth; - -import gg.modl.backend.auth.data.AuthCode; -import gg.modl.backend.database.mongo.repository.AuthCodeMongoRepository; -import gg.modl.backend.email.EmailAddressUtil; -import gg.modl.backend.email.EmailHTMLTemplate; -import gg.modl.backend.email.EmailService; -import gg.modl.backend.infrastructure.onetimecode.OneTimeCodeCodec; -import gg.modl.backend.server.data.Server; -import java.util.Date; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class AuthService { - private final EmailService emailService; - private final AuthCodeMongoRepository authCodeRepository; - private final AuthConfiguration authConfiguration; - private final OneTimeCodeCodec oneTimeCodeCodec; - - @Async("emailTaskExecutor") - public void sendUserLoginCode(Server server, String email) { - String code = prepareAndStoreCode(email, (normalizedEmail, codeHash, expiresAt) -> - authCodeRepository.replaceForServer(server, normalizedEmail, codeHash, expiresAt)); - - if (code == null) { - return; - } - - emailService.send(email, EmailHTMLTemplate.USER_CODE.build(server.getServerName(), code)); - } - - @Async("emailTaskExecutor") - public void sendEmailChangeCode(Server server, String newEmail) { - String code = prepareAndStoreCode(newEmail, (normalizedEmail, codeHash, expiresAt) -> - authCodeRepository.replaceForServer(server, normalizedEmail, codeHash, expiresAt)); - - if (code == null) { - return; - } - - emailService.send(newEmail, EmailHTMLTemplate.EMAIL_CHANGE_CODE.build(server.getServerName(), code)); - } - - private String prepareAndStoreCode(String email, CodeStorageAction storageAction) { - String code = oneTimeCodeCodec.generateNumericCode(authConfiguration.getEmailCodeLength()); - String codeHash = hash(code); - String normalizedEmail = EmailAddressUtil.normalize(email); - Date expiresAt = new Date(System.currentTimeMillis() + (authConfiguration.getEmailCodeExpiry() * 1000L)); - - storageAction.store(normalizedEmail, codeHash, expiresAt); - - return code; - } - - private String hash(String code) { - return oneTimeCodeCodec.hash(code, authConfiguration.getCodeHashSecret()); - } - - @Async("emailTaskExecutor") - public void sendAdminLoginCode(String email) { - String code = prepareAndStoreCode(email, (normalizedEmail, codeHash, expiresAt) -> - authCodeRepository.replaceForGlobal(normalizedEmail, codeHash, expiresAt)); - - if (code == null) { - return; - } - - emailService.send(email, EmailHTMLTemplate.ADMIN_CODE.build(null, code)); - } - - public boolean verifyCode(Server server, String email, String code) { - String normalizedEmail = EmailAddressUtil.normalize(email); - String codeHash = hash(code); - Date now = new Date(); - - Optional consumed = authCodeRepository.consumeIfHashMatchesForServer(server, normalizedEmail, codeHash, now); - if (consumed.isPresent()) { - return true; - } - - authCodeRepository.incrementFailedAttemptsForServer(server, normalizedEmail, now); - return false; - } - - public boolean verifyAdminCode(String email, String code) { - String normalizedEmail = EmailAddressUtil.normalize(email); - String codeHash = hash(code); - Date now = new Date(); - - Optional consumed = authCodeRepository.consumeIfHashMatchesForGlobal(normalizedEmail, codeHash, now); - if (consumed.isPresent()) { - return true; - } - - authCodeRepository.incrementFailedAttemptsForGlobal(normalizedEmail, now); - return false; - } - - @FunctionalInterface - private interface CodeStorageAction { - void store(String normalizedEmail, String codeHash, Date expiresAt); - } -} +package gg.modl.backend.auth; + +import gg.modl.backend.auth.data.AuthCode; +import gg.modl.backend.database.mongo.repository.AuthCodeMongoRepository; +import gg.modl.backend.email.EmailAddressUtil; +import gg.modl.backend.email.EmailHTMLTemplate; +import gg.modl.backend.email.EmailService; +import gg.modl.backend.infrastructure.onetimecode.OneTimeCodeCodec; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthService { + private final EmailService emailService; + private final AuthCodeMongoRepository authCodeRepository; + private final AuthConfiguration authConfiguration; + private final OneTimeCodeCodec oneTimeCodeCodec; + + @Async("emailTaskExecutor") + public void sendUserLoginCode(Server server, String email) { + String code = prepareAndStoreCode(email, (normalizedEmail, codeHash, expiresAt) -> + authCodeRepository.replaceForServer(server, normalizedEmail, codeHash, expiresAt)); + + if (code == null) { + return; + } + + emailService.send(email, EmailHTMLTemplate.USER_CODE.build(server.getServerName(), code)); + } + + @Async("emailTaskExecutor") + public void sendEmailChangeCode(Server server, String newEmail) { + String code = prepareAndStoreCode(newEmail, (normalizedEmail, codeHash, expiresAt) -> + authCodeRepository.replaceForServer(server, normalizedEmail, codeHash, expiresAt)); + + if (code == null) { + return; + } + + emailService.send(newEmail, EmailHTMLTemplate.EMAIL_CHANGE_CODE.build(server.getServerName(), code)); + } + + private String prepareAndStoreCode(String email, CodeStorageAction storageAction) { + String code = oneTimeCodeCodec.generateNumericCode(authConfiguration.getEmailCodeLength()); + String codeHash = hash(code); + String normalizedEmail = EmailAddressUtil.normalize(email); + Date expiresAt = new Date(System.currentTimeMillis() + (authConfiguration.getEmailCodeExpiry() * 1000L)); + + storageAction.store(normalizedEmail, codeHash, expiresAt); + + return code; + } + + private String hash(String code) { + return oneTimeCodeCodec.hash(code, authConfiguration.getCodeHashSecret()); + } + + @Async("emailTaskExecutor") + public void sendAdminLoginCode(String email) { + String code = prepareAndStoreCode(email, (normalizedEmail, codeHash, expiresAt) -> + authCodeRepository.replaceForGlobal(normalizedEmail, codeHash, expiresAt)); + + if (code == null) { + return; + } + + emailService.send(email, EmailHTMLTemplate.ADMIN_CODE.build(null, code)); + } + + public boolean verifyCode(Server server, String email, String code) { + String normalizedEmail = EmailAddressUtil.normalize(email); + String codeHash = hash(code); + Date now = new Date(); + + Optional consumed = authCodeRepository.consumeIfHashMatchesForServer(server, normalizedEmail, codeHash, now); + if (consumed.isPresent()) { + return true; + } + + authCodeRepository.incrementFailedAttemptsForServer(server, normalizedEmail, now); + return false; + } + + public boolean verifyAdminCode(String email, String code) { + String normalizedEmail = EmailAddressUtil.normalize(email); + String codeHash = hash(code); + Date now = new Date(); + + Optional consumed = authCodeRepository.consumeIfHashMatchesForGlobal(normalizedEmail, codeHash, now); + if (consumed.isPresent()) { + return true; + } + + authCodeRepository.incrementFailedAttemptsForGlobal(normalizedEmail, now); + return false; + } + + @FunctionalInterface + private interface CodeStorageAction { + void store(String normalizedEmail, String codeHash, Date expiresAt); + } +} diff --git a/src/main/java/gg/modl/backend/auth/EmailChangeService.java b/src/main/java/gg/modl/backend/auth/EmailChangeService.java index 17029de..39fc89d 100644 --- a/src/main/java/gg/modl/backend/auth/EmailChangeService.java +++ b/src/main/java/gg/modl/backend/auth/EmailChangeService.java @@ -13,7 +13,7 @@ import gg.modl.backend.server.ServerService; import gg.modl.backend.server.data.Server; import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.staff.service.StaffService; +import gg.modl.backend.staff.service.StaffProfileService; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,7 +25,7 @@ public class EmailChangeService { private final PermissionService permissionService; private final AuthService authService; - private final StaffService staffService; + private final StaffProfileService staffProfileService; private final ServerService serverService; private final WebAuthnService webAuthnService; private final BillingService billingService; @@ -52,7 +52,7 @@ public AuthSessionData changeEmail(Server server, String currentEmail, String ne serverService.changeAdminEmail(server, normalizedNewEmail); } - Optional staff = staffService.applyStaffEmailChange(server, currentEmail, normalizedNewEmail); + Optional staff = staffProfileService.applyStaffEmailChange(server, currentEmail, normalizedNewEmail); if (staff.isEmpty() && !isSuperAdmin) { throw new ResourceNotFoundException("Staff member not found"); } @@ -80,7 +80,7 @@ private String validateTarget(Server server, String currentEmail, String newEmai boolean collidesWithServerAdmin = server.getAdminEmail() != null && normalizedNewEmail.equalsIgnoreCase(server.getAdminEmail()); if (collidesWithServerAdmin - || staffService.isStaffEmailInUse(server, normalizedNewEmail, currentEmail) + || staffProfileService.isStaffEmailInUse(server, normalizedNewEmail, currentEmail) || (isSuperAdmin && serverService.isAdminEmailInUse(normalizedNewEmail, server.getId()))) { throw new ConflictException("Email address already in use"); } diff --git a/src/main/java/gg/modl/backend/auth/WebAuthnService.java b/src/main/java/gg/modl/backend/auth/WebAuthnService.java index de9127c..90a6224 100644 --- a/src/main/java/gg/modl/backend/auth/WebAuthnService.java +++ b/src/main/java/gg/modl/backend/auth/WebAuthnService.java @@ -1,404 +1,404 @@ -package gg.modl.backend.auth; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.yubico.webauthn.AssertionRequest; -import com.yubico.webauthn.AssertionResult; -import com.yubico.webauthn.CredentialRepository; -import com.yubico.webauthn.FinishAssertionOptions; -import com.yubico.webauthn.FinishRegistrationOptions; -import com.yubico.webauthn.RegisteredCredential; -import com.yubico.webauthn.RegistrationResult; -import com.yubico.webauthn.RelyingParty; -import com.yubico.webauthn.StartAssertionOptions; -import com.yubico.webauthn.StartRegistrationOptions; -import com.yubico.webauthn.data.AttestationConveyancePreference; -import com.yubico.webauthn.data.AuthenticatorAssertionResponse; -import com.yubico.webauthn.data.AuthenticatorAttestationResponse; -import com.yubico.webauthn.data.AuthenticatorSelectionCriteria; -import com.yubico.webauthn.data.ByteArray; -import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; -import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; -import com.yubico.webauthn.data.PublicKeyCredential; -import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; -import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; -import com.yubico.webauthn.data.PublicKeyCredentialType; -import com.yubico.webauthn.data.RelyingPartyIdentity; -import com.yubico.webauthn.data.ResidentKeyRequirement; -import com.yubico.webauthn.data.UserIdentity; -import com.yubico.webauthn.data.UserVerificationRequirement; -import com.yubico.webauthn.data.exception.Base64UrlException; -import com.yubico.webauthn.exception.AssertionFailedException; -import com.yubico.webauthn.exception.RegistrationFailedException; -import gg.modl.backend.email.EmailAddressUtil; -import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.infrastructure.exception.UnauthorizedException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.auth.data.WebAuthnChallenge; -import gg.modl.backend.auth.data.WebAuthnCredential; -import gg.modl.backend.database.mongo.repository.WebAuthnChallengeMongoRepository; -import gg.modl.backend.database.mongo.repository.WebAuthnCredentialMongoRepository; -import gg.modl.backend.server.data.CustomDomainStatus; -import gg.modl.backend.server.data.Server; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Date; -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.function.Predicate; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class WebAuthnService { - private final WebAuthnChallengeMongoRepository challengeRepository; - private final WebAuthnCredentialMongoRepository credentialRepository; - private final AuthConfiguration authConfiguration; - - public StartRegistrationResult startRegistration(Server server, String email) { - RelyingParty rp = buildRelyingParty(server); - - UserIdentity userIdentity = UserIdentity.builder() - .name(email) - .displayName(email) - .id(userHandle(email)) - .build(); - - PublicKeyCredentialCreationOptions options = rp.startRegistration( - StartRegistrationOptions.builder() - .user(userIdentity) - .authenticatorSelection(AuthenticatorSelectionCriteria.builder() - .residentKey(ResidentKeyRequirement.PREFERRED) - .userVerification(UserVerificationRequirement.REQUIRED) - .build()) - .build() - ); - - String challengeId = UUID.randomUUID().toString(); - try { - WebAuthnChallenge challenge = new WebAuthnChallenge(); - challenge.setId(challengeId); - challenge.setChallengeJson(options.toJson()); - challenge.setEmail(normalizeEmail(email)); - challenge.setExpiresAt(challengeExpiry()); - challengeRepository.saveEntity(server, challenge); - return new StartRegistrationResult(challengeId, options.toCredentialsCreateJson()); - } catch (JsonProcessingException e) { - throw new ExternalServiceException("Failed to serialize registration options", e); - } - } - - private RelyingParty buildRelyingParty(Server server) { - String rpId = resolveRpId(server); - Set origins = resolveOrigins(server, rpId); - CredentialRepositoryAdapter credRepo = new CredentialRepositoryAdapter(server); - - return RelyingParty.builder() - .identity(RelyingPartyIdentity.builder() - .id(rpId) - .name("modl") - .build()) - .credentialRepository(credRepo) - .origins(origins) - .attestationConveyancePreference(AttestationConveyancePreference.NONE) - .build(); - } - - private String resolveRpId(Server server) { - if (server.getCustomDomainOverride() != null && !server.getCustomDomainOverride().isBlank() - && server.getCustomDomainStatus() == CustomDomainStatus.ACTIVE) { - return server.getCustomDomainOverride(); - } - return "modl.gg"; - } - - private Set resolveOrigins(Server server, String rpId) { - Set origins = new HashSet<>(); - if (authConfiguration.isDevelopmentMode()) { - origins.add("http://localhost:3000"); - origins.add("http://localhost:5173"); - } - if (server.getCustomDomainOverride() != null && !server.getCustomDomainOverride().isBlank() - && server.getCustomDomainStatus() == CustomDomainStatus.ACTIVE) { - origins.add("https://" + server.getCustomDomainOverride()); - } else { - origins.add("https://" + server.getCustomDomain() + ".modl.gg"); - } - origins.add("https://" + rpId); - return origins; - } - - private ByteArray userHandle(String email) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(normalizeEmail(email).getBytes(StandardCharsets.UTF_8)); - return new ByteArray(hash); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("SHA-256 not available", e); - } - } - - private String normalizeEmail(String email) { - return EmailAddressUtil.normalize(email); - } - - private Date challengeExpiry() { - return new Date(System.currentTimeMillis() + 5 * 60 * 1000); - } - - public void finishRegistration(Server server, String email, String challengeId, String responseJson, String credentialName) - throws Exception { - RelyingParty rp = buildRelyingParty(server); - WebAuthnChallenge challenge = challengeRepository.consumeActiveChallenge(server, challengeId, new Date()).orElse(null); - if (challenge == null) { - throw new ResourceNotFoundException("Challenge not found or expired"); - } - - String normalizedEmail = normalizeEmail(email); - if (!normalizedEmail.equals(challenge.getEmail())) { - throw new ValidationException("Email mismatch"); - } - - PublicKeyCredentialCreationOptions options = PublicKeyCredentialCreationOptions.fromJson(challenge.getChallengeJson()); - PublicKeyCredential pkc = - PublicKeyCredential.parseRegistrationResponseJson(responseJson); - - RegistrationResult result; - try { - result = rp.finishRegistration( - FinishRegistrationOptions.builder() - .request(options) - .response(pkc) - .build() - ); - } catch (RegistrationFailedException e) { - throw new ValidationException("Registration verification failed: " + e.getMessage(), e); - } - - WebAuthnCredential cred = new WebAuthnCredential(); - cred.setEmail(normalizedEmail); - cred.setUserHandle(userHandle(normalizedEmail).getBase64Url()); - cred.setCredentialId(result.getKeyId().getId().getBase64Url()); - cred.setPublicKeyCose(result.getPublicKeyCose().getBytes()); - cred.setSignatureCount(result.getSignatureCount()); - cred.setName(credentialName != null && !credentialName.isBlank() ? credentialName.trim() : "Passkey"); - cred.setCreatedAt(new Date()); - cred.setLastUsedAt(new Date()); - credentialRepository.saveEntity(server, cred); - } - - public boolean checkHasPasskeys(Server server, String email) { - return credentialRepository.existsByEmail(server, email); - } - - public StartAuthenticationResult startDiscoverableAuthentication(Server server) { - RelyingParty rp = buildRelyingParty(server); - AssertionRequest assertionRequest = rp.startAssertion( - StartAssertionOptions.builder() - .userVerification(UserVerificationRequirement.REQUIRED) - .build() - ); - - String challengeId = UUID.randomUUID().toString(); - try { - WebAuthnChallenge challenge = new WebAuthnChallenge(); - challenge.setId(challengeId); - challenge.setChallengeJson(assertionRequest.toJson()); - challenge.setEmail(null); - challenge.setExpiresAt(challengeExpiry()); - challengeRepository.saveEntity(server, challenge); - return new StartAuthenticationResult(challengeId, assertionRequest.toCredentialsGetJson(), true); - } catch (JsonProcessingException e) { - throw new ExternalServiceException("Failed to serialize assertion request", e); - } - } - - public StartAuthenticationResult startAuthentication(Server server, String email) { - RelyingParty rp = buildRelyingParty(server); - AssertionRequest assertionRequest = rp.startAssertion( - StartAssertionOptions.builder() - .username(normalizeEmail(email)) - .userVerification(UserVerificationRequirement.REQUIRED) - .build() - ); - - String challengeId = UUID.randomUUID().toString(); - try { - WebAuthnChallenge challenge = new WebAuthnChallenge(); - challenge.setId(challengeId); - challenge.setChallengeJson(assertionRequest.toJson()); - challenge.setEmail(normalizeEmail(email)); - challenge.setExpiresAt(challengeExpiry()); - challengeRepository.saveEntity(server, challenge); - return new StartAuthenticationResult(challengeId, assertionRequest.toCredentialsGetJson(), true); - } catch (JsonProcessingException e) { - throw new ExternalServiceException("Failed to serialize assertion request", e); - } - } - - public String finishAuthentication(Server server, String challengeId, String responseJson, Predicate isAuthorized) throws Exception { - RelyingParty rp = buildRelyingParty(server); - WebAuthnChallenge challenge = challengeRepository.consumeActiveChallenge(server, challengeId, new Date()).orElse(null); - if (challenge == null) { - throw new ResourceNotFoundException("Challenge not found or expired"); - } - - AssertionRequest assertionRequest = AssertionRequest.fromJson(challenge.getChallengeJson()); - PublicKeyCredential pkc = - PublicKeyCredential.parseAssertionResponseJson(responseJson); - - AssertionResult result; - try { - result = rp.finishAssertion( - FinishAssertionOptions.builder() - .request(assertionRequest) - .response(pkc) - .build() - ); - } catch (AssertionFailedException e) { - throw new UnauthorizedException("Authentication verification failed: " + e.getMessage(), e); - } - - if (!result.isSuccess()) { - throw new UnauthorizedException("Authentication failed"); - } - - if (!result.isSignatureCounterValid()) { - log.warn("WebAuthn signature counter invalid for credential {}: possible cloned authenticator", - result.getCredential().getCredentialId().getBase64Url()); - throw new UnauthorizedException("Authentication failed: possible cloned authenticator"); - } - - String email = challenge.getEmail(); - if (email == null || email.isBlank()) { - ByteArray userHandle = result.getCredential().getUserHandle(); - if (userHandle == null) { - throw new ResourceNotFoundException("Could not determine user identity"); - } - WebAuthnCredential cred = credentialRepository.findByUserHandle(server, userHandle.getBase64Url()).orElse(null); - if (cred == null) { - throw new ResourceNotFoundException("Could not determine user identity"); - } - email = cred.getEmail(); - } - - if (!isAuthorized.test(email)) { - throw new ValidationException("Not authorized"); - } - - String credentialId = result.getCredential().getCredentialId().getBase64Url(); - boolean updated = credentialRepository.updateUsage(server, credentialId, result.getSignatureCount(), new Date()); - if (!updated) { - throw new UnauthorizedException("Authentication failed: credential not found"); - } - - return email; - } - - public List listCredentials(Server server, String email) { - return credentialRepository.findByEmail(server, email) - .stream() - .map(c -> new CredentialInfo(c.getId(), c.getName(), c.getCreatedAt(), c.getLastUsedAt())) - .collect(Collectors.toList()); - } - - public boolean renameCredential(Server server, String email, String credentialMongoId, String newName) { - return credentialRepository.renameByIdAndEmail(server, credentialMongoId, email, newName); - } - - public boolean deleteCredential(Server server, String email, String credentialMongoId) { - return credentialRepository.deleteByIdAndEmail(server, credentialMongoId, email); - } - - public long deleteCredentialsForEmail(Server server, String email) { - return credentialRepository.deleteAllByEmail(server, email); - } - - public record StartRegistrationResult(String challengeId, String optionsJson) { - } - - public record StartAuthenticationResult(String challengeId, String optionsJson, boolean hasPasskeys) { - } - - public record CredentialInfo(String id, String name, Date createdAt, Date lastUsedAt) { - } - - private class CredentialRepositoryAdapter implements CredentialRepository { - private final Server server; - - CredentialRepositoryAdapter(Server server) { - this.server = server; - } - - @Override - public Set getCredentialIdsForUsername(String username) { - return credentialRepository.findByEmail(server, username) - .stream() - .map(c -> { - try { - return PublicKeyCredentialDescriptor.builder() - .id(ByteArray.fromBase64Url(c.getCredentialId())) - .type(PublicKeyCredentialType.PUBLIC_KEY) - .build(); - } catch (Base64UrlException e) { - log.error("Invalid base64url credential ID for {}", c.getId(), e); - return null; - } - }) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - } - - @Override - public Optional getUserHandleForUsername(String username) { - return Optional.of(userHandle(username)); - } - - @Override - public Optional getUsernameForUserHandle(ByteArray userHandle) { - return credentialRepository.findByUserHandle(server, userHandle.getBase64Url()).map(WebAuthnCredential::getEmail); - } - - @Override - public Optional lookup(ByteArray credentialId, ByteArray userHandle) { - return credentialRepository.findByCredentialId(server, credentialId.getBase64Url()) - .filter(cred -> { - try { - ByteArray storedHandle = ByteArray.fromBase64Url(cred.getUserHandle()); - return storedHandle.equals(userHandle); - } catch (Base64UrlException e) { - log.error("Invalid base64url user handle for credential {}", cred.getId(), e); - return false; - } - }) - .map(cred -> RegisteredCredential.builder() - .credentialId(credentialId) - .userHandle(userHandle) - .publicKeyCose(new ByteArray(cred.getPublicKeyCose())) - .signatureCount(cred.getSignatureCount()) - .build()); - } - - @Override - public Set lookupAll(ByteArray credentialId) { - return credentialRepository.findAllByCredentialId(server, credentialId.getBase64Url()) - .stream() - .map(c -> RegisteredCredential.builder() - .credentialId(credentialId) - .userHandle(userHandle(c.getEmail())) - .publicKeyCose(new ByteArray(c.getPublicKeyCose())) - .signatureCount(c.getSignatureCount()) - .build()) - .collect(Collectors.toSet()); - } - } -} +package gg.modl.backend.auth; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.yubico.webauthn.AssertionRequest; +import com.yubico.webauthn.AssertionResult; +import com.yubico.webauthn.CredentialRepository; +import com.yubico.webauthn.FinishAssertionOptions; +import com.yubico.webauthn.FinishRegistrationOptions; +import com.yubico.webauthn.RegisteredCredential; +import com.yubico.webauthn.RegistrationResult; +import com.yubico.webauthn.RelyingParty; +import com.yubico.webauthn.StartAssertionOptions; +import com.yubico.webauthn.StartRegistrationOptions; +import com.yubico.webauthn.data.AttestationConveyancePreference; +import com.yubico.webauthn.data.AuthenticatorAssertionResponse; +import com.yubico.webauthn.data.AuthenticatorAttestationResponse; +import com.yubico.webauthn.data.AuthenticatorSelectionCriteria; +import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; +import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; +import com.yubico.webauthn.data.PublicKeyCredential; +import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; +import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; +import com.yubico.webauthn.data.PublicKeyCredentialType; +import com.yubico.webauthn.data.RelyingPartyIdentity; +import com.yubico.webauthn.data.ResidentKeyRequirement; +import com.yubico.webauthn.data.UserIdentity; +import com.yubico.webauthn.data.UserVerificationRequirement; +import com.yubico.webauthn.data.exception.Base64UrlException; +import com.yubico.webauthn.exception.AssertionFailedException; +import com.yubico.webauthn.exception.RegistrationFailedException; +import gg.modl.backend.email.EmailAddressUtil; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.exception.UnauthorizedException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.auth.data.WebAuthnChallenge; +import gg.modl.backend.auth.data.WebAuthnCredential; +import gg.modl.backend.database.mongo.repository.WebAuthnChallengeMongoRepository; +import gg.modl.backend.database.mongo.repository.WebAuthnCredentialMongoRepository; +import gg.modl.backend.server.data.CustomDomainStatus; +import gg.modl.backend.server.data.Server; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class WebAuthnService { + private final WebAuthnChallengeMongoRepository challengeRepository; + private final WebAuthnCredentialMongoRepository credentialRepository; + private final AuthConfiguration authConfiguration; + + public StartRegistrationResult startRegistration(Server server, String email) { + RelyingParty rp = buildRelyingParty(server); + + UserIdentity userIdentity = UserIdentity.builder() + .name(email) + .displayName(email) + .id(userHandle(email)) + .build(); + + PublicKeyCredentialCreationOptions options = rp.startRegistration( + StartRegistrationOptions.builder() + .user(userIdentity) + .authenticatorSelection(AuthenticatorSelectionCriteria.builder() + .residentKey(ResidentKeyRequirement.PREFERRED) + .userVerification(UserVerificationRequirement.REQUIRED) + .build()) + .build() + ); + + String challengeId = UUID.randomUUID().toString(); + try { + WebAuthnChallenge challenge = new WebAuthnChallenge(); + challenge.setId(challengeId); + challenge.setChallengeJson(options.toJson()); + challenge.setEmail(normalizeEmail(email)); + challenge.setExpiresAt(challengeExpiry()); + challengeRepository.saveEntity(server, challenge); + return new StartRegistrationResult(challengeId, options.toCredentialsCreateJson()); + } catch (JsonProcessingException e) { + throw new ExternalServiceException("Failed to serialize registration options", e); + } + } + + private RelyingParty buildRelyingParty(Server server) { + String rpId = resolveRpId(server); + Set origins = resolveOrigins(server, rpId); + CredentialRepositoryAdapter credRepo = new CredentialRepositoryAdapter(server); + + return RelyingParty.builder() + .identity(RelyingPartyIdentity.builder() + .id(rpId) + .name("modl") + .build()) + .credentialRepository(credRepo) + .origins(origins) + .attestationConveyancePreference(AttestationConveyancePreference.NONE) + .build(); + } + + private String resolveRpId(Server server) { + if (server.getCustomDomainOverride() != null && !server.getCustomDomainOverride().isBlank() + && server.getCustomDomainStatus() == CustomDomainStatus.ACTIVE) { + return server.getCustomDomainOverride(); + } + return "modl.gg"; + } + + private Set resolveOrigins(Server server, String rpId) { + Set origins = new HashSet<>(); + if (authConfiguration.isDevelopmentMode()) { + origins.add("http://localhost:3000"); + origins.add("http://localhost:5173"); + } + if (server.getCustomDomainOverride() != null && !server.getCustomDomainOverride().isBlank() + && server.getCustomDomainStatus() == CustomDomainStatus.ACTIVE) { + origins.add("https://" + server.getCustomDomainOverride()); + } else { + origins.add("https://" + server.getCustomDomain() + ".modl.gg"); + } + origins.add("https://" + rpId); + return origins; + } + + private ByteArray userHandle(String email) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(normalizeEmail(email).getBytes(StandardCharsets.UTF_8)); + return new ByteArray(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } + + private String normalizeEmail(String email) { + return EmailAddressUtil.normalize(email); + } + + private Date challengeExpiry() { + return new Date(System.currentTimeMillis() + 5 * 60 * 1000); + } + + public void finishRegistration(Server server, String email, String challengeId, String responseJson, String credentialName) + throws Exception { + RelyingParty rp = buildRelyingParty(server); + WebAuthnChallenge challenge = challengeRepository.consumeActiveChallenge(server, challengeId, new Date()).orElse(null); + if (challenge == null) { + throw new ResourceNotFoundException("Challenge not found or expired"); + } + + String normalizedEmail = normalizeEmail(email); + if (!normalizedEmail.equals(challenge.getEmail())) { + throw new ValidationException("Email mismatch"); + } + + PublicKeyCredentialCreationOptions options = PublicKeyCredentialCreationOptions.fromJson(challenge.getChallengeJson()); + PublicKeyCredential pkc = + PublicKeyCredential.parseRegistrationResponseJson(responseJson); + + RegistrationResult result; + try { + result = rp.finishRegistration( + FinishRegistrationOptions.builder() + .request(options) + .response(pkc) + .build() + ); + } catch (RegistrationFailedException e) { + throw new ValidationException("Registration verification failed: " + e.getMessage(), e); + } + + WebAuthnCredential cred = new WebAuthnCredential(); + cred.setEmail(normalizedEmail); + cred.setUserHandle(userHandle(normalizedEmail).getBase64Url()); + cred.setCredentialId(result.getKeyId().getId().getBase64Url()); + cred.setPublicKeyCose(result.getPublicKeyCose().getBytes()); + cred.setSignatureCount(result.getSignatureCount()); + cred.setName(credentialName != null && !credentialName.isBlank() ? credentialName.trim() : "Passkey"); + cred.setCreatedAt(new Date()); + cred.setLastUsedAt(new Date()); + credentialRepository.saveEntity(server, cred); + } + + public boolean checkHasPasskeys(Server server, String email) { + return credentialRepository.existsByEmail(server, email); + } + + public StartAuthenticationResult startDiscoverableAuthentication(Server server) { + RelyingParty rp = buildRelyingParty(server); + AssertionRequest assertionRequest = rp.startAssertion( + StartAssertionOptions.builder() + .userVerification(UserVerificationRequirement.REQUIRED) + .build() + ); + + String challengeId = UUID.randomUUID().toString(); + try { + WebAuthnChallenge challenge = new WebAuthnChallenge(); + challenge.setId(challengeId); + challenge.setChallengeJson(assertionRequest.toJson()); + challenge.setEmail(null); + challenge.setExpiresAt(challengeExpiry()); + challengeRepository.saveEntity(server, challenge); + return new StartAuthenticationResult(challengeId, assertionRequest.toCredentialsGetJson(), true); + } catch (JsonProcessingException e) { + throw new ExternalServiceException("Failed to serialize assertion request", e); + } + } + + public StartAuthenticationResult startAuthentication(Server server, String email) { + RelyingParty rp = buildRelyingParty(server); + AssertionRequest assertionRequest = rp.startAssertion( + StartAssertionOptions.builder() + .username(normalizeEmail(email)) + .userVerification(UserVerificationRequirement.REQUIRED) + .build() + ); + + String challengeId = UUID.randomUUID().toString(); + try { + WebAuthnChallenge challenge = new WebAuthnChallenge(); + challenge.setId(challengeId); + challenge.setChallengeJson(assertionRequest.toJson()); + challenge.setEmail(normalizeEmail(email)); + challenge.setExpiresAt(challengeExpiry()); + challengeRepository.saveEntity(server, challenge); + return new StartAuthenticationResult(challengeId, assertionRequest.toCredentialsGetJson(), true); + } catch (JsonProcessingException e) { + throw new ExternalServiceException("Failed to serialize assertion request", e); + } + } + + public String finishAuthentication(Server server, String challengeId, String responseJson, Predicate isAuthorized) throws Exception { + RelyingParty rp = buildRelyingParty(server); + WebAuthnChallenge challenge = challengeRepository.consumeActiveChallenge(server, challengeId, new Date()).orElse(null); + if (challenge == null) { + throw new ResourceNotFoundException("Challenge not found or expired"); + } + + AssertionRequest assertionRequest = AssertionRequest.fromJson(challenge.getChallengeJson()); + PublicKeyCredential pkc = + PublicKeyCredential.parseAssertionResponseJson(responseJson); + + AssertionResult result; + try { + result = rp.finishAssertion( + FinishAssertionOptions.builder() + .request(assertionRequest) + .response(pkc) + .build() + ); + } catch (AssertionFailedException e) { + throw new UnauthorizedException("Authentication verification failed: " + e.getMessage(), e); + } + + if (!result.isSuccess()) { + throw new UnauthorizedException("Authentication failed"); + } + + if (!result.isSignatureCounterValid()) { + log.warn("WebAuthn signature counter invalid for credential {}: possible cloned authenticator", + result.getCredential().getCredentialId().getBase64Url()); + throw new UnauthorizedException("Authentication failed: possible cloned authenticator"); + } + + String email = challenge.getEmail(); + if (email == null || email.isBlank()) { + ByteArray userHandle = result.getCredential().getUserHandle(); + if (userHandle == null) { + throw new ResourceNotFoundException("Could not determine user identity"); + } + WebAuthnCredential cred = credentialRepository.findByUserHandle(server, userHandle.getBase64Url()).orElse(null); + if (cred == null) { + throw new ResourceNotFoundException("Could not determine user identity"); + } + email = cred.getEmail(); + } + + if (!isAuthorized.test(email)) { + throw new ValidationException("Not authorized"); + } + + String credentialId = result.getCredential().getCredentialId().getBase64Url(); + boolean updated = credentialRepository.updateUsage(server, credentialId, result.getSignatureCount(), new Date()); + if (!updated) { + throw new UnauthorizedException("Authentication failed: credential not found"); + } + + return email; + } + + public List listCredentials(Server server, String email) { + return credentialRepository.findByEmail(server, email) + .stream() + .map(c -> new CredentialInfo(c.getId(), c.getName(), c.getCreatedAt(), c.getLastUsedAt())) + .collect(Collectors.toList()); + } + + public boolean renameCredential(Server server, String email, String credentialMongoId, String newName) { + return credentialRepository.renameByIdAndEmail(server, credentialMongoId, email, newName); + } + + public boolean deleteCredential(Server server, String email, String credentialMongoId) { + return credentialRepository.deleteByIdAndEmail(server, credentialMongoId, email); + } + + public long deleteCredentialsForEmail(Server server, String email) { + return credentialRepository.deleteAllByEmail(server, email); + } + + public record StartRegistrationResult(String challengeId, String optionsJson) { + } + + public record StartAuthenticationResult(String challengeId, String optionsJson, boolean hasPasskeys) { + } + + public record CredentialInfo(String id, String name, Date createdAt, Date lastUsedAt) { + } + + private class CredentialRepositoryAdapter implements CredentialRepository { + private final Server server; + + CredentialRepositoryAdapter(Server server) { + this.server = server; + } + + @Override + public Set getCredentialIdsForUsername(String username) { + return credentialRepository.findByEmail(server, username) + .stream() + .map(c -> { + try { + return PublicKeyCredentialDescriptor.builder() + .id(ByteArray.fromBase64Url(c.getCredentialId())) + .type(PublicKeyCredentialType.PUBLIC_KEY) + .build(); + } catch (Base64UrlException e) { + log.error("Invalid base64url credential ID for {}", c.getId(), e); + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + @Override + public Optional getUserHandleForUsername(String username) { + return Optional.of(userHandle(username)); + } + + @Override + public Optional getUsernameForUserHandle(ByteArray userHandle) { + return credentialRepository.findByUserHandle(server, userHandle.getBase64Url()).map(WebAuthnCredential::getEmail); + } + + @Override + public Optional lookup(ByteArray credentialId, ByteArray userHandle) { + return credentialRepository.findByCredentialId(server, credentialId.getBase64Url()) + .filter(cred -> { + try { + ByteArray storedHandle = ByteArray.fromBase64Url(cred.getUserHandle()); + return storedHandle.equals(userHandle); + } catch (Base64UrlException e) { + log.error("Invalid base64url user handle for credential {}", cred.getId(), e); + return false; + } + }) + .map(cred -> RegisteredCredential.builder() + .credentialId(credentialId) + .userHandle(userHandle) + .publicKeyCose(new ByteArray(cred.getPublicKeyCose())) + .signatureCount(cred.getSignatureCount()) + .build()); + } + + @Override + public Set lookupAll(ByteArray credentialId) { + return credentialRepository.findAllByCredentialId(server, credentialId.getBase64Url()) + .stream() + .map(c -> RegisteredCredential.builder() + .credentialId(credentialId) + .userHandle(userHandle(c.getEmail())) + .publicKeyCose(new ByteArray(c.getPublicKeyCose())) + .signatureCount(c.getSignatureCount()) + .build()) + .collect(Collectors.toSet()); + } + } +} diff --git a/src/main/java/gg/modl/backend/auth/controller/PanelAuthController.java b/src/main/java/gg/modl/backend/auth/controller/PanelAuthController.java index 064699b..e8c896c 100644 --- a/src/main/java/gg/modl/backend/auth/controller/PanelAuthController.java +++ b/src/main/java/gg/modl/backend/auth/controller/PanelAuthController.java @@ -1,328 +1,340 @@ -package gg.modl.backend.auth.controller; - -import gg.modl.backend.auth.AuthConfiguration; -import gg.modl.backend.auth.AuthResponseMessage; -import gg.modl.backend.auth.AuthService; -import gg.modl.backend.auth.EmailChangeService; -import gg.modl.backend.auth.session.AuthSessionData; -import gg.modl.backend.auth.session.SessionService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.role.data.StaffRole; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.role.service.RoleAuthorization; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.staff.service.StaffService; -import gg.modl.backend.infrastructure.util.CookieUtil; -import gg.modl.proto.modl.v1.PanelAuthResponse; -import gg.modl.proto.modl.v1.PanelPermissionsResponse; -import gg.modl.proto.modl.v1.PanelProfileResponse; -import gg.modl.proto.modl.v1.PanelSendEmailCodeRequest; -import gg.modl.proto.modl.v1.PanelSessionsResponse; -import gg.modl.proto.modl.v1.PanelUpdateEmailRequest; -import gg.modl.proto.modl.v1.PanelUpdateEmailWithCodeRequest; -import gg.modl.proto.modl.v1.PanelUpdateProfileRequest; -import gg.modl.proto.modl.v1.PanelVerifyEmailCodeRequest; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.util.Arrays; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_AUTH) -@RequiredArgsConstructor -@Slf4j -public class PanelAuthController { - private final AuthService authService; - private final SessionService sessionService; - private final AuthConfiguration authConfiguration; - private final StaffService staffService; - private final PermissionService permissionService; - private final CookieUtil cookieUtil; - private final EmailChangeService emailChangeService; - - @PostMapping("/send-email-code") - public ResponseEntity sendEmailCode( - HttpServletRequest request, - @RequestBody PanelSendEmailCodeRequest requestData) throws Exception { - - Server server = RequestUtil.getRequestServer(request); - - // Always return generic success to prevent email enumeration - if (!permissionService.isAuthorizedEmail(server, requestData.getEmail())) { - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.VERIFICATION_CODE_SENT)); - } - - authService.sendUserLoginCode(server, requestData.getEmail()); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.VERIFICATION_CODE_SENT)); - } - - @PostMapping("/verify-email-code") - public ResponseEntity verifyEmailCode( - HttpServletRequest request, - HttpServletResponse response, - @RequestBody PanelVerifyEmailCodeRequest requestData) { - - Server server = RequestUtil.getRequestServer(request); - - // Return same error as invalid code to prevent email enumeration - if (!permissionService.isAuthorizedEmail(server, requestData.getEmail())) { - return ResponseEntity.badRequest().body(PanelAuthProtoMapper.toAuthResponse(false, AuthResponseMessage.INVALID_CODE)); - } - - boolean valid = authService.verifyCode(server, requestData.getEmail(), requestData.getCode()); - - if (!valid) { - return ResponseEntity.badRequest().body(PanelAuthProtoMapper.toAuthResponse(false, AuthResponseMessage.INVALID_CODE)); - } - - String clientIp = RequestUtil.getClientIp(request); - String userAgent = request.getHeader("User-Agent"); - AuthSessionData session = sessionService.createSession(server, requestData.getEmail(), clientIp, userAgent); - - response.addCookie(cookieUtil.createSessionCookie(session.getId())); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGIN_SUCCESS)); - } - - @PostMapping("/logout") - public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { - Server server = RequestUtil.getRequestServer(request); - Set sessionIds = extractSessionIds(request); - - for (String sessionId : sessionIds) { - sessionService.invalidateSession(server, sessionId); - } - - expireSessionCookies(response); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGOUT_SUCCESS)); - } - - private Set extractSessionIds(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - if (cookies == null) { - return Set.of(); - } - - return Arrays.stream(cookies) - .filter(cookie -> authConfiguration.getSessionCookieName().equals(cookie.getName())) - .map(Cookie::getValue) - .filter(value -> value != null && !value.isBlank()) - .collect(Collectors.toCollection(LinkedHashSet::new)); - } - - @PatchMapping("/profile") - public ResponseEntity updateProfile( - HttpServletRequest request, - @RequestBody PanelUpdateProfileRequest requestData) { - - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - boolean isSuperAdmin = permissionService.isSuperAdmin(server, email); - - String username = requestData.hasUsername() ? requestData.getUsername() : null; - String language = requestData.hasLanguage() ? requestData.getLanguage() : null; - String dateFormat = requestData.hasDateFormat() ? requestData.getDateFormat() : null; - - Optional result = staffService.updateOrCreateProfileUsername(server, email, username, isSuperAdmin, language, dateFormat); - if (result.isEmpty()) { - if (isSuperAdmin) { - String resolvedUsername = username != null ? username : "Admin"; - String resolvedLanguage = language != null ? language : "en"; - String resolvedDateFormat = dateFormat != null ? dateFormat : "MM/DD/YYYY"; - return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( - null, email, resolvedUsername, "Super Admin", resolvedUsername, resolvedLanguage, resolvedDateFormat)); - } - return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Staff member not found")); - } - Staff staff = result.get(); - String role = isSuperAdmin ? "Super Admin" : permissionService.resolveRoleName(server, staff.getRoleId()); - String minecraftUsername = staff.getAssignedMinecraftUsername() != null - ? staff.getAssignedMinecraftUsername() - : staff.getUsername(); - return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( - staff.getId(), staff.getEmail(), staff.getUsername(), role, minecraftUsername, staff.getLanguage(), staff.getDateFormat())); - } - - @PostMapping("/email/send-code") - public ResponseEntity sendEmailChangeCode( - HttpServletRequest request, - @RequestBody PanelUpdateEmailRequest requestData) { - - String currentEmail = RequestUtil.getSessionEmail(request); - if (currentEmail == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - emailChangeService.sendChangeCode(server, currentEmail, requestData.getNewEmail()); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, "Verification code sent to new email.")); - } - - @PatchMapping("/email") - public ResponseEntity updateEmail( - HttpServletRequest request, - HttpServletResponse response, - @RequestBody PanelUpdateEmailWithCodeRequest requestData) { - - String currentEmail = RequestUtil.getSessionEmail(request); - if (currentEmail == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - AuthSessionData newSession = emailChangeService.changeEmail( - server, currentEmail, requestData.getNewEmail(), requestData.getCode(), - RequestUtil.getClientIp(request), request.getHeader("User-Agent")); - response.addCookie(cookieUtil.createSessionCookie(newSession.getId())); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, "Email updated successfully.")); - } - - @GetMapping("/me") - public ResponseEntity getCurrentUser(HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - boolean isSuperAdmin = permissionService.isSuperAdmin(server, email); - - Optional staffOpt = staffService.getStaffByEmail(server, email); - - if (staffOpt.isPresent()) { - Staff staff = staffOpt.get(); - String role = isSuperAdmin ? "Super Admin" : permissionService.resolveRoleName(server, staff.getRoleId()); - // Include Minecraft username if assigned, fall back to panel username - String minecraftUsername = staff.getAssignedMinecraftUsername() != null - ? staff.getAssignedMinecraftUsername() - : staff.getUsername(); - return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( - staff.getId(), staff.getEmail(), staff.getUsername(), role, minecraftUsername, staff.getLanguage(), staff.getDateFormat())); - } - - // Super Admin without a staff record - return default username - if (isSuperAdmin) { - return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse(null, email, "Admin", "Super Admin", "Admin", "en", "MM/DD/YYYY")); - } - - return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Staff member not found")); - } - - @GetMapping("/sessions") - public ResponseEntity getSessions(HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - AuthSessionData currentSession = RequestUtil.getSession(request); - String currentSessionId = currentSession != null ? currentSession.getId() : null; - - List sessions = sessionService.findAllSessionsForEmail(server, email); - - return ResponseEntity.ok(PanelAuthProtoMapper.toSessionsResponse(sessions, currentSessionId)); - } - - @DeleteMapping("/sessions/{publicId}") - public ResponseEntity revokeSession( - HttpServletRequest request, - HttpServletResponse response, - @PathVariable String publicId) { - - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - Optional target = sessionService.findSessionByPublicId(server, email, publicId); - if (target.isEmpty()) { - return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Session not found")); - } - - String sessionId = target.get().getId(); - sessionService.invalidateSession(server, sessionId); - expireCookiesIfCurrentSession(request, response, sessionId); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGOUT_SUCCESS)); - } - - @DeleteMapping("/sessions") - public ResponseEntity revokeAllSessions(HttpServletRequest request, HttpServletResponse response) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - sessionService.invalidateAllSessionsForEmail(server, email); - expireSessionCookies(response); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGOUT_SUCCESS)); - } - - private void expireCookiesIfCurrentSession(HttpServletRequest request, HttpServletResponse response, String sessionId) { - AuthSessionData currentSession = RequestUtil.getSession(request); - if (currentSession != null && sessionId.equals(currentSession.getId())) { - expireSessionCookies(response); - } - } - - private void expireSessionCookies(HttpServletResponse response) { - for (Cookie cookie : cookieUtil.createExpiredSessionCookies()) { - response.addCookie(cookie); - } - } - - @GetMapping("/permissions") - public ResponseEntity getUserPermissions(HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toPermissionsResponse(List.of())); - } - - Server server = RequestUtil.getRequestServer(request); - - if (permissionService.isSuperAdmin(server, email)) { - return ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(permissionService.getAllPermissionIds(server))); - } - - Optional staffOpt = staffService.getStaffByEmail(server, email); - if (staffOpt.isEmpty()) { - return ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(List.of())); - } - - String roleId = RoleAuthorization.effectiveRoleId(server, staffOpt.get()); - Optional roleOpt = permissionService.getRoleById(server, roleId); - - return roleOpt.map(staffRole -> ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(staffRole.getPermissions()))) - .orElseGet(() -> ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(List.of()))); - } -} +package gg.modl.backend.auth.controller; + +import gg.modl.backend.auth.AuthConfiguration; +import gg.modl.backend.auth.AuthResponseMessage; +import gg.modl.backend.auth.AuthService; +import gg.modl.backend.auth.EmailChangeService; +import gg.modl.backend.auth.session.AuthSessionData; +import gg.modl.backend.auth.session.SessionService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.role.data.StaffRole; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.role.service.RoleAuthorization; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.SupportedLanguages; +import gg.modl.backend.staff.data.Staff; +import gg.modl.backend.staff.service.StaffLookupCache; +import gg.modl.backend.staff.service.StaffProfileService; +import gg.modl.backend.staff.service.SuperAdminStaffSynthesizer; +import gg.modl.backend.infrastructure.util.CookieUtil; +import gg.modl.proto.modl.v1.PanelAuthResponse; +import gg.modl.proto.modl.v1.PanelPermissionsResponse; +import gg.modl.proto.modl.v1.PanelSendEmailCodeRequest; +import gg.modl.proto.modl.v1.PanelUpdateEmailRequest; +import gg.modl.proto.modl.v1.PanelUpdateEmailWithCodeRequest; +import gg.modl.proto.modl.v1.PanelUpdateProfileRequest; +import gg.modl.proto.modl.v1.PanelVerifyEmailCodeRequest; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_AUTH) +@RequiredArgsConstructor +@Slf4j +public class PanelAuthController { + private final AuthService authService; + private final SessionService sessionService; + private final AuthConfiguration authConfiguration; + private final StaffProfileService staffProfileService; + private final StaffLookupCache staffLookupCache; + private final PermissionService permissionService; + private final CookieUtil cookieUtil; + private final EmailChangeService emailChangeService; + + @PostMapping("/send-email-code") + public ResponseEntity sendEmailCode( + HttpServletRequest request, + @RequestBody PanelSendEmailCodeRequest requestData) throws Exception { + + Server server = RequestUtil.getRequestServer(request); + + if (!permissionService.isAuthorizedEmail(server, requestData.getEmail())) { + return verificationCodeSentResponse(); + } + + authService.sendUserLoginCode(server, requestData.getEmail()); + + return verificationCodeSentResponse(); + } + + @PostMapping("/verify-email-code") + public ResponseEntity verifyEmailCode( + HttpServletRequest request, + HttpServletResponse response, + @RequestBody PanelVerifyEmailCodeRequest requestData) { + + Server server = RequestUtil.getRequestServer(request); + + if (!permissionService.isAuthorizedEmail(server, requestData.getEmail())) { + return invalidCodeResponse(); + } + + boolean valid = authService.verifyCode(server, requestData.getEmail(), requestData.getCode()); + + if (!valid) { + return invalidCodeResponse(); + } + + String clientIp = RequestUtil.getClientIp(request); + String userAgent = request.getHeader("User-Agent"); + AuthSessionData session = sessionService.createSession(server, requestData.getEmail(), clientIp, userAgent); + + response.addCookie(cookieUtil.createSessionCookie(session.getId())); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGIN_SUCCESS)); + } + + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + Server server = RequestUtil.getRequestServer(request); + Set sessionIds = extractSessionIds(request); + + for (String sessionId : sessionIds) { + sessionService.invalidateSession(server, sessionId); + } + + expireSessionCookies(response); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGOUT_SUCCESS)); + } + + private Set extractSessionIds(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Set.of(); + } + + return Arrays.stream(cookies) + .filter(cookie -> authConfiguration.getSessionCookieName().equals(cookie.getName())) + .map(Cookie::getValue) + .filter(value -> value != null && !value.isBlank()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + @PatchMapping("/profile") + public ResponseEntity updateProfile( + HttpServletRequest request, + @RequestBody PanelUpdateProfileRequest requestData) { + + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + boolean isSuperAdmin = permissionService.isSuperAdmin(server, email); + + String username = requestData.hasUsername() ? requestData.getUsername() : null; + String language = requestData.hasLanguage() ? requestData.getLanguage() : null; + String dateFormat = requestData.hasDateFormat() ? requestData.getDateFormat() : null; + + Optional result = staffProfileService.updateOrCreateProfileUsername(server, email, username, isSuperAdmin, language, dateFormat); + if (result.isEmpty()) { + if (isSuperAdmin) { + String resolvedUsername = username != null ? username : SuperAdminStaffSynthesizer.SUPER_ADMIN_USERNAME; + String resolvedLanguage = language != null ? language : SupportedLanguages.DEFAULT; + String resolvedDateFormat = dateFormat != null ? dateFormat : Staff.DEFAULT_DATE_FORMAT; + return superAdminProfileResponse(email, resolvedUsername, resolvedLanguage, resolvedDateFormat); + } + return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Staff member not found")); + } + Staff staff = result.get(); + String role = isSuperAdmin ? RoleAuthorization.SUPER_ADMIN_ROLE_NAME : permissionService.resolveRoleName(server, staff.getRoleId()); + String minecraftUsername = minecraftUsernameOrPanel(staff); + return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( + staff.getId(), staff.getEmail(), staff.getUsername(), role, minecraftUsername, staff.getLanguage(), staff.getDateFormat())); + } + + @PostMapping("/email/send-code") + public ResponseEntity sendEmailChangeCode( + HttpServletRequest request, + @RequestBody PanelUpdateEmailRequest requestData) { + + String currentEmail = RequestUtil.getSessionEmail(request); + if (currentEmail == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + emailChangeService.sendChangeCode(server, currentEmail, requestData.getNewEmail()); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, "Verification code sent to new email.")); + } + + @PatchMapping("/email") + public ResponseEntity updateEmail( + HttpServletRequest request, + HttpServletResponse response, + @RequestBody PanelUpdateEmailWithCodeRequest requestData) { + + String currentEmail = RequestUtil.getSessionEmail(request); + if (currentEmail == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + AuthSessionData newSession = emailChangeService.changeEmail( + server, currentEmail, requestData.getNewEmail(), requestData.getCode(), + RequestUtil.getClientIp(request), request.getHeader("User-Agent")); + response.addCookie(cookieUtil.createSessionCookie(newSession.getId())); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, "Email updated successfully.")); + } + + @GetMapping("/me") + public ResponseEntity getCurrentUser(HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + boolean isSuperAdmin = permissionService.isSuperAdmin(server, email); + + Optional staffOpt = staffLookupCache.findByEmail(server, email); + + if (staffOpt.isPresent()) { + Staff staff = staffOpt.get(); + String role = isSuperAdmin ? RoleAuthorization.SUPER_ADMIN_ROLE_NAME : permissionService.resolveRoleName(server, staff.getRoleId()); + String minecraftUsername = minecraftUsernameOrPanel(staff); + return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( + staff.getId(), staff.getEmail(), staff.getUsername(), role, minecraftUsername, staff.getLanguage(), staff.getDateFormat())); + } + + if (isSuperAdmin) { + return superAdminProfileResponse(email, SuperAdminStaffSynthesizer.SUPER_ADMIN_USERNAME, SupportedLanguages.DEFAULT, Staff.DEFAULT_DATE_FORMAT); + } + + return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Staff member not found")); + } + + @GetMapping("/sessions") + public ResponseEntity getSessions(HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + AuthSessionData currentSession = RequestUtil.getSession(request); + String currentSessionId = currentSession != null ? currentSession.getId() : null; + + List sessions = sessionService.findAllSessionsForEmail(server, email); + + return ResponseEntity.ok(PanelAuthProtoMapper.toSessionsResponse(sessions, currentSessionId)); + } + + @DeleteMapping("/sessions/{publicId}") + public ResponseEntity revokeSession( + HttpServletRequest request, + HttpServletResponse response, + @PathVariable String publicId) { + + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + Optional target = sessionService.findSessionByPublicId(server, email, publicId); + if (target.isEmpty()) { + return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Session not found")); + } + + String sessionId = target.get().getId(); + sessionService.invalidateSession(server, sessionId); + expireCookiesIfCurrentSession(request, response, sessionId); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGOUT_SUCCESS)); + } + + @DeleteMapping("/sessions") + public ResponseEntity revokeAllSessions(HttpServletRequest request, HttpServletResponse response) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + sessionService.invalidateAllSessionsForEmail(server, email); + expireSessionCookies(response); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGOUT_SUCCESS)); + } + + private void expireCookiesIfCurrentSession(HttpServletRequest request, HttpServletResponse response, String sessionId) { + AuthSessionData currentSession = RequestUtil.getSession(request); + if (currentSession != null && sessionId.equals(currentSession.getId())) { + expireSessionCookies(response); + } + } + + private void expireSessionCookies(HttpServletResponse response) { + for (Cookie cookie : cookieUtil.createExpiredSessionCookies()) { + response.addCookie(cookie); + } + } + + private ResponseEntity verificationCodeSentResponse() { + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.VERIFICATION_CODE_SENT)); + } + + private ResponseEntity invalidCodeResponse() { + return ResponseEntity.badRequest().body(PanelAuthProtoMapper.toAuthResponse(false, AuthResponseMessage.INVALID_CODE)); + } + + private ResponseEntity superAdminProfileResponse(String email, String username, String language, String dateFormat) { + return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( + null, email, username, RoleAuthorization.SUPER_ADMIN_ROLE_NAME, username, language, dateFormat)); + } + + private static String minecraftUsernameOrPanel(Staff staff) { + return staff.getAssignedMinecraftUsername() != null + ? staff.getAssignedMinecraftUsername() + : staff.getUsername(); + } + + @GetMapping("/permissions") + public ResponseEntity getUserPermissions(HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toPermissionsResponse(List.of())); + } + + Server server = RequestUtil.getRequestServer(request); + + if (permissionService.isSuperAdmin(server, email)) { + return ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(permissionService.getAllPermissionIds(server))); + } + + Optional staffOpt = staffLookupCache.findByEmail(server, email); + if (staffOpt.isEmpty()) { + return ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(List.of())); + } + + String roleId = RoleAuthorization.effectiveRoleId(server, staffOpt.get()); + Optional roleOpt = permissionService.getRoleById(server, roleId); + + return roleOpt.map(staffRole -> ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(staffRole.getPermissions()))) + .orElseGet(() -> ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(List.of()))); + } +} diff --git a/src/main/java/gg/modl/backend/auth/controller/WebAuthnController.java b/src/main/java/gg/modl/backend/auth/controller/WebAuthnController.java index ce92a59..9ec9212 100644 --- a/src/main/java/gg/modl/backend/auth/controller/WebAuthnController.java +++ b/src/main/java/gg/modl/backend/auth/controller/WebAuthnController.java @@ -1,186 +1,174 @@ -package gg.modl.backend.auth.controller; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import gg.modl.backend.auth.WebAuthnService; -import gg.modl.backend.auth.session.AuthSessionData; -import gg.modl.backend.auth.session.SessionService; -import gg.modl.backend.infrastructure.exception.UnauthorizedException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.util.CookieUtil; -import gg.modl.proto.modl.v1.RenameWebAuthnCredentialRequest; -import gg.modl.proto.modl.v1.WebAuthnCredentialMutationResponse; -import gg.modl.proto.modl.v1.WebAuthnCredentialsResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.Size; -import jakarta.validation.constraints.NotBlank; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_AUTH + "/webauthn") -@RequiredArgsConstructor -public class WebAuthnController { - private final WebAuthnService webAuthnService; - private final SessionService sessionService; - private final PermissionService permissionService; - private final ObjectMapper objectMapper; - private final CookieUtil cookieUtil; - - - @PostMapping("/register/options") - public ResponseEntity registerOptions(HttpServletRequest request) throws JsonProcessingException { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } - - Server server = RequestUtil.getRequestServer(request); - WebAuthnService.StartRegistrationResult result = webAuthnService.startRegistration(server, email); - Object options = objectMapper.readValue(result.optionsJson(), Object.class); - return ResponseEntity.ok(Map.of("challengeId", result.challengeId(), "options", options)); - } - - @PostMapping("/register/verify") - public ResponseEntity registerVerify( - HttpServletRequest request, - @RequestBody @Valid RegisterVerifyRequest body) throws Exception { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } - - Server server = RequestUtil.getRequestServer(request); - webAuthnService.finishRegistration(server, email, body.challengeId(), body.response(), body.name()); - return ResponseEntity.ok(Map.of("success", true)); - } - - @GetMapping("/credentials") - public ResponseEntity listCredentials(HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } - - Server server = RequestUtil.getRequestServer(request); - List credentials = webAuthnService.listCredentials(server, email); - return ResponseEntity.ok(WebAuthnProtoMapper.toCredentialsResponse(credentials)); - } - - @PatchMapping("/credentials/{id}") - public ResponseEntity renameCredential( - HttpServletRequest request, - @PathVariable String id, - @RequestBody RenameWebAuthnCredentialRequest body) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } - - Server server = RequestUtil.getRequestServer(request); - boolean updated = webAuthnService.renameCredential(server, email, id, body.getName()); - if (!updated) { - return ResponseEntity.notFound().build(); - } - return ResponseEntity.ok(WebAuthnProtoMapper.toMutationResponse(true)); - } - - - @DeleteMapping("/credentials/{id}") - public ResponseEntity deleteCredential( - HttpServletRequest request, - @PathVariable String id) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } - - Server server = RequestUtil.getRequestServer(request); - boolean deleted = webAuthnService.deleteCredential(server, email, id); - if (!deleted) { - return ResponseEntity.notFound().build(); - } - return ResponseEntity.ok(WebAuthnProtoMapper.toMutationResponse(true)); - } - - @PostMapping("/login/start") - public ResponseEntity loginStart(HttpServletRequest request) throws JsonProcessingException { - Server server = RequestUtil.getRequestServer(request); - WebAuthnService.StartAuthenticationResult result = webAuthnService.startDiscoverableAuthentication(server); - Object options = objectMapper.readValue(result.optionsJson(), Object.class); - return ResponseEntity.ok(Map.of("challengeId", result.challengeId(), "options", options)); - } - - @PostMapping("/login/options") - public ResponseEntity loginOptions( - HttpServletRequest request, - @RequestBody @Valid LoginOptionsRequest body) throws JsonProcessingException { - Server server = RequestUtil.getRequestServer(request); - - // Prevent email enumeration: check if email is authorized first - if (!permissionService.isAuthorizedEmail(server, body.email())) { - return ResponseEntity.ok(Map.of("hasPasskeys", false)); - } - - boolean hasPasskeys = webAuthnService.checkHasPasskeys(server, body.email()); - if (!hasPasskeys) { - return ResponseEntity.ok(Map.of("hasPasskeys", false)); - } - - WebAuthnService.StartAuthenticationResult result = webAuthnService.startAuthentication(server, body.email()); - Object options = objectMapper.readValue(result.optionsJson(), Object.class); - return ResponseEntity.ok(Map.of( - "hasPasskeys", true, - "challengeId", result.challengeId(), - "options", options - )); - } - - - @PostMapping("/login/verify") - public ResponseEntity loginVerify( - HttpServletRequest request, - HttpServletResponse response, - @RequestBody @Valid LoginVerifyRequest body) throws Exception { - Server server = RequestUtil.getRequestServer(request); - - String email = webAuthnService.finishAuthentication(server, body.challengeId(), body.response(), - candidateEmail -> permissionService.isAuthorizedEmail(server, candidateEmail)); - - AuthSessionData session = sessionService.createSession(server, email, RequestUtil.getClientIp(request), request.getHeader("User-Agent")); - response.addCookie(cookieUtil.createSessionCookie(session.getId())); - - return ResponseEntity.ok(Map.of("success", true)); - } - - - public record RegisterVerifyRequest( - @NotBlank @Size(max = 256) String challengeId, - @NotBlank @Size(max = 10_000) String response, - @Size(max = 128) String name - ) {} - - public record LoginOptionsRequest(@Email @NotBlank @Size(max = RequestValidationLimits.EMAIL_MAX_LENGTH) String email) {} - - public record LoginVerifyRequest( - @NotBlank @Size(max = 256) String challengeId, - @NotBlank @Size(max = 10_000) String response - ) {} -} +package gg.modl.backend.auth.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.auth.WebAuthnService; +import gg.modl.backend.auth.session.AuthSessionData; +import gg.modl.backend.auth.session.SessionService; +import gg.modl.backend.infrastructure.exception.UnauthorizedException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.util.CookieUtil; +import gg.modl.proto.modl.v1.RenameWebAuthnCredentialRequest; +import gg.modl.proto.modl.v1.WebAuthnCredentialMutationResponse; +import gg.modl.proto.modl.v1.WebAuthnCredentialsResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_AUTH + "/webauthn") +@RequiredArgsConstructor +public class WebAuthnController { + private final WebAuthnService webAuthnService; + private final SessionService sessionService; + private final PermissionService permissionService; + private final ObjectMapper objectMapper; + private final CookieUtil cookieUtil; + + @PostMapping("/register/options") + public ResponseEntity registerOptions(HttpServletRequest request) throws JsonProcessingException { + String email = requireSessionEmail(request); + + Server server = RequestUtil.getRequestServer(request); + WebAuthnService.StartRegistrationResult result = webAuthnService.startRegistration(server, email); + Object options = objectMapper.readValue(result.optionsJson(), Object.class); + return ResponseEntity.ok(Map.of("challengeId", result.challengeId(), "options", options)); + } + + @PostMapping("/register/verify") + public ResponseEntity registerVerify( + HttpServletRequest request, + @RequestBody @Valid RegisterVerifyRequest body) throws Exception { + String email = requireSessionEmail(request); + + Server server = RequestUtil.getRequestServer(request); + webAuthnService.finishRegistration(server, email, body.challengeId(), body.response(), body.name()); + return ResponseEntity.ok(Map.of("success", true)); + } + + @GetMapping("/credentials") + public ResponseEntity listCredentials(HttpServletRequest request) { + String email = requireSessionEmail(request); + + Server server = RequestUtil.getRequestServer(request); + List credentials = webAuthnService.listCredentials(server, email); + return ResponseEntity.ok(WebAuthnProtoMapper.toCredentialsResponse(credentials)); + } + + @PatchMapping("/credentials/{id}") + public ResponseEntity renameCredential( + HttpServletRequest request, + @PathVariable String id, + @RequestBody RenameWebAuthnCredentialRequest body) { + String email = requireSessionEmail(request); + + Server server = RequestUtil.getRequestServer(request); + boolean updated = webAuthnService.renameCredential(server, email, id, body.getName()); + if (!updated) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(WebAuthnProtoMapper.toMutationResponse(true)); + } + + @DeleteMapping("/credentials/{id}") + public ResponseEntity deleteCredential( + HttpServletRequest request, + @PathVariable String id) { + String email = requireSessionEmail(request); + + Server server = RequestUtil.getRequestServer(request); + boolean deleted = webAuthnService.deleteCredential(server, email, id); + if (!deleted) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(WebAuthnProtoMapper.toMutationResponse(true)); + } + + @PostMapping("/login/start") + public ResponseEntity loginStart(HttpServletRequest request) throws JsonProcessingException { + Server server = RequestUtil.getRequestServer(request); + WebAuthnService.StartAuthenticationResult result = webAuthnService.startDiscoverableAuthentication(server); + Object options = objectMapper.readValue(result.optionsJson(), Object.class); + return ResponseEntity.ok(Map.of("challengeId", result.challengeId(), "options", options)); + } + + @PostMapping("/login/options") + public ResponseEntity loginOptions( + HttpServletRequest request, + @RequestBody @Valid LoginOptionsRequest body) throws JsonProcessingException { + Server server = RequestUtil.getRequestServer(request); + + if (!permissionService.isAuthorizedEmail(server, body.email())) { + return ResponseEntity.ok(Map.of("hasPasskeys", false)); + } + + boolean hasPasskeys = webAuthnService.checkHasPasskeys(server, body.email()); + if (!hasPasskeys) { + return ResponseEntity.ok(Map.of("hasPasskeys", false)); + } + + WebAuthnService.StartAuthenticationResult result = webAuthnService.startAuthentication(server, body.email()); + Object options = objectMapper.readValue(result.optionsJson(), Object.class); + return ResponseEntity.ok(Map.of( + "hasPasskeys", true, + "challengeId", result.challengeId(), + "options", options + )); + } + + @PostMapping("/login/verify") + public ResponseEntity loginVerify( + HttpServletRequest request, + HttpServletResponse response, + @RequestBody @Valid LoginVerifyRequest body) throws Exception { + Server server = RequestUtil.getRequestServer(request); + + String email = webAuthnService.finishAuthentication(server, body.challengeId(), body.response(), + candidateEmail -> permissionService.isAuthorizedEmail(server, candidateEmail)); + + AuthSessionData session = sessionService.createSession(server, email, RequestUtil.getClientIp(request), request.getHeader("User-Agent")); + response.addCookie(cookieUtil.createSessionCookie(session.getId())); + + return ResponseEntity.ok(Map.of("success", true)); + } + + private String requireSessionEmail(HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + throw new UnauthorizedException("Not authenticated"); + } + return email; + } + + public record RegisterVerifyRequest( + @NotBlank @Size(max = 256) String challengeId, + @NotBlank @Size(max = 10_000) String response, + @Size(max = 128) String name + ) {} + + public record LoginOptionsRequest(@Email @NotBlank @Size(max = RequestValidationLimits.EMAIL_MAX_LENGTH) String email) {} + + public record LoginVerifyRequest( + @NotBlank @Size(max = 256) String challengeId, + @NotBlank @Size(max = 10_000) String response + ) {} +} diff --git a/src/main/java/gg/modl/backend/auth/data/WebAuthnChallenge.java b/src/main/java/gg/modl/backend/auth/data/WebAuthnChallenge.java index 112720a..62c79ed 100644 --- a/src/main/java/gg/modl/backend/auth/data/WebAuthnChallenge.java +++ b/src/main/java/gg/modl/backend/auth/data/WebAuthnChallenge.java @@ -1,30 +1,30 @@ -package gg.modl.backend.auth.data; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document(collection = CollectionName.WEBAUTHN_CHALLENGES) -@GenerateMongoFields -@Data -@NoArgsConstructor -@AllArgsConstructor -public class WebAuthnChallenge { - @Id - private String id; - - @Field("challengeJson") - private String challengeJson; - - @Field("email") - private String email; - - @Field("expiresAt") - private Date expiresAt; -} +package gg.modl.backend.auth.data; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document(collection = CollectionName.WEBAUTHN_CHALLENGES) +@GenerateMongoFields +@Data +@NoArgsConstructor +@AllArgsConstructor +public class WebAuthnChallenge { + @Id + private String id; + + @Field("challengeJson") + private String challengeJson; + + @Field("email") + private String email; + + @Field("expiresAt") + private Date expiresAt; +} diff --git a/src/main/java/gg/modl/backend/auth/data/WebAuthnCredential.java b/src/main/java/gg/modl/backend/auth/data/WebAuthnCredential.java index 05b0639..1497465 100644 --- a/src/main/java/gg/modl/backend/auth/data/WebAuthnCredential.java +++ b/src/main/java/gg/modl/backend/auth/data/WebAuthnCredential.java @@ -1,45 +1,45 @@ -package gg.modl.backend.auth.data; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document(collection = CollectionName.WEBAUTHN_CREDENTIALS) -@GenerateMongoFields -@Data -@NoArgsConstructor -@AllArgsConstructor -public class WebAuthnCredential { - @Id - private String id; - - @Field("email") - private String email; - - @Field("credentialId") - private String credentialId; - - @Field("publicKeyCose") - private byte[] publicKeyCose; - - @Field("signatureCount") - private long signatureCount; - - @Field("userHandle") - private String userHandle; - - @Field("name") - private String name; - - @Field("createdAt") - private Date createdAt; - - @Field("lastUsedAt") - private Date lastUsedAt; -} +package gg.modl.backend.auth.data; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document(collection = CollectionName.WEBAUTHN_CREDENTIALS) +@GenerateMongoFields +@Data +@NoArgsConstructor +@AllArgsConstructor +public class WebAuthnCredential { + @Id + private String id; + + @Field("email") + private String email; + + @Field("credentialId") + private String credentialId; + + @Field("publicKeyCose") + private byte[] publicKeyCose; + + @Field("signatureCount") + private long signatureCount; + + @Field("userHandle") + private String userHandle; + + @Field("name") + private String name; + + @Field("createdAt") + private Date createdAt; + + @Field("lastUsedAt") + private Date lastUsedAt; +} diff --git a/src/main/java/gg/modl/backend/auth/session/AuthSessionData.java b/src/main/java/gg/modl/backend/auth/session/AuthSessionData.java index 8eaaeef..839ea31 100644 --- a/src/main/java/gg/modl/backend/auth/session/AuthSessionData.java +++ b/src/main/java/gg/modl/backend/auth/session/AuthSessionData.java @@ -1,37 +1,37 @@ -package gg.modl.backend.auth.session; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; - -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document(collection = CollectionName.SESSIONS) -@GenerateMongoFields -@Data -@NoArgsConstructor -@AllArgsConstructor -public class AuthSessionData { - @Id - private String id; - - @Field("email") - private String email; - - @Field("createdAt") - private Date createdAt; - - @Field("expiresAt") - private Date expiresAt; - - @Field("ipAddress") - private String ipAddress; - - @Field("userAgent") - private String userAgent; -} +package gg.modl.backend.auth.session; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; + +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document(collection = CollectionName.SESSIONS) +@GenerateMongoFields +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AuthSessionData { + @Id + private String id; + + @Field("email") + private String email; + + @Field("createdAt") + private Date createdAt; + + @Field("expiresAt") + private Date expiresAt; + + @Field("ipAddress") + private String ipAddress; + + @Field("userAgent") + private String userAgent; +} diff --git a/src/main/java/gg/modl/backend/auth/session/SessionPublicId.java b/src/main/java/gg/modl/backend/auth/session/SessionPublicId.java index 5a4d78f..da904ef 100644 --- a/src/main/java/gg/modl/backend/auth/session/SessionPublicId.java +++ b/src/main/java/gg/modl/backend/auth/session/SessionPublicId.java @@ -1,8 +1,6 @@ package gg.modl.backend.auth.session; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; +import gg.modl.backend.infrastructure.util.DigestUtils; public final class SessionPublicId { @@ -13,16 +11,6 @@ public static String of(String sessionId) { if (sessionId == null) { return null; } - try { - byte[] digest = MessageDigest.getInstance("SHA-256").digest(sessionId.getBytes(StandardCharsets.UTF_8)); - StringBuilder hex = new StringBuilder(digest.length * 2); - for (byte b : digest) { - hex.append(Character.forDigit((b >> 4) & 0xF, 16)); - hex.append(Character.forDigit(b & 0xF, 16)); - } - return hex.toString(); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 not available", e); - } + return DigestUtils.sha256Hex(sessionId); } } diff --git a/src/main/java/gg/modl/backend/auth/session/SessionService.java b/src/main/java/gg/modl/backend/auth/session/SessionService.java index 5d221ae..cb3c00c 100644 --- a/src/main/java/gg/modl/backend/auth/session/SessionService.java +++ b/src/main/java/gg/modl/backend/auth/session/SessionService.java @@ -81,21 +81,6 @@ public Optional findAndRefreshSession(Server server, String ses ); } - public Optional findValidSession(Server server, String sessionId) { - if (sessionId == null || sessionId.isBlank()) { - return Optional.empty(); - } - return sessionRepository.findActiveById(server, sessionId, new Date()); - } - - public void refreshSession(Server server, String sessionId) { - sessionRepository.refreshExpiresAt(server, sessionId, nextExpiryDate()); - } - - private Date nextExpiryDate() { - return nextExpiryDate(new Date()); - } - public Optional findAndRefreshAdminSession(String sessionId) { return findAndRefreshInternal( sessionId, @@ -128,10 +113,6 @@ public Optional findValidAdminSession(String sessionId) { return sessionRepository.findActiveByIdGlobal(sessionId, new Date()); } - public void refreshAdminSession(String sessionId) { - sessionRepository.refreshExpiresAtGlobal(sessionId, nextExpiryDate()); - } - public void invalidateSession(Server server, String sessionId) { sessionRepository.deleteById(server, sessionId); } diff --git a/src/main/java/gg/modl/backend/beta/AdminBetaTesterController.java b/src/main/java/gg/modl/backend/beta/AdminBetaTesterController.java index 032dd57..1a9ba20 100644 --- a/src/main/java/gg/modl/backend/beta/AdminBetaTesterController.java +++ b/src/main/java/gg/modl/backend/beta/AdminBetaTesterController.java @@ -1,6 +1,5 @@ package gg.modl.backend.beta; -import gg.modl.backend.admin.service.AdminAuthService; import gg.modl.backend.infrastructure.filter.AdminAuthFilter; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.proto.modl.v1.BetaAuditResponse; @@ -83,10 +82,7 @@ public ResponseEntity audit( } private String actingAdminEmail(HttpServletRequest request) { - Object attribute = request.getAttribute(AdminAuthFilter.ADMIN_SESSION_ATTR); - if (attribute instanceof AdminAuthService.AdminSession session) { - return session.email(); - } - throw new BetaRequestException("Admin authentication required.", HttpStatus.UNAUTHORIZED); + return AdminAuthFilter.actingEmail(request) + .orElseThrow(() -> new BetaRequestException("Admin authentication required.", HttpStatus.UNAUTHORIZED)); } } diff --git a/src/main/java/gg/modl/backend/beta/AdminBetaTesterService.java b/src/main/java/gg/modl/backend/beta/AdminBetaTesterService.java index 157c691..d9ed4de 100644 --- a/src/main/java/gg/modl/backend/beta/AdminBetaTesterService.java +++ b/src/main/java/gg/modl/backend/beta/AdminBetaTesterService.java @@ -2,7 +2,9 @@ import gg.modl.backend.beta.data.BetaAudit; import gg.modl.backend.database.mongo.repository.AuthSessionMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerAdminRepository; +import gg.modl.backend.database.mongo.repository.ServerBetaTesterRepository; +import gg.modl.backend.database.mongo.repository.ServerProvisioningRepository; import gg.modl.backend.email.EmailAddressUtil; import gg.modl.backend.email.EmailHTMLTemplate; import gg.modl.backend.email.EmailService; @@ -29,7 +31,9 @@ public class AdminBetaTesterService { private static final int DEFAULT_PAGE_LIMIT = 50; - private final ServerMongoRepository serverRepository; + private final ServerBetaTesterRepository serverBetaTesterRepository; + private final ServerAdminRepository serverAdminRepository; + private final ServerProvisioningRepository serverProvisioningRepository; private final ServerService serverService; private final ServerProvisioningService provisioningService; private final RegistrationService registrationService; @@ -45,8 +49,8 @@ public BetaTesterPage list(int page, int limit, String search) { int normalizedLimit = PaginationHelper.normalizeLimit(limit, DEFAULT_PAGE_LIMIT); int skip = PaginationHelper.calculateSkip(page, normalizedLimit); - List servers = serverRepository.findBetaTesters(search, skip, normalizedLimit); - long total = serverRepository.countBetaTesters(search); + List servers = serverBetaTesterRepository.findBetaTesters(search, skip, normalizedLimit); + long total = serverBetaTesterRepository.countBetaTesters(search); int pages = PaginationHelper.calculateTotalPages(total, normalizedLimit); List items = servers.stream().map(this::details).toList(); @@ -54,7 +58,7 @@ public BetaTesterPage list(int page, int limit, String search) { } public BetaTesterDetails get(String id) { - Server server = serverRepository.findById(id) + Server server = serverBetaTesterRepository.findById(id) .filter(candidate -> candidate.getBetaTesterCreatedAt() != null) .orElseThrow(() -> new BetaRequestException("Beta tester not found.", HttpStatus.NOT_FOUND)); return details(server); @@ -78,21 +82,23 @@ public BetaTesterDetails create(BetaTesterCreation creation, String actingAdminE }); Server saved = persistBetaServer(trimmedName, subdomain, normalizedEmail, actingAdminEmail); + boolean tenantDatabaseExisted = provisioningService.tenantDatabaseExists(saved); try { provisioningService.provision(saved); } catch (Exception e) { - serverRepository.deleteByServerId(saved.getId()); + serverAdminRepository.deleteByServerId(saved.getId()); + provisioningService.teardownProvisionedDatabase(saved, tenantDatabaseExisted); serverService.evictAllServerCaches(); log.error("Beta provisioning failed for {}; rolled back the server row", subdomain, e); throw new BetaRequestException("Failed to provision beta tester panel. Please retry.", HttpStatus.INTERNAL_SERVER_ERROR); } registrationService.resolveOrGenerateApiKey(saved); - serverRepository.markProvisioningCompleted(saved.getId()); + serverProvisioningRepository.markProvisioningCompleted(saved.getId()); serverService.evictAllServerCaches(); - Server reloaded = serverRepository.findById(saved.getId()).orElse(saved); + Server reloaded = serverBetaTesterRepository.findById(saved.getId()).orElse(saved); betaAuditService.record(BetaAuditAction.CREATE, reloaded.getId(), actingAdminEmail, "Created beta tester panel " + subdomain); sendBetaReadyEmail(reloaded); @@ -120,7 +126,7 @@ private Server persistBetaServer(String serverName, String subdomain, String adm server.setBetaTesterCreatedBy(actingAdminEmail); server.setCreatedAt(now); server.setUpdatedAt(now); - Server saved = serverRepository.saveEntity(server); + Server saved = serverBetaTesterRepository.saveEntity(server); serverService.evictAllServerCaches(); return saved; } @@ -128,7 +134,7 @@ private Server persistBetaServer(String serverName, String subdomain, String adm public BetaTesterDetails revoke(String id, String actingAdminEmail) { requireActiveBetaTester(id); - Server updated = serverRepository.updateBetaState(id, ServerPlan.FREE, SubscriptionStatus.INACTIVE, false) + Server updated = serverBetaTesterRepository.updateBetaState(id, ServerPlan.FREE, SubscriptionStatus.INACTIVE, false) .orElseThrow(() -> new BetaRequestException("Failed to revoke beta tester.", HttpStatus.INTERNAL_SERVER_ERROR)); authSessionRepository.deleteAllForServer(updated); serverService.evictAllServerCaches(); @@ -160,7 +166,7 @@ public List audit(String id, int limit) { } private Server requireActiveBetaTester(String id) { - Server server = serverRepository.findById(id) + Server server = serverBetaTesterRepository.findById(id) .orElseThrow(() -> new BetaRequestException("Beta tester not found.", HttpStatus.NOT_FOUND)); if (!Boolean.TRUE.equals(server.getBetaTester())) { throw new BetaRequestException("Beta tester not found.", HttpStatus.NOT_FOUND); diff --git a/src/main/java/gg/modl/backend/beta/BetaAuditService.java b/src/main/java/gg/modl/backend/beta/BetaAuditService.java index 7ba6a84..5d07b33 100644 --- a/src/main/java/gg/modl/backend/beta/BetaAuditService.java +++ b/src/main/java/gg/modl/backend/beta/BetaAuditService.java @@ -16,12 +16,13 @@ public class BetaAuditService { private final BetaAuditMongoRepository auditRepository; public void record(BetaAuditAction action, String serverId, String adminEmail, String details) { - BetaAudit audit = new BetaAudit(); - audit.setAction(action.name()); - audit.setServerId(serverId); - audit.setAdminEmail(adminEmail); - audit.setTimestamp(new Date()); - audit.setDetails(details); + BetaAudit audit = BetaAudit.builder() + .action(action.name()) + .serverId(serverId) + .adminEmail(adminEmail) + .timestamp(new Date()) + .details(details) + .build(); auditRepository.saveEntity(audit); } diff --git a/src/main/java/gg/modl/backend/beta/BetaResetService.java b/src/main/java/gg/modl/backend/beta/BetaResetService.java index 6cf1185..6589829 100644 --- a/src/main/java/gg/modl/backend/beta/BetaResetService.java +++ b/src/main/java/gg/modl/backend/beta/BetaResetService.java @@ -8,7 +8,8 @@ import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; import gg.modl.backend.database.mongo.repository.ReplayMongoRepository; import gg.modl.backend.database.mongo.repository.ServerLogMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerBetaTesterRepository; +import gg.modl.backend.database.mongo.repository.ServerUsageRepository; import gg.modl.backend.database.mongo.repository.StorageFileMongoRepository; import gg.modl.backend.database.mongo.repository.TicketMongoRepository; import gg.modl.backend.database.mongo.repository.TicketVerificationMongoRepository; @@ -45,7 +46,8 @@ public class BetaResetService { private final WebAuthnChallengeMongoRepository webAuthnChallengeRepository; private final AuthSessionMongoRepository authSessionRepository; private final AuthCodeMongoRepository authCodeRepository; - private final ServerMongoRepository serverRepository; + private final ServerBetaTesterRepository serverBetaTesterRepository; + private final ServerUsageRepository serverUsageRepository; private final ServerService serverService; private final Set resetsInFlight = ConcurrentHashMap.newKeySet(); @@ -58,7 +60,7 @@ public List reset(Server server) { @Async public CompletableFuture> resetAll() { - List servers = serverRepository.findAllBetaTesters(); + List servers = serverBetaTesterRepository.findAllBetaTesters(); List results = new ArrayList<>(); boolean anyCleared = false; for (Server server : servers) { @@ -98,7 +100,7 @@ private List clearTenant(Server server) { log.error("Beta reset failed clearing {} for server {}", step.getKey(), server.getId(), e); } } - serverRepository.resetUsageAndStatsCounters(server.getId()); + serverUsageRepository.resetUsageAndStatsCounters(server.getId()); return cleared; } diff --git a/src/main/java/gg/modl/backend/beta/data/BetaAudit.java b/src/main/java/gg/modl/backend/beta/data/BetaAudit.java index 95ba075..21dd0de 100644 --- a/src/main/java/gg/modl/backend/beta/data/BetaAudit.java +++ b/src/main/java/gg/modl/backend/beta/data/BetaAudit.java @@ -2,12 +2,18 @@ import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; @Data +@Builder +@NoArgsConstructor +@AllArgsConstructor @Document(collection = "beta_audit") @GenerateMongoFields public class BetaAudit { diff --git a/src/main/java/gg/modl/backend/billing/config/StripeConfiguration.java b/src/main/java/gg/modl/backend/billing/config/StripeConfiguration.java index 8528e15..6616d68 100644 --- a/src/main/java/gg/modl/backend/billing/config/StripeConfiguration.java +++ b/src/main/java/gg/modl/backend/billing/config/StripeConfiguration.java @@ -1,46 +1,36 @@ -package gg.modl.backend.billing.config; - -import com.stripe.Stripe; -import com.stripe.StripeClient; -import jakarta.annotation.PostConstruct; -import lombok.Getter; -import lombok.Setter; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.validation.annotation.Validated; - -@Configuration -@ConfigurationProperties(prefix = "modl.stripe") -@Validated -@Getter -@Setter -@Slf4j -public class StripeConfiguration { - private String secretKey = ""; - private String webhookSecret = ""; - private String priceId = ""; - - @PostConstruct - public void init() { - if (secretKey != null && !secretKey.isBlank()) { - Stripe.apiKey = secretKey; - log.info("Stripe API initialized"); - } else { - log.warn("STRIPE_SECRET_KEY not found. Billing features will be disabled."); - } - } - - @Bean - public StripeClient stripeClient() { - if (secretKey == null || secretKey.isBlank()) { - return null; - } - return new StripeClient(secretKey); - } - - public boolean isConfigured() { - return secretKey != null && !secretKey.isBlank(); - } -} +package gg.modl.backend.billing.config; + +import com.stripe.Stripe; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.annotation.Validated; + +@Configuration +@ConfigurationProperties(prefix = "modl.stripe") +@Validated +@Getter +@Setter +@Slf4j +public class StripeConfiguration { + private String secretKey = ""; + private String webhookSecret = ""; + private String priceId = ""; + + @PostConstruct + public void init() { + if (isConfigured()) { + Stripe.apiKey = secretKey; + log.info("Stripe API initialized"); + } else { + log.warn("STRIPE_SECRET_KEY not found. Billing features will be disabled."); + } + } + + public boolean isConfigured() { + return secretKey != null && !secretKey.isBlank(); + } +} diff --git a/src/main/java/gg/modl/backend/billing/controller/PanelBillingController.java b/src/main/java/gg/modl/backend/billing/controller/PanelBillingController.java index ef2b356..4df6f82 100644 --- a/src/main/java/gg/modl/backend/billing/controller/PanelBillingController.java +++ b/src/main/java/gg/modl/backend/billing/controller/PanelBillingController.java @@ -1,127 +1,131 @@ -package gg.modl.backend.billing.controller; - -import gg.modl.backend.billing.service.BillingService; -import gg.modl.backend.billing.service.UsageTrackingService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.BillingStatusResponse; -import gg.modl.proto.modl.v1.CancelResponse; -import gg.modl.proto.modl.v1.CheckoutSessionResponse; -import gg.modl.proto.modl.v1.PortalSessionResponse; -import gg.modl.proto.modl.v1.ResubscribeResponse; -import gg.modl.proto.modl.v1.UpdateOverageLimitsRequest; -import gg.modl.proto.modl.v1.UpdateOverageLimitsResponse; -import gg.modl.proto.modl.v1.UpdateStorageLimitRequest; -import gg.modl.proto.modl.v1.UpdateStorageLimitResponse; -import gg.modl.proto.modl.v1.UsageBillingSettingsRequest; -import gg.modl.proto.modl.v1.UsageBillingSettingsResponse; -import gg.modl.proto.modl.v1.UsageResponse; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_BILLING) -@RequiredArgsConstructor -public class PanelBillingController { - private final BillingService billingService; - private final UsageTrackingService usageTrackingService; - - @PostMapping("/checkout-session") - public ResponseEntity createCheckoutSession(HttpServletRequest request) { - billingService.requireStripeConfigured(); - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - return ResponseEntity.ok(PanelBillingProtoMapper.toCheckoutSessionResponse(billingService.createCheckoutSession(server))); - } - - @PostMapping("/portal-session") - public ResponseEntity createPortalSession(HttpServletRequest request) { - billingService.requireStripeConfigured(); - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - return ResponseEntity.ok(PanelBillingProtoMapper.toPortalSessionResponse(billingService.createPortalSession(server))); - } - - @PostMapping("/cancel") - public ResponseEntity cancelSubscription(HttpServletRequest request) { - billingService.requireStripeConfigured(); - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - return ResponseEntity.ok(PanelBillingProtoMapper.toCancelResponse(billingService.cancelSubscription(server))); - } - - @PostMapping("/resubscribe") - public ResponseEntity resubscribe(HttpServletRequest request) { - billingService.requireStripeConfigured(); - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - return ResponseEntity.ok(PanelBillingProtoMapper.toResubscribeResponse(billingService.resubscribe(server))); - } - - @GetMapping("/status") - public ResponseEntity getBillingStatus(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - billingService.reconcileBillingStatus(server); - return ResponseEntity.ok(PanelBillingProtoMapper.toBillingStatusResponse(billingService.getBillingStatus(server))); - } - - @GetMapping("/usage") - public ResponseEntity getUsage(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelBillingProtoMapper.toUsageResponse(usageTrackingService.getUsage(server))); - } - - @PostMapping("/usage-settings") - public ResponseEntity updateUsageBillingSettings( - @RequestBody UsageBillingSettingsRequest settingsRequest, - HttpServletRequest request - ) { - billingService.requireStripeConfigured(); - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - return ResponseEntity.ok(PanelBillingProtoMapper.toUsageBillingSettingsResponse( - usageTrackingService.updateUsageBillingSettings(server, settingsRequest.getEnabled()))); - } - - @PostMapping("/storage-limit") - public ResponseEntity updateStorageLimit( - @RequestBody UpdateStorageLimitRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - long maxStorageLimitBytes = body.getMaxStorageLimitBytes(); - usageTrackingService.updateStorageLimit(server, maxStorageLimitBytes); - - return ResponseEntity.ok(PanelBillingProtoMapper.toUpdateStorageLimitResponse(maxStorageLimitBytes)); - } - - @PostMapping("/overage-limits") - public ResponseEntity updateOverageLimits( - @RequestBody UpdateOverageLimitsRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - int maxStorageOverageGB = body.hasMaxStorageOverageGbValue() ? body.getMaxStorageOverageGbValue() : body.getMaxStorageOverageGb(); - int maxAiOverageRequests = body.hasMaxAiOverageRequestsValue() ? body.getMaxAiOverageRequestsValue() : body.getMaxAiOverageRequests(); - - long maxStorageLimitBytes = usageTrackingService.updateOverageLimits(server, maxStorageOverageGB, maxAiOverageRequests); - - return ResponseEntity.ok(PanelBillingProtoMapper.toUpdateOverageLimitsResponse(maxStorageLimitBytes, maxAiOverageRequests)); - } -} +package gg.modl.backend.billing.controller; + +import gg.modl.backend.billing.service.BillingService; +import gg.modl.backend.billing.service.UsageTrackingService; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.BillingStatusResponse; +import gg.modl.proto.modl.v1.CancelResponse; +import gg.modl.proto.modl.v1.CheckoutSessionResponse; +import gg.modl.proto.modl.v1.PortalSessionResponse; +import gg.modl.proto.modl.v1.ResubscribeResponse; +import gg.modl.proto.modl.v1.UpdateOverageLimitsRequest; +import gg.modl.proto.modl.v1.UpdateOverageLimitsResponse; +import gg.modl.proto.modl.v1.UpdateStorageLimitRequest; +import gg.modl.proto.modl.v1.UpdateStorageLimitResponse; +import gg.modl.proto.modl.v1.UsageBillingSettingsRequest; +import gg.modl.proto.modl.v1.UsageBillingSettingsResponse; +import gg.modl.proto.modl.v1.UsageResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_BILLING) +@RequiresPanelPermission(view = "admin.settings.view.billing", modify = "admin.settings.modify.billing") +@RequiredArgsConstructor +public class PanelBillingController { + private final BillingService billingService; + private final UsageTrackingService usageTrackingService; + + @PostMapping("/checkout-session") + public ResponseEntity createCheckoutSession(HttpServletRequest request) { + billingService.requireStripeConfigured(); + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + return ResponseEntity.ok(PanelBillingProtoMapper.toCheckoutSessionResponse(billingService.createCheckoutSession(server))); + } + + @PostMapping("/portal-session") + public ResponseEntity createPortalSession(HttpServletRequest request) { + billingService.requireStripeConfigured(); + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + return ResponseEntity.ok(PanelBillingProtoMapper.toPortalSessionResponse(billingService.createPortalSession(server))); + } + + @PostMapping("/cancel") + public ResponseEntity cancelSubscription(HttpServletRequest request) { + billingService.requireStripeConfigured(); + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + return ResponseEntity.ok(PanelBillingProtoMapper.toCancelResponse(billingService.cancelSubscription(server))); + } + + @PostMapping("/resubscribe") + public ResponseEntity resubscribe(HttpServletRequest request) { + billingService.requireStripeConfigured(); + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + return ResponseEntity.ok(PanelBillingProtoMapper.toResubscribeResponse(billingService.resubscribe(server))); + } + + @GetMapping("/status") + public ResponseEntity getBillingStatus(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + billingService.reconcileBillingStatus(server); + return ResponseEntity.ok(PanelBillingProtoMapper.toBillingStatusResponse(billingService.getBillingStatus(server))); + } + + @GetMapping("/usage") + public ResponseEntity getUsage(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + return ResponseEntity.ok(PanelBillingProtoMapper.toUsageResponse(usageTrackingService.getUsage(server))); + } + + @PostMapping("/usage-settings") + public ResponseEntity updateUsageBillingSettings( + @RequestBody UsageBillingSettingsRequest settingsRequest, + HttpServletRequest request + ) { + billingService.requireStripeConfigured(); + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + return ResponseEntity.ok(PanelBillingProtoMapper.toUsageBillingSettingsResponse( + usageTrackingService.updateUsageBillingSettings(server, settingsRequest.getEnabled()))); + } + + @PostMapping("/storage-limit") + public ResponseEntity updateStorageLimit( + @RequestBody UpdateStorageLimitRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + long maxStorageLimitBytes = body.getMaxStorageLimitBytes(); + usageTrackingService.updateStorageLimit(server, maxStorageLimitBytes); + + return ResponseEntity.ok(PanelBillingProtoMapper.toUpdateStorageLimitResponse(maxStorageLimitBytes)); + } + + @PostMapping("/overage-limits") + public ResponseEntity updateOverageLimits( + @RequestBody UpdateOverageLimitsRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + int maxStorageOverageGB = body.hasMaxStorageOverageGbValue() ? body.getMaxStorageOverageGbValue() : body.getMaxStorageOverageGb(); + int maxAiOverageRequests = body.hasMaxAiOverageRequestsValue() ? body.getMaxAiOverageRequestsValue() : body.getMaxAiOverageRequests(); + + long maxStorageLimitBytes = usageTrackingService.updateOverageLimits(server, maxStorageOverageGB, maxAiOverageRequests); + + return ResponseEntity.ok(PanelBillingProtoMapper.toUpdateOverageLimitsResponse(maxStorageLimitBytes, maxAiOverageRequests)); + } +} diff --git a/src/main/java/gg/modl/backend/billing/dto/response/CancelResponse.java b/src/main/java/gg/modl/backend/billing/dto/response/CancelResponse.java index 4eadb9d..c16abf9 100644 --- a/src/main/java/gg/modl/backend/billing/dto/response/CancelResponse.java +++ b/src/main/java/gg/modl/backend/billing/dto/response/CancelResponse.java @@ -1,9 +1,9 @@ -package gg.modl.backend.billing.dto.response; - -import java.util.Date; - -public record CancelResponse( - boolean success, - String message, - Date cancelsAt -) {} +package gg.modl.backend.billing.dto.response; + +import java.util.Date; + +public record CancelResponse( + boolean success, + String message, + Date cancelsAt +) {} diff --git a/src/main/java/gg/modl/backend/billing/dto/response/CheckoutSessionResponse.java b/src/main/java/gg/modl/backend/billing/dto/response/CheckoutSessionResponse.java index 31f4f58..3ac5abe 100644 --- a/src/main/java/gg/modl/backend/billing/dto/response/CheckoutSessionResponse.java +++ b/src/main/java/gg/modl/backend/billing/dto/response/CheckoutSessionResponse.java @@ -1,6 +1,6 @@ -package gg.modl.backend.billing.dto.response; - -public record CheckoutSessionResponse( - String sessionId, - String url -) {} +package gg.modl.backend.billing.dto.response; + +public record CheckoutSessionResponse( + String sessionId, + String url +) {} diff --git a/src/main/java/gg/modl/backend/billing/dto/response/PortalSessionResponse.java b/src/main/java/gg/modl/backend/billing/dto/response/PortalSessionResponse.java index 5f0802c..a742891 100644 --- a/src/main/java/gg/modl/backend/billing/dto/response/PortalSessionResponse.java +++ b/src/main/java/gg/modl/backend/billing/dto/response/PortalSessionResponse.java @@ -1,5 +1,5 @@ -package gg.modl.backend.billing.dto.response; - -public record PortalSessionResponse( - String url -) {} +package gg.modl.backend.billing.dto.response; + +public record PortalSessionResponse( + String url +) {} diff --git a/src/main/java/gg/modl/backend/billing/dto/response/ResubscribeResponse.java b/src/main/java/gg/modl/backend/billing/dto/response/ResubscribeResponse.java index 35c406d..69b868c 100644 --- a/src/main/java/gg/modl/backend/billing/dto/response/ResubscribeResponse.java +++ b/src/main/java/gg/modl/backend/billing/dto/response/ResubscribeResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.billing.dto.response; - -import java.util.Date; - -public record ResubscribeResponse( - boolean success, - String message, - SubscriptionInfo subscription -) { - public record SubscriptionInfo( - String id, - String status, - Date currentPeriodEnd - ) {} -} +package gg.modl.backend.billing.dto.response; + +import java.util.Date; + +public record ResubscribeResponse( + boolean success, + String message, + SubscriptionInfo subscription +) { + public record SubscriptionInfo( + String id, + String status, + Date currentPeriodEnd + ) {} +} diff --git a/src/main/java/gg/modl/backend/billing/dto/response/UsageBillingSettingsResponse.java b/src/main/java/gg/modl/backend/billing/dto/response/UsageBillingSettingsResponse.java index 7b67415..5537563 100644 --- a/src/main/java/gg/modl/backend/billing/dto/response/UsageBillingSettingsResponse.java +++ b/src/main/java/gg/modl/backend/billing/dto/response/UsageBillingSettingsResponse.java @@ -1,7 +1,7 @@ -package gg.modl.backend.billing.dto.response; - -public record UsageBillingSettingsResponse( - boolean success, - String message, - boolean usageBillingEnabled -) {} +package gg.modl.backend.billing.dto.response; + +public record UsageBillingSettingsResponse( + boolean success, + String message, + boolean usageBillingEnabled +) {} diff --git a/src/main/java/gg/modl/backend/billing/dto/response/UsageResponse.java b/src/main/java/gg/modl/backend/billing/dto/response/UsageResponse.java index 8a7d88e..4300be9 100644 --- a/src/main/java/gg/modl/backend/billing/dto/response/UsageResponse.java +++ b/src/main/java/gg/modl/backend/billing/dto/response/UsageResponse.java @@ -1,24 +1,24 @@ -package gg.modl.backend.billing.dto.response; - -import java.util.Date; - -public record UsageResponse( - Period period, - UsageMetric ai, - double totalOverageCost, - boolean usageBillingEnabled -) { - public record Period( - Date start, - Date end - ) {} - - public record UsageMetric( - double used, - double limit, - double overage, - double overageRate, - double overageCost, - double percentage - ) {} -} +package gg.modl.backend.billing.dto.response; + +import java.util.Date; + +public record UsageResponse( + Period period, + UsageMetric ai, + double totalOverageCost, + boolean usageBillingEnabled +) { + public record Period( + Date start, + Date end + ) {} + + public record UsageMetric( + double used, + double limit, + double overage, + double overageRate, + double overageCost, + double percentage + ) {} +} diff --git a/src/main/java/gg/modl/backend/billing/service/BillingService.java b/src/main/java/gg/modl/backend/billing/service/BillingService.java index c64c077..8a88833 100644 --- a/src/main/java/gg/modl/backend/billing/service/BillingService.java +++ b/src/main/java/gg/modl/backend/billing/service/BillingService.java @@ -1,238 +1,238 @@ -package gg.modl.backend.billing.service; - -import com.stripe.exception.StripeException; -import com.stripe.model.Subscription; -import com.stripe.model.checkout.Session; -import gg.modl.backend.infrastructure.exception.ConflictException; -import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import gg.modl.backend.infrastructure.exception.ForbiddenException; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.billing.dto.response.BillingStatusResponse; -import gg.modl.backend.billing.dto.response.CancelResponse; -import gg.modl.backend.billing.dto.response.CheckoutSessionResponse; -import gg.modl.backend.billing.dto.response.PortalSessionResponse; -import gg.modl.backend.billing.dto.response.ResubscribeResponse; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.data.SubscriptionStatus; -import gg.modl.backend.server.service.ServerMutationHelper; -import java.util.Date; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class BillingService { - private final StripeService stripeService; - private final ServerMutationHelper serverMutationHelper; - private final PermissionService permissionService; - - public void requireStripeConfigured() { - if (!stripeService.isConfigured()) { - throw new ExternalServiceException("Billing service unavailable. Stripe not configured."); - } - } - - public void requireSuperAdmin(Server server, String email) { - if (email == null || !permissionService.isSuperAdmin(server, email)) { - throw new ForbiddenException("Only the super admin can manage billing"); - } - } - - public void syncCustomerEmail(Server server, String newEmail) { - String customerId = server.getStripeCustomerId(); - if (!stripeService.isConfigured() || customerId == null || customerId.isBlank()) { - return; - } - - try { - stripeService.updateCustomerEmail(customerId, newEmail); - } catch (StripeException e) { - log.warn("Failed to sync Stripe customer email for server {}; billing email may be stale", server.getId(), e); - } - } - - public CheckoutSessionResponse createCheckoutSession(Server server) { - try { - String customerId = server.getStripeCustomerId(); - - if (customerId == null || customerId.isBlank()) { - customerId = stripeService.createCustomer(server); - String createdCustomerId = customerId; - serverMutationHelper.mutate(server, current -> current.setStripeCustomerId(createdCustomerId)); - } - - Session session = stripeService.createCheckoutSession(customerId, server.getCustomDomain()); - return new CheckoutSessionResponse(session.getId(), session.getUrl()); - } catch (StripeException e) { - throw new ExternalServiceException("Failed to create checkout session", e); - } - } - - public PortalSessionResponse createPortalSession(Server server) { - if (server.getStripeCustomerId() == null || server.getStripeCustomerId().isBlank()) { - throw new ResourceNotFoundException("Customer ID not found for server"); - } - - try { - com.stripe.model.billingportal.Session session = stripeService.createPortalSession(server.getStripeCustomerId(), server.getCustomDomain()); - return new PortalSessionResponse(session.getUrl()); - } catch (StripeException e) { - throw new ExternalServiceException("Failed to create portal session", e); - } - } - - public CancelResponse cancelSubscription(Server server) { - if (server.getStripeSubscriptionId() == null || server.getStripeSubscriptionId().isBlank()) { - throw new ResourceNotFoundException("No active subscription found to cancel"); - } - - try { - Subscription canceledSubscription = stripeService.cancelSubscription(server.getStripeSubscriptionId()); - - Date periodEndDate = server.getCurrentPeriodEnd(); - if (periodEndDate == null) { - periodEndDate = stripeService.extractPeriodEnd(canceledSubscription); - } - - Date finalPeriodEndDate = periodEndDate; - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.CANCELED); - if (finalPeriodEndDate != null) { - current.setCurrentPeriodEnd(finalPeriodEndDate); - } - }); - - return new CancelResponse( - true, - "Subscription cancelled successfully. Access will continue until the end of your current billing period.", - periodEndDate - ); - } catch (StripeException e) { - throw new ExternalServiceException("Failed to cancel subscription", e); - } - } - - public BillingStatusResponse getBillingStatus(Server server) { - SubscriptionStatus currentStatus = server.getSubscriptionStatus(); - return new BillingStatusResponse( - server.getPlan() != null ? server.getPlan().name() : null, - currentStatus != null ? currentStatus.name() : null, - server.getCurrentPeriodStart(), - server.getCurrentPeriodEnd(), - server.getCustomDomainGrandfathered(), - server.getMaxStorageLimitBytes(), - server.getMaxAiOverageRequests() - ); - } - - public void reconcileBillingStatus(Server server) { - SubscriptionStatus currentStatus = server.getSubscriptionStatus(); - boolean reconcilable = server.getStripeSubscriptionId() != null - && (currentStatus == null || currentStatus == SubscriptionStatus.ACTIVE || currentStatus == SubscriptionStatus.CANCELED) - && stripeService.isConfigured(); - if (!reconcilable) { - return; - } - - try { - Subscription subscription = stripeService.retrieveSubscription(server.getStripeSubscriptionId()); - SubscriptionStatus effectiveStatus = SubscriptionStatus.fromStripeOrInactive(stripeService.getEffectiveStatus(subscription)); - Date periodStartDate = stripeService.extractPeriodStart(subscription); - Date periodEndDate = stripeService.extractPeriodEnd(subscription); - - boolean needsUpdate = effectiveStatus != currentStatus - || periodDrifted(server.getCurrentPeriodStart(), periodStartDate) - || periodDrifted(server.getCurrentPeriodEnd(), periodEndDate); - - if (needsUpdate) { - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(effectiveStatus); - if (periodStartDate != null) { - current.setCurrentPeriodStart(periodStartDate); - } - if (periodEndDate != null) { - current.setCurrentPeriodEnd(periodEndDate); - } - }); - } - } catch (StripeException exception) { - log.error("Error reconciling subscription from Stripe for server {}", server.getId(), exception); - } - } - - private boolean periodDrifted(Date persisted, Date fresh) { - return fresh != null && (persisted == null || Math.abs(persisted.getTime() - fresh.getTime()) > 1000); - } - - public ResubscribeResponse resubscribe(Server server) { - if (server.getSubscriptionStatus() != SubscriptionStatus.CANCELED) { - throw new ConflictException("No cancelled subscription found to reactivate."); - } - - try { - Subscription subscriptionResult; - - if (server.getStripeSubscriptionId() != null) { - try { - Subscription existingSubscription = stripeService.retrieveSubscription(server.getStripeSubscriptionId()); - - if ("active".equals(existingSubscription.getStatus()) && Boolean.TRUE.equals(existingSubscription.getCancelAtPeriodEnd())) { - subscriptionResult = stripeService.reactivateSubscription(server.getStripeSubscriptionId()); - } else if ("canceled".equals(existingSubscription.getStatus())) { - subscriptionResult = createNewSubscription(server); - } else { - throw new ConflictException("Subscription is not in a cancelled state that can be reactivated."); - } - } catch (StripeException exception) { - if ("resource_missing".equals(exception.getCode())) { - subscriptionResult = createNewSubscription(server); - } else { - throw exception; - } - } - } else { - subscriptionResult = createNewSubscription(server); - } - - Date periodStartDate = stripeService.extractPeriodStart(subscriptionResult); - Date periodEndDate = stripeService.extractPeriodEnd(subscriptionResult); - String subscriptionId = subscriptionResult.getId(); - SubscriptionStatus subscriptionStatus = SubscriptionStatus.fromStripeOrInactive(subscriptionResult.getStatus()); - - serverMutationHelper.mutate(server, current -> { - current.setStripeSubscriptionId(subscriptionId); - current.setSubscriptionStatus(subscriptionStatus); - current.setPlan(ServerPlan.PREMIUM); - if (periodStartDate != null) { - current.setCurrentPeriodStart(periodStartDate); - } - if (periodEndDate != null) { - current.setCurrentPeriodEnd(periodEndDate); - } - }); - - return new ResubscribeResponse( - true, - "Subscription reactivated successfully! Your premium features are now active.", - new ResubscribeResponse.SubscriptionInfo( - subscriptionResult.getId(), - subscriptionResult.getStatus(), - periodEndDate - ) - ); - } catch (StripeException e) { - throw new ExternalServiceException("Failed to resubscribe", e); - } - } - - private Subscription createNewSubscription(Server server) throws StripeException { - if (server.getStripeCustomerId() == null || server.getStripeCustomerId().isBlank()) { - throw new ResourceNotFoundException("No Stripe customer ID found. Cannot create subscription."); - } - return stripeService.createSubscription(server.getStripeCustomerId()); - } -} +package gg.modl.backend.billing.service; + +import com.stripe.exception.StripeException; +import com.stripe.model.Subscription; +import com.stripe.model.checkout.Session; +import gg.modl.backend.infrastructure.exception.ConflictException; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.billing.dto.response.BillingStatusResponse; +import gg.modl.backend.billing.dto.response.CancelResponse; +import gg.modl.backend.billing.dto.response.CheckoutSessionResponse; +import gg.modl.backend.billing.dto.response.PortalSessionResponse; +import gg.modl.backend.billing.dto.response.ResubscribeResponse; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import gg.modl.backend.server.service.ServerMutationHelper; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BillingService { + private final StripeService stripeService; + private final ServerMutationHelper serverMutationHelper; + private final PermissionService permissionService; + + public void requireStripeConfigured() { + if (!stripeService.isConfigured()) { + throw new ExternalServiceException("Billing service unavailable. Stripe not configured."); + } + } + + public void requireSuperAdmin(Server server, String email) { + if (email == null || !permissionService.isSuperAdmin(server, email)) { + throw new ForbiddenException("Only the super admin can manage billing"); + } + } + + public void syncCustomerEmail(Server server, String newEmail) { + String customerId = server.getStripeCustomerId(); + if (!stripeService.isConfigured() || customerId == null || customerId.isBlank()) { + return; + } + + try { + stripeService.updateCustomerEmail(customerId, newEmail); + } catch (StripeException exception) { + log.warn("Failed to sync Stripe customer email for server {}; billing email may be stale", server.getId(), exception); + } + } + + public CheckoutSessionResponse createCheckoutSession(Server server) { + try { + String customerId = server.getStripeCustomerId(); + + if (customerId == null || customerId.isBlank()) { + customerId = stripeService.createCustomer(server); + String createdCustomerId = customerId; + serverMutationHelper.mutate(server, current -> current.setStripeCustomerId(createdCustomerId)); + } + + Session session = stripeService.createCheckoutSession(customerId, server.getCustomDomain()); + return new CheckoutSessionResponse(session.getId(), session.getUrl()); + } catch (StripeException exception) { + throw new ExternalServiceException("Failed to create checkout session", exception); + } + } + + public PortalSessionResponse createPortalSession(Server server) { + if (server.getStripeCustomerId() == null || server.getStripeCustomerId().isBlank()) { + throw new ResourceNotFoundException("Customer ID not found for server"); + } + + try { + com.stripe.model.billingportal.Session session = stripeService.createPortalSession(server.getStripeCustomerId(), server.getCustomDomain()); + return new PortalSessionResponse(session.getUrl()); + } catch (StripeException exception) { + throw new ExternalServiceException("Failed to create portal session", exception); + } + } + + public CancelResponse cancelSubscription(Server server) { + if (server.getStripeSubscriptionId() == null || server.getStripeSubscriptionId().isBlank()) { + throw new ResourceNotFoundException("No active subscription found to cancel"); + } + + try { + Subscription canceledSubscription = stripeService.cancelSubscription(server.getStripeSubscriptionId()); + + Date periodEndDate = server.getCurrentPeriodEnd(); + if (periodEndDate == null) { + periodEndDate = stripeService.extractPeriodEnd(canceledSubscription); + } + + Date finalPeriodEndDate = periodEndDate; + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.CANCELED); + if (finalPeriodEndDate != null) { + current.setCurrentPeriodEnd(finalPeriodEndDate); + } + }); + + return new CancelResponse( + true, + "Subscription cancelled successfully. Access will continue until the end of your current billing period.", + periodEndDate + ); + } catch (StripeException exception) { + throw new ExternalServiceException("Failed to cancel subscription", exception); + } + } + + public BillingStatusResponse getBillingStatus(Server server) { + SubscriptionStatus currentStatus = server.getSubscriptionStatus(); + return new BillingStatusResponse( + server.getPlan() != null ? server.getPlan().name() : null, + currentStatus != null ? currentStatus.name() : null, + server.getCurrentPeriodStart(), + server.getCurrentPeriodEnd(), + server.getCustomDomainGrandfathered(), + server.getMaxStorageLimitBytes(), + server.getMaxAiOverageRequests() + ); + } + + public void reconcileBillingStatus(Server server) { + SubscriptionStatus currentStatus = server.getSubscriptionStatus(); + boolean reconcilable = server.getStripeSubscriptionId() != null + && (currentStatus == null || currentStatus == SubscriptionStatus.ACTIVE || currentStatus == SubscriptionStatus.CANCELED) + && stripeService.isConfigured(); + if (!reconcilable) { + return; + } + + try { + Subscription subscription = stripeService.retrieveSubscription(server.getStripeSubscriptionId()); + SubscriptionStatus effectiveStatus = SubscriptionStatus.fromStripeOrInactive(stripeService.getEffectiveStatus(subscription)); + Date periodStartDate = stripeService.extractPeriodStart(subscription); + Date periodEndDate = stripeService.extractPeriodEnd(subscription); + + boolean needsUpdate = effectiveStatus != currentStatus + || periodDrifted(server.getCurrentPeriodStart(), periodStartDate) + || periodDrifted(server.getCurrentPeriodEnd(), periodEndDate); + + if (needsUpdate) { + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(effectiveStatus); + if (periodStartDate != null) { + current.setCurrentPeriodStart(periodStartDate); + } + if (periodEndDate != null) { + current.setCurrentPeriodEnd(periodEndDate); + } + }); + } + } catch (StripeException exception) { + log.error("Error reconciling subscription from Stripe for server {}", server.getId(), exception); + } + } + + private boolean periodDrifted(Date persisted, Date fresh) { + return fresh != null && (persisted == null || Math.abs(persisted.getTime() - fresh.getTime()) > 1000); + } + + public ResubscribeResponse resubscribe(Server server) { + if (server.getSubscriptionStatus() != SubscriptionStatus.CANCELED) { + throw new ConflictException("No cancelled subscription found to reactivate."); + } + + try { + Subscription subscriptionResult; + + if (server.getStripeSubscriptionId() != null) { + try { + Subscription existingSubscription = stripeService.retrieveSubscription(server.getStripeSubscriptionId()); + + if ("active".equals(existingSubscription.getStatus()) && Boolean.TRUE.equals(existingSubscription.getCancelAtPeriodEnd())) { + subscriptionResult = stripeService.reactivateSubscription(server.getStripeSubscriptionId()); + } else if ("canceled".equals(existingSubscription.getStatus())) { + subscriptionResult = createNewSubscription(server); + } else { + throw new ConflictException("Subscription is not in a cancelled state that can be reactivated."); + } + } catch (StripeException exception) { + if ("resource_missing".equals(exception.getCode())) { + subscriptionResult = createNewSubscription(server); + } else { + throw exception; + } + } + } else { + subscriptionResult = createNewSubscription(server); + } + + Date periodStartDate = stripeService.extractPeriodStart(subscriptionResult); + Date periodEndDate = stripeService.extractPeriodEnd(subscriptionResult); + String subscriptionId = subscriptionResult.getId(); + SubscriptionStatus subscriptionStatus = SubscriptionStatus.fromStripeOrInactive(subscriptionResult.getStatus()); + + serverMutationHelper.mutate(server, current -> { + current.setStripeSubscriptionId(subscriptionId); + current.setSubscriptionStatus(subscriptionStatus); + current.setPlan(ServerPlan.PREMIUM); + if (periodStartDate != null) { + current.setCurrentPeriodStart(periodStartDate); + } + if (periodEndDate != null) { + current.setCurrentPeriodEnd(periodEndDate); + } + }); + + return new ResubscribeResponse( + true, + "Subscription reactivated successfully! Your premium features are now active.", + new ResubscribeResponse.SubscriptionInfo( + subscriptionResult.getId(), + subscriptionResult.getStatus(), + periodEndDate + ) + ); + } catch (StripeException exception) { + throw new ExternalServiceException("Failed to resubscribe", exception); + } + } + + private Subscription createNewSubscription(Server server) throws StripeException { + if (server.getStripeCustomerId() == null || server.getStripeCustomerId().isBlank()) { + throw new ResourceNotFoundException("No Stripe customer ID found. Cannot create subscription."); + } + return stripeService.createSubscription(server.getStripeCustomerId()); + } +} diff --git a/src/main/java/gg/modl/backend/billing/service/StripeService.java b/src/main/java/gg/modl/backend/billing/service/StripeService.java index 2bf87c4..823fa3b 100644 --- a/src/main/java/gg/modl/backend/billing/service/StripeService.java +++ b/src/main/java/gg/modl/backend/billing/service/StripeService.java @@ -1,146 +1,146 @@ -package gg.modl.backend.billing.service; - -import com.stripe.exception.StripeException; -import com.stripe.model.Customer; -import com.stripe.model.Subscription; -import com.stripe.model.SubscriptionItem; -import com.stripe.model.billingportal.Session; -import com.stripe.param.CustomerCreateParams; -import com.stripe.param.CustomerUpdateParams; -import com.stripe.param.SubscriptionCreateParams; -import com.stripe.param.SubscriptionUpdateParams; -import com.stripe.param.billingportal.SessionCreateParams; -import com.stripe.param.checkout.SessionCreateParams.ConsentCollection; -import gg.modl.backend.billing.config.StripeConfiguration; -import gg.modl.backend.infrastructure.config.ModlProperties; -import gg.modl.backend.server.data.Server; -import java.util.Date; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class StripeService { - private final StripeConfiguration config; - private final ModlProperties modlProperties; - - public boolean isConfigured() { - return config.isConfigured(); - } - - public String createCustomer(Server server) throws StripeException { - CustomerCreateParams params = CustomerCreateParams.builder() - .setEmail(server.getAdminEmail()) - .setName(server.getServerName()) - .putMetadata("serverName", server.getCustomDomain()) - .build(); - - Customer customer = Customer.create(params); - return customer.getId(); - } - - public void updateCustomerEmail(String customerId, String newEmail) throws StripeException { - Customer customer = Customer.retrieve(customerId); - customer.update(CustomerUpdateParams.builder().setEmail(newEmail).build()); - } - - public com.stripe.model.checkout.Session createCheckoutSession(String customerId, String subdomain) throws StripeException { - String successUrl = String.format("https://%s.%s/panel/settings?session_id={CHECKOUT_SESSION_ID}", subdomain, modlProperties.getDomain()); - String cancelUrl = String.format("https://%s.%s/panel/settings", subdomain, modlProperties.getDomain()); - - com.stripe.param.checkout.SessionCreateParams params = com.stripe.param.checkout.SessionCreateParams.builder() - .setMode(com.stripe.param.checkout.SessionCreateParams.Mode.SUBSCRIPTION) - .setAllowPromotionCodes(true) - .setConsentCollection( - ConsentCollection.builder() - .setTermsOfService(ConsentCollection.TermsOfService.REQUIRED) - .build() - ) - .addLineItem( - com.stripe.param.checkout.SessionCreateParams.LineItem.builder() - .setPrice(config.getPriceId()) - .setQuantity(1L) - .build() - ) - .setCustomer(customerId) - .setSuccessUrl(successUrl) - .setCancelUrl(cancelUrl) - .build(); - - return com.stripe.model.checkout.Session.create(params); - } - - public Session createPortalSession(String customerId, String subdomain) throws StripeException { - String returnUrl = String.format("https://%s.%s/panel/settings", subdomain, modlProperties.getDomain()); - - SessionCreateParams params = SessionCreateParams.builder() - .setCustomer(customerId) - .setReturnUrl(returnUrl) - .build(); - - return Session.create(params); - } - - public Subscription cancelSubscription(String subscriptionId) throws StripeException { - Subscription subscription = Subscription.retrieve(subscriptionId); - SubscriptionUpdateParams params = SubscriptionUpdateParams.builder() - .setCancelAtPeriodEnd(true) - .build(); - return subscription.update(params); - } - - public Subscription retrieveSubscription(String subscriptionId) throws StripeException { - return Subscription.retrieve(subscriptionId); - } - - public Subscription reactivateSubscription(String subscriptionId) throws StripeException { - Subscription subscription = Subscription.retrieve(subscriptionId); - SubscriptionUpdateParams params = SubscriptionUpdateParams.builder() - .setCancelAtPeriodEnd(false) - .build(); - return subscription.update(params); - } - - public Subscription createSubscription(String customerId) throws StripeException { - SubscriptionCreateParams params = SubscriptionCreateParams.builder() - .setCustomer(customerId) - .addItem( - SubscriptionCreateParams.Item.builder() - .setPrice(config.getPriceId()) - .build() - ) - .build(); - - return Subscription.create(params); - } - - public Date extractPeriodStart(Subscription subscription) { - if (subscription.getItems() != null && !subscription.getItems().getData().isEmpty()) { - SubscriptionItem item = subscription.getItems().getData().get(0); - if (item.getCurrentPeriodStart() != null) { - return new Date(item.getCurrentPeriodStart() * 1000); - } - } - return null; - } - - public Date extractPeriodEnd(Subscription subscription) { - if (subscription.getItems() != null && !subscription.getItems().getData().isEmpty()) { - SubscriptionItem item = subscription.getItems().getData().get(0); - if (item.getCurrentPeriodEnd() != null) { - return new Date(item.getCurrentPeriodEnd() * 1000); - } - } - return null; - } - - public String getEffectiveStatus(Subscription subscription) { - String status = subscription.getStatus(); - if (Boolean.TRUE.equals(subscription.getCancelAtPeriodEnd()) && "active".equals(status)) { - return "canceled"; - } - return status; - } -} +package gg.modl.backend.billing.service; + +import com.stripe.exception.StripeException; +import com.stripe.model.Customer; +import com.stripe.model.Subscription; +import com.stripe.model.SubscriptionItem; +import com.stripe.model.billingportal.Session; +import com.stripe.param.CustomerCreateParams; +import com.stripe.param.CustomerUpdateParams; +import com.stripe.param.SubscriptionCreateParams; +import com.stripe.param.SubscriptionUpdateParams; +import com.stripe.param.billingportal.SessionCreateParams; +import com.stripe.param.checkout.SessionCreateParams.ConsentCollection; +import gg.modl.backend.billing.config.StripeConfiguration; +import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class StripeService { + private final StripeConfiguration config; + private final ModlProperties modlProperties; + + public boolean isConfigured() { + return config.isConfigured(); + } + + public String createCustomer(Server server) throws StripeException { + CustomerCreateParams params = CustomerCreateParams.builder() + .setEmail(server.getAdminEmail()) + .setName(server.getServerName()) + .putMetadata("serverName", server.getCustomDomain()) + .build(); + + Customer customer = Customer.create(params); + return customer.getId(); + } + + public void updateCustomerEmail(String customerId, String newEmail) throws StripeException { + Customer customer = Customer.retrieve(customerId); + customer.update(CustomerUpdateParams.builder().setEmail(newEmail).build()); + } + + public com.stripe.model.checkout.Session createCheckoutSession(String customerId, String subdomain) throws StripeException { + String successUrl = String.format("https://%s.%s/panel/settings?session_id={CHECKOUT_SESSION_ID}", subdomain, modlProperties.getDomain()); + String cancelUrl = String.format("https://%s.%s/panel/settings", subdomain, modlProperties.getDomain()); + + com.stripe.param.checkout.SessionCreateParams params = com.stripe.param.checkout.SessionCreateParams.builder() + .setMode(com.stripe.param.checkout.SessionCreateParams.Mode.SUBSCRIPTION) + .setAllowPromotionCodes(true) + .setConsentCollection( + ConsentCollection.builder() + .setTermsOfService(ConsentCollection.TermsOfService.REQUIRED) + .build() + ) + .addLineItem( + com.stripe.param.checkout.SessionCreateParams.LineItem.builder() + .setPrice(config.getPriceId()) + .setQuantity(1L) + .build() + ) + .setCustomer(customerId) + .setSuccessUrl(successUrl) + .setCancelUrl(cancelUrl) + .build(); + + return com.stripe.model.checkout.Session.create(params); + } + + public Session createPortalSession(String customerId, String subdomain) throws StripeException { + String returnUrl = String.format("https://%s.%s/panel/settings", subdomain, modlProperties.getDomain()); + + SessionCreateParams params = SessionCreateParams.builder() + .setCustomer(customerId) + .setReturnUrl(returnUrl) + .build(); + + return Session.create(params); + } + + public Subscription cancelSubscription(String subscriptionId) throws StripeException { + Subscription subscription = Subscription.retrieve(subscriptionId); + SubscriptionUpdateParams params = SubscriptionUpdateParams.builder() + .setCancelAtPeriodEnd(true) + .build(); + return subscription.update(params); + } + + public Subscription retrieveSubscription(String subscriptionId) throws StripeException { + return Subscription.retrieve(subscriptionId); + } + + public Subscription reactivateSubscription(String subscriptionId) throws StripeException { + Subscription subscription = Subscription.retrieve(subscriptionId); + SubscriptionUpdateParams params = SubscriptionUpdateParams.builder() + .setCancelAtPeriodEnd(false) + .build(); + return subscription.update(params); + } + + public Subscription createSubscription(String customerId) throws StripeException { + SubscriptionCreateParams params = SubscriptionCreateParams.builder() + .setCustomer(customerId) + .addItem( + SubscriptionCreateParams.Item.builder() + .setPrice(config.getPriceId()) + .build() + ) + .build(); + + return Subscription.create(params); + } + + public Date extractPeriodStart(Subscription subscription) { + return extractPeriod(subscription, SubscriptionItem::getCurrentPeriodStart); + } + + public Date extractPeriodEnd(Subscription subscription) { + return extractPeriod(subscription, SubscriptionItem::getCurrentPeriodEnd); + } + + private Date extractPeriod(Subscription subscription, Function periodExtractor) { + if (subscription.getItems() != null && !subscription.getItems().getData().isEmpty()) { + SubscriptionItem item = subscription.getItems().getData().get(0); + Long period = periodExtractor.apply(item); + if (period != null) { + return new Date(period * 1000); + } + } + return null; + } + + public String getEffectiveStatus(Subscription subscription) { + String status = subscription.getStatus(); + if (Boolean.TRUE.equals(subscription.getCancelAtPeriodEnd()) && "active".equals(status)) { + return "canceled"; + } + return status; + } +} diff --git a/src/main/java/gg/modl/backend/billing/service/StripeWebhookService.java b/src/main/java/gg/modl/backend/billing/service/StripeWebhookService.java index 3a50a98..d242301 100644 --- a/src/main/java/gg/modl/backend/billing/service/StripeWebhookService.java +++ b/src/main/java/gg/modl/backend/billing/service/StripeWebhookService.java @@ -1,273 +1,271 @@ -package gg.modl.backend.billing.service; - -import com.stripe.exception.StripeException; -import com.stripe.model.Event; -import com.stripe.model.Invoice; -import com.stripe.model.StripeObject; -import com.stripe.model.Subscription; -import gg.modl.backend.database.mongo.repository.StripeWebhookEventMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerBillingUpdate; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.data.SubscriptionStatus; -import gg.modl.backend.server.service.ServerMutationHelper; -import java.util.Date; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class StripeWebhookService { - private final StripeService stripeService; - private final ServerMongoRepository serverRepository; - private final UsageTrackingService usageTrackingService; - private final ServerMutationHelper serverMutationHelper; - private final StripeWebhookEventMongoRepository webhookEventRepository; - - public void processEvent(Event event) { - if (!webhookEventRepository.markProcessing(event.getId(), event.getType(), new Date())) { - log.info("Ignoring duplicate Stripe webhook event {}", event.getId()); - return; - } - try { - switch (event.getType()) { - case "checkout.session.completed" -> handleCheckoutCompleted(event); - case "customer.subscription.created" -> handleSubscriptionCreated(event); - case "customer.subscription.updated" -> handleSubscriptionUpdated(event); - case "customer.subscription.deleted" -> handleSubscriptionDeleted(event); - case "invoice.payment_failed" -> handlePaymentFailed(event); - case "invoice.payment_succeeded" -> handlePaymentSucceeded(event); - default -> log.debug("Unhandled event type: {}", event.getType()); - } - webhookEventRepository.markProcessed(event.getId(), new Date()); - } catch (RuntimeException exception) { - webhookEventRepository.markFailed(event.getId(), new Date(), exception.getMessage()); - throw exception; - } - } - - private void handleCheckoutCompleted(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof com.stripe.model.checkout.Session session)) { - return; - } - - if (session.getCustomer() == null || session.getSubscription() == null) { - return; - } - - Server server = findServerByCustomerId(session.getCustomer()); - if (server == null) { - log.warn("No server found for customer: {}", session.getCustomer()); - return; - } - - serverMutationHelper.mutate(server, current -> { - current.setStripeSubscriptionId(session.getSubscription()); - current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); - current.setPlan(ServerPlan.PREMIUM); - }); - } - - private void applyPeriodDates(ServerBillingUpdate current, Subscription subscription) { - Date periodStart = stripeService.extractPeriodStart(subscription); - Date periodEnd = stripeService.extractPeriodEnd(subscription); - if (periodStart != null) { - current.setCurrentPeriodStart(periodStart); - } - if (periodEnd != null) { - current.setCurrentPeriodEnd(periodEnd); - } - } - - private Server findServerByCustomerId(String customerId) { - return serverRepository.findByStripeCustomerId(customerId).orElse(null); - } - - private Server resolveServer(Subscription subscription) { - Server server = findServerBySubscriptionId(subscription.getId()); - if (server != null) { - return server; - } - String customerId = subscription.getCustomer(); - if (customerId == null) { - return null; - } - server = findServerByCustomerId(customerId); - if (server != null && server.getStripeSubscriptionId() == null) { - serverMutationHelper.mutate(server, current -> current.setStripeSubscriptionId(subscription.getId())); - } - return server; - } - - private void handleSubscriptionCreated(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof Subscription subscription)) { - return; - } - - if (subscription.getCustomer() == null) { - return; - } - - Server server = findServerByCustomerId(subscription.getCustomer()); - if (server == null) { - return; - } - - serverMutationHelper.mutate(server, current -> { - current.setStripeSubscriptionId(subscription.getId()); - current.setSubscriptionStatus(SubscriptionStatus.fromStripeOrInactive(subscription.getStatus())); - current.setPlan(planForSubscriptionStatus(subscription.getStatus())); - applyPeriodDates(current, subscription); - }); - } - - private ServerPlan planForSubscriptionStatus(String status) { - return isFreeStatus(status) ? ServerPlan.FREE : ServerPlan.PREMIUM; - } - - private boolean isFreeStatus(String status) { - return "past_due".equals(status) - || "unpaid".equals(status) - || "incomplete".equals(status) - || "incomplete_expired".equals(status); - } - - private void handleSubscriptionUpdated(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof Subscription subscription)) { - return; - } - - Server server = resolveServer(subscription); - if (server == null) { - log.warn("No server found for subscription: {}", subscription.getId()); - return; - } - - String effectiveStatus = stripeService.getEffectiveStatus(subscription); - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.fromStripeOrInactive(effectiveStatus)); - if (isPremiumStatus(effectiveStatus)) { - current.setPlan(ServerPlan.PREMIUM); - } else if (isFreeStatus(effectiveStatus)) { - current.setPlan(ServerPlan.FREE); - } - - applyPeriodDates(current, subscription); - }); - } - - private Server findServerBySubscriptionId(String subscriptionId) { - return serverRepository.findByStripeSubscriptionId(subscriptionId).orElse(null); - } - - private boolean isPremiumStatus(String status) { - return "active".equals(status) || "trialing".equals(status) || "paused".equals(status); - } - - private void handleSubscriptionDeleted(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof Subscription subscription)) { - return; - } - - Server server = resolveServer(subscription); - if (server == null) { - return; - } - - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.INACTIVE); - current.setPlan(ServerPlan.FREE); - current.setCurrentPeriodEnd(null); - }); - usageTrackingService.resetUsageCounters(server.getId()); - } - - private void handlePaymentFailed(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof Invoice invoice) || invoice.getCustomer() == null) { - return; - } - - Server server = findServerByCustomerId(invoice.getCustomer()); - if (server == null) { - return; - } - - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.PAST_DUE); - current.setPlan(ServerPlan.FREE); - }); - } - - private void handlePaymentSucceeded(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof Invoice invoice) || invoice.getCustomer() == null) { - return; - } - - Server server = findServerByCustomerId(invoice.getCustomer()); - if (server == null) { - return; - } - - String subscriptionId = extractInvoiceSubscriptionId(invoice); - if (subscriptionId == null) { - subscriptionId = server.getStripeSubscriptionId(); - } - - if (subscriptionId == null) { - unstickPastDue(server); - return; - } - - boolean alreadyActive = server.getSubscriptionStatus() == SubscriptionStatus.ACTIVE - && server.getPlan() == ServerPlan.PREMIUM; - if (alreadyActive) { - return; - } - - try { - Subscription subscription = stripeService.retrieveSubscription(subscriptionId); - String effectiveStatus = stripeService.getEffectiveStatus(subscription); - if (isPremiumStatus(effectiveStatus)) { - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); - current.setPlan(ServerPlan.PREMIUM); - applyPeriodDates(current, subscription); - if (current.getStripeSubscriptionId() == null) { - current.setStripeSubscriptionId(subscription.getId()); - } - }); - } else { - unstickPastDue(server); - } - } catch (StripeException exception) { - throw new ExternalServiceException("Failed to sync subscription state on Stripe payment success", exception); - } - } - - private void unstickPastDue(Server server) { - if (server.getSubscriptionStatus() == SubscriptionStatus.PAST_DUE) { - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); - current.setPlan(ServerPlan.PREMIUM); - }); - } - } - - private String extractInvoiceSubscriptionId(Invoice invoice) { - Invoice.Parent parent = invoice.getParent(); - if (parent == null || parent.getSubscriptionDetails() == null) { - return null; - } - return parent.getSubscriptionDetails().getSubscription(); - } -} +package gg.modl.backend.billing.service; + +import com.stripe.exception.StripeException; +import com.stripe.model.Event; +import com.stripe.model.Invoice; +import com.stripe.model.StripeObject; +import com.stripe.model.Subscription; +import com.stripe.model.checkout.Session; +import gg.modl.backend.database.mongo.repository.StripeWebhookEventMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerLookupRepository; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerBillingUpdate; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import gg.modl.backend.server.service.ServerMutationHelper; +import java.util.Date; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class StripeWebhookService { + private final StripeService stripeService; + private final ServerLookupRepository serverLookupRepository; + private final UsageTrackingService usageTrackingService; + private final ServerMutationHelper serverMutationHelper; + private final StripeWebhookEventMongoRepository webhookEventRepository; + + public void processEvent(Event event) { + if (!webhookEventRepository.markProcessing(event.getId(), event.getType(), new Date())) { + log.info("Ignoring duplicate Stripe webhook event {}", event.getId()); + return; + } + try { + switch (event.getType()) { + case "checkout.session.completed" -> handleCheckoutCompleted(event); + case "customer.subscription.created" -> handleSubscriptionCreated(event); + case "customer.subscription.updated" -> handleSubscriptionUpdated(event); + case "customer.subscription.deleted" -> handleSubscriptionDeleted(event); + case "invoice.payment_failed" -> handlePaymentFailed(event); + case "invoice.payment_succeeded" -> handlePaymentSucceeded(event); + default -> log.debug("Unhandled event type: {}", event.getType()); + } + webhookEventRepository.markProcessed(event.getId(), new Date()); + } catch (RuntimeException exception) { + webhookEventRepository.markFailed(event.getId(), new Date(), exception.getMessage()); + throw exception; + } + } + + private Optional objectOf(Event event, Class type) { + return event.getDataObjectDeserializer().getObject() + .filter(type::isInstance) + .map(type::cast); + } + + private void handleCheckoutCompleted(Event event) { + objectOf(event, Session.class).ifPresent(session -> { + if (session.getCustomer() == null || session.getSubscription() == null) { + return; + } + + Server server = findServerByCustomerId(session.getCustomer()); + if (server == null) { + log.warn("No server found for customer: {}", session.getCustomer()); + return; + } + + serverMutationHelper.mutate(server, current -> { + current.setStripeSubscriptionId(session.getSubscription()); + current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); + current.setPlan(ServerPlan.PREMIUM); + }); + }); + } + + private void applyPeriodDates(ServerBillingUpdate current, Subscription subscription) { + Date periodStart = stripeService.extractPeriodStart(subscription); + Date periodEnd = stripeService.extractPeriodEnd(subscription); + if (periodStart != null) { + current.setCurrentPeriodStart(periodStart); + } + if (periodEnd != null) { + current.setCurrentPeriodEnd(periodEnd); + } + } + + private Server findServerByCustomerId(String customerId) { + return serverLookupRepository.findByStripeCustomerId(customerId).orElse(null); + } + + private Server resolveServer(Subscription subscription) { + Server server = findServerBySubscriptionId(subscription.getId()); + if (server != null) { + return server; + } + String customerId = subscription.getCustomer(); + if (customerId == null) { + return null; + } + server = findServerByCustomerId(customerId); + if (server != null && server.getStripeSubscriptionId() == null) { + serverMutationHelper.mutate(server, current -> current.setStripeSubscriptionId(subscription.getId())); + } + return server; + } + + private void handleSubscriptionCreated(Event event) { + objectOf(event, Subscription.class).ifPresent(subscription -> { + if (subscription.getCustomer() == null) { + return; + } + + Server server = findServerByCustomerId(subscription.getCustomer()); + if (server == null) { + return; + } + + serverMutationHelper.mutate(server, current -> { + current.setStripeSubscriptionId(subscription.getId()); + current.setSubscriptionStatus(SubscriptionStatus.fromStripeOrInactive(subscription.getStatus())); + current.setPlan(planForSubscriptionStatus(subscription.getStatus())); + applyPeriodDates(current, subscription); + }); + }); + } + + private ServerPlan planForSubscriptionStatus(String status) { + return isFreeStatus(status) ? ServerPlan.FREE : ServerPlan.PREMIUM; + } + + private boolean isFreeStatus(String status) { + return "past_due".equals(status) + || "unpaid".equals(status) + || "incomplete".equals(status) + || "incomplete_expired".equals(status); + } + + private void handleSubscriptionUpdated(Event event) { + objectOf(event, Subscription.class).ifPresent(subscription -> { + Server server = resolveServer(subscription); + if (server == null) { + log.warn("No server found for subscription: {}", subscription.getId()); + return; + } + + String effectiveStatus = stripeService.getEffectiveStatus(subscription); + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.fromStripeOrInactive(effectiveStatus)); + if (isPremiumStatus(effectiveStatus)) { + current.setPlan(ServerPlan.PREMIUM); + } else if (isFreeStatus(effectiveStatus)) { + current.setPlan(ServerPlan.FREE); + } + + applyPeriodDates(current, subscription); + }); + }); + } + + private Server findServerBySubscriptionId(String subscriptionId) { + return serverLookupRepository.findByStripeSubscriptionId(subscriptionId).orElse(null); + } + + private boolean isPremiumStatus(String status) { + return "active".equals(status) || "trialing".equals(status) || "paused".equals(status); + } + + private void handleSubscriptionDeleted(Event event) { + objectOf(event, Subscription.class).ifPresent(subscription -> { + Server server = resolveServer(subscription); + if (server == null) { + return; + } + + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.INACTIVE); + current.setPlan(ServerPlan.FREE); + current.setCurrentPeriodEnd(null); + }); + usageTrackingService.resetUsageCounters(server.getId()); + }); + } + + private void handlePaymentFailed(Event event) { + objectOf(event, Invoice.class).ifPresent(invoice -> { + if (invoice.getCustomer() == null) { + return; + } + + Server server = findServerByCustomerId(invoice.getCustomer()); + if (server == null) { + return; + } + + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.PAST_DUE); + current.setPlan(ServerPlan.FREE); + }); + }); + } + + private void handlePaymentSucceeded(Event event) { + objectOf(event, Invoice.class).ifPresent(invoice -> { + if (invoice.getCustomer() == null) { + return; + } + + Server server = findServerByCustomerId(invoice.getCustomer()); + if (server == null) { + return; + } + + String subscriptionId = extractInvoiceSubscriptionId(invoice); + if (subscriptionId == null) { + subscriptionId = server.getStripeSubscriptionId(); + } + + if (subscriptionId == null) { + unstickPastDue(server); + return; + } + + boolean alreadyActive = server.getSubscriptionStatus() == SubscriptionStatus.ACTIVE + && server.getPlan() == ServerPlan.PREMIUM; + if (alreadyActive) { + return; + } + + try { + Subscription subscription = stripeService.retrieveSubscription(subscriptionId); + String effectiveStatus = stripeService.getEffectiveStatus(subscription); + if (isPremiumStatus(effectiveStatus)) { + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); + current.setPlan(ServerPlan.PREMIUM); + applyPeriodDates(current, subscription); + if (current.getStripeSubscriptionId() == null) { + current.setStripeSubscriptionId(subscription.getId()); + } + }); + } else { + unstickPastDue(server); + } + } catch (StripeException exception) { + throw new ExternalServiceException("Failed to sync subscription state on Stripe payment success", exception); + } + }); + } + + private void unstickPastDue(Server server) { + if (server.getSubscriptionStatus() == SubscriptionStatus.PAST_DUE) { + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); + current.setPlan(ServerPlan.PREMIUM); + }); + } + } + + private String extractInvoiceSubscriptionId(Invoice invoice) { + Invoice.Parent parent = invoice.getParent(); + if (parent == null || parent.getSubscriptionDetails() == null) { + return null; + } + return parent.getSubscriptionDetails().getSubscription(); + } +} diff --git a/src/main/java/gg/modl/backend/billing/service/SubscriptionExpiryService.java b/src/main/java/gg/modl/backend/billing/service/SubscriptionExpiryService.java index 3ed27b4..c9f1da4 100644 --- a/src/main/java/gg/modl/backend/billing/service/SubscriptionExpiryService.java +++ b/src/main/java/gg/modl/backend/billing/service/SubscriptionExpiryService.java @@ -1,45 +1,45 @@ -package gg.modl.backend.billing.service; - -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.data.SubscriptionStatus; -import gg.modl.backend.server.service.ServerMutationHelper; -import java.util.Date; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class SubscriptionExpiryService { - private final ServerMongoRepository serverRepository; - private final UsageTrackingService usageTrackingService; - private final ServerMutationHelper serverMutationHelper; - - @Scheduled(fixedRate = 3600000) - public void checkExpiredSubscriptions() { - try { - List cancelledServers = serverRepository.findCancelledWithPeriodEnd(); - Date now = new Date(); - - for (Server server : cancelledServers) { - Date endDate = server.getCurrentPeriodEnd(); - if (endDate != null && endDate.before(now)) { - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.INACTIVE); - current.setPlan(ServerPlan.FREE); - current.setCurrentPeriodEnd(null); - }); - usageTrackingService.resetUsageCounters(server.getId()); - } - } - } catch (Exception exception) { - log.error("Error checking for expired subscriptions", exception); - } - } - -} +package gg.modl.backend.billing.service; + +import gg.modl.backend.database.mongo.repository.ServerLookupRepository; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import gg.modl.backend.server.service.ServerMutationHelper; +import java.util.Date; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SubscriptionExpiryService { + private final ServerLookupRepository serverLookupRepository; + private final UsageTrackingService usageTrackingService; + private final ServerMutationHelper serverMutationHelper; + + @Scheduled(fixedRate = 3600000) + public void checkExpiredSubscriptions() { + try { + List cancelledServers = serverLookupRepository.findCancelledWithPeriodEnd(); + Date now = new Date(); + + for (Server server : cancelledServers) { + Date endDate = server.getCurrentPeriodEnd(); + if (endDate != null && endDate.before(now)) { + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.INACTIVE); + current.setPlan(ServerPlan.FREE); + current.setCurrentPeriodEnd(null); + }); + usageTrackingService.resetUsageCounters(server.getId()); + } + } + } catch (Exception exception) { + log.error("Error checking for expired subscriptions", exception); + } + } + +} diff --git a/src/main/java/gg/modl/backend/billing/service/UsageTrackingService.java b/src/main/java/gg/modl/backend/billing/service/UsageTrackingService.java index 29cf975..d1ffb87 100644 --- a/src/main/java/gg/modl/backend/billing/service/UsageTrackingService.java +++ b/src/main/java/gg/modl/backend/billing/service/UsageTrackingService.java @@ -1,143 +1,145 @@ -package gg.modl.backend.billing.service; - -import gg.modl.backend.billing.dto.response.UsageBillingSettingsResponse; -import gg.modl.backend.billing.dto.response.UsageResponse; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.limits.ServerLimitPolicy; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.storage.service.StorageQuotaService; -import gg.modl.backend.server.service.ServerMutationHelper; -import java.util.Date; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class UsageTrackingService { - private final ServerMongoRepository serverRepository; - private final ServerMutationHelper serverMutationHelper; - private final ServerLimitPolicy serverLimitPolicy; - public static final long AI_BASE_LIMIT_REQUESTS = 1000L; - private static final double AI_OVERAGE_RATE = 0.02; - - public UsageResponse getUsage(Server server) { - Server freshServer = getFreshServer(server.getId()); - if (freshServer == null) { - throw new IllegalStateException("Server not found in database."); - } - - Date currentPeriodStart = freshServer.getCurrentPeriodStart(); - if (currentPeriodStart == null) { - currentPeriodStart = new Date(System.currentTimeMillis() - (30L * 24 * 60 * 60 * 1000)); - } - - Date currentPeriodEnd = freshServer.getCurrentPeriodEnd(); - if (currentPeriodEnd == null) { - currentPeriodEnd = new Date(System.currentTimeMillis() + (30L * 24 * 60 * 60 * 1000)); - } - - long aiRequestsUsed = freshServer.getAiRequestsCurrentPeriod() != null ? freshServer.getAiRequestsCurrentPeriod() : 0L; - boolean usageBillingEnabled = Boolean.TRUE.equals(freshServer.getUsageBillingEnabled()); - - long aiLimitRequests = getAiRequestLimit(freshServer); - long aiOverageRequests = Math.max(0, aiRequestsUsed - getAiBaseLimitRequests()); - double aiOverageCost = usageBillingEnabled ? aiOverageRequests * AI_OVERAGE_RATE : 0.0; - - return new UsageResponse( - new UsageResponse.Period(currentPeriodStart, currentPeriodEnd), - new UsageResponse.UsageMetric( - aiRequestsUsed, - aiLimitRequests, - aiOverageRequests, - AI_OVERAGE_RATE, - aiOverageCost, - Math.min(100, aiLimitRequests > 0 ? ((double) aiRequestsUsed / aiLimitRequests) * 100 : 0) - ), - aiOverageCost, - usageBillingEnabled - ); - } - - public long getAiRequestLimit(Server server) { - return serverLimitPolicy.resolve(server).getAiRequestLimit(); - } - - public long getAiBaseLimitRequests() { - return AI_BASE_LIMIT_REQUESTS; - } - - private Server getFreshServer(String serverId) { - return serverRepository.findById(serverId).orElse(null); - } - - public UsageBillingSettingsResponse updateUsageBillingSettings(Server server, boolean enabled) { - if (enabled && (server.getStripeCustomerId() == null || server.getStripeCustomerId().isBlank())) { - throw new IllegalStateException("No Stripe customer ID found. Please ensure you have an active subscription."); - } - - serverMutationHelper.mutate(server, current -> { - current.setUsageBillingEnabled(enabled); - current.setUsageBillingUpdatedAt(new Date()); - }); - - String message = enabled - ? "Usage billing has been enabled. You will be charged for overages at the end of each billing period." - : "Usage billing has been disabled. Overages will not be charged."; - - return new UsageBillingSettingsResponse(true, message, enabled); - } - - public void incrementAiRequests(String serverId, long additionalRequests) { - serverRepository.incrementAiRequests(serverId, additionalRequests); - } - - public void resetUsageCounters(String serverId) { - serverRepository.resetUsageCounters(serverId); - } - - public void updateStorageLimit(Server server, long bytes) { - if (server.getPlan() != ServerPlan.PREMIUM) { - throw new ValidationException("Storage limit configuration is only available for premium servers"); - } - if (bytes <= 0) { - throw new ValidationException("Storage limit must be positive"); - } - validatePremiumStorageBytes(bytes); - serverMutationHelper.mutate(server, current -> current.setMaxStorageLimitBytes(bytes)); - } - - public long updateOverageLimits(Server server, long maxStorageOverageGb, long maxAiOverageRequests) { - if (server.getPlan() != ServerPlan.PREMIUM) { - throw new ValidationException("Overage limits configuration is only available for premium servers"); - } - if (maxStorageOverageGb < 0 || maxAiOverageRequests < 0) { - throw new ValidationException("Overage limits cannot be negative"); - } - if (maxStorageOverageGb > StorageQuotaService.MAX_STORAGE_OVERAGE_BYTES / (1024L * 1024 * 1024)) { - throw new ValidationException("Storage overage cannot exceed 2000 GB. Please contact support for higher limits."); - } - if (maxAiOverageRequests > StorageQuotaService.MAX_AI_OVERAGE_REQUESTS) { - throw new ValidationException("AI request overage cannot exceed 5000 requests. Please contact support for higher limits."); - } - - long maxStorageLimitBytes = StorageQuotaService.PREMIUM_BASE_BYTES + maxStorageOverageGb * (1024L * 1024 * 1024); - validatePremiumStorageBytes(maxStorageLimitBytes); - - serverMutationHelper.mutate(server, current -> { - current.setMaxStorageLimitBytes(maxStorageLimitBytes); - current.setMaxAiOverageRequests(maxAiOverageRequests); - }); - return maxStorageLimitBytes; - } - - private void validatePremiumStorageBytes(long bytes) { - if (bytes > StorageQuotaService.MAX_PREMIUM_BYTES) { - throw new ValidationException("Storage limit cannot exceed 2200 GB. Please contact support for higher limits."); - } - } - -} +package gg.modl.backend.billing.service; + +import gg.modl.backend.billing.dto.response.UsageBillingSettingsResponse; +import gg.modl.backend.billing.dto.response.UsageResponse; +import gg.modl.backend.database.mongo.repository.ServerUsageRepository; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.limits.ServerLimitPolicy; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.storage.service.StorageQuotaService; +import gg.modl.backend.server.service.ServerMutationHelper; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UsageTrackingService { + private final ServerUsageRepository serverUsageRepository; + private final ServerMutationHelper serverMutationHelper; + private final ServerLimitPolicy serverLimitPolicy; + public static final long AI_BASE_LIMIT_REQUESTS = 1000L; + private static final double AI_OVERAGE_RATE = 0.02; + private static final long DEFAULT_PERIOD_MILLIS = 30L * 24 * 60 * 60 * 1000; + + public UsageResponse getUsage(Server server) { + Server freshServer = getFreshServer(server.getId()); + if (freshServer == null) { + throw new ResourceNotFoundException("Server not found in database."); + } + + Date currentPeriodStart = freshServer.getCurrentPeriodStart(); + if (currentPeriodStart == null) { + currentPeriodStart = new Date(System.currentTimeMillis() - DEFAULT_PERIOD_MILLIS); + } + + Date currentPeriodEnd = freshServer.getCurrentPeriodEnd(); + if (currentPeriodEnd == null) { + currentPeriodEnd = new Date(System.currentTimeMillis() + DEFAULT_PERIOD_MILLIS); + } + + long aiRequestsUsed = freshServer.getAiRequestsCurrentPeriod() != null ? freshServer.getAiRequestsCurrentPeriod() : 0L; + boolean usageBillingEnabled = Boolean.TRUE.equals(freshServer.getUsageBillingEnabled()); + + long aiLimitRequests = getAiRequestLimit(freshServer); + long aiOverageRequests = Math.max(0, aiRequestsUsed - getAiBaseLimitRequests()); + double aiOverageCost = usageBillingEnabled ? aiOverageRequests * AI_OVERAGE_RATE : 0.0; + + return new UsageResponse( + new UsageResponse.Period(currentPeriodStart, currentPeriodEnd), + new UsageResponse.UsageMetric( + aiRequestsUsed, + aiLimitRequests, + aiOverageRequests, + AI_OVERAGE_RATE, + aiOverageCost, + Math.min(100, aiLimitRequests > 0 ? ((double) aiRequestsUsed / aiLimitRequests) * 100 : 0) + ), + aiOverageCost, + usageBillingEnabled + ); + } + + public long getAiRequestLimit(Server server) { + return serverLimitPolicy.resolve(server).getAiRequestLimit(); + } + + public long getAiBaseLimitRequests() { + return AI_BASE_LIMIT_REQUESTS; + } + + private Server getFreshServer(String serverId) { + return serverUsageRepository.findById(serverId).orElse(null); + } + + public UsageBillingSettingsResponse updateUsageBillingSettings(Server server, boolean enabled) { + if (enabled && (server.getStripeCustomerId() == null || server.getStripeCustomerId().isBlank())) { + throw new ResourceNotFoundException("No Stripe customer ID found. Please ensure you have an active subscription."); + } + + serverMutationHelper.mutate(server, current -> { + current.setUsageBillingEnabled(enabled); + current.setUsageBillingUpdatedAt(new Date()); + }); + + String message = enabled + ? "Usage billing has been enabled. You will be charged for overages at the end of each billing period." + : "Usage billing has been disabled. Overages will not be charged."; + + return new UsageBillingSettingsResponse(true, message, enabled); + } + + public void incrementAiRequests(String serverId, long additionalRequests) { + serverUsageRepository.incrementAiRequests(serverId, additionalRequests); + } + + public void resetUsageCounters(String serverId) { + serverUsageRepository.resetUsageCounters(serverId); + } + + public void updateStorageLimit(Server server, long bytes) { + if (server.getPlan() != ServerPlan.PREMIUM) { + throw new ValidationException("Storage limit configuration is only available for premium servers"); + } + if (bytes <= 0) { + throw new ValidationException("Storage limit must be positive"); + } + validatePremiumStorageBytes(bytes); + serverMutationHelper.mutate(server, current -> current.setMaxStorageLimitBytes(bytes)); + } + + public long updateOverageLimits(Server server, long maxStorageOverageGb, long maxAiOverageRequests) { + if (server.getPlan() != ServerPlan.PREMIUM) { + throw new ValidationException("Overage limits configuration is only available for premium servers"); + } + if (maxStorageOverageGb < 0 || maxAiOverageRequests < 0) { + throw new ValidationException("Overage limits cannot be negative"); + } + if (maxStorageOverageGb > StorageQuotaService.MAX_STORAGE_OVERAGE_BYTES / (1024L * 1024 * 1024)) { + throw new ValidationException("Storage overage cannot exceed 2000 GB. Please contact support for higher limits."); + } + if (maxAiOverageRequests > StorageQuotaService.MAX_AI_OVERAGE_REQUESTS) { + throw new ValidationException("AI request overage cannot exceed 5000 requests. Please contact support for higher limits."); + } + + long maxStorageLimitBytes = StorageQuotaService.PREMIUM_BASE_BYTES + maxStorageOverageGb * (1024L * 1024 * 1024); + validatePremiumStorageBytes(maxStorageLimitBytes); + + serverMutationHelper.mutate(server, current -> { + current.setMaxStorageLimitBytes(maxStorageLimitBytes); + current.setMaxAiOverageRequests(maxAiOverageRequests); + }); + return maxStorageLimitBytes; + } + + private void validatePremiumStorageBytes(long bytes) { + if (bytes > StorageQuotaService.MAX_PREMIUM_BYTES) { + throw new ValidationException("Storage limit cannot exceed 2200 GB. Please contact support for higher limits."); + } + } + +} diff --git a/src/main/java/gg/modl/backend/cloudflare/config/CloudflareConfiguration.java b/src/main/java/gg/modl/backend/cloudflare/config/CloudflareConfiguration.java index 7f08cb6..b5b5438 100644 --- a/src/main/java/gg/modl/backend/cloudflare/config/CloudflareConfiguration.java +++ b/src/main/java/gg/modl/backend/cloudflare/config/CloudflareConfiguration.java @@ -1,21 +1,21 @@ -package gg.modl.backend.cloudflare.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; -import org.springframework.validation.annotation.Validated; - -@Configuration -@ConfigurationProperties(prefix = "modl.cloudflare") -@Validated -@Getter -@Setter -public class CloudflareConfiguration { - private String apiToken = ""; - private String zoneId = ""; - - public boolean isConfigured() { - return apiToken != null && !apiToken.isBlank() && zoneId != null && !zoneId.isBlank(); - } -} +package gg.modl.backend.cloudflare.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.annotation.Validated; + +@Configuration +@ConfigurationProperties(prefix = "modl.cloudflare") +@Validated +@Getter +@Setter +public class CloudflareConfiguration { + private String apiToken = ""; + private String zoneId = ""; + + public boolean isConfigured() { + return apiToken != null && !apiToken.isBlank() && zoneId != null && !zoneId.isBlank(); + } +} diff --git a/src/main/java/gg/modl/backend/cloudflare/external/CloudflareClient.java b/src/main/java/gg/modl/backend/cloudflare/external/CloudflareClient.java index 72abe36..a4aad14 100644 --- a/src/main/java/gg/modl/backend/cloudflare/external/CloudflareClient.java +++ b/src/main/java/gg/modl/backend/cloudflare/external/CloudflareClient.java @@ -1,201 +1,265 @@ -package gg.modl.backend.cloudflare.external; - -import gg.modl.backend.cloudflare.config.CloudflareConfiguration; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -@Component -@RequiredArgsConstructor -@Slf4j -public class CloudflareClient { - private final CloudflareConfiguration config; - private final RestTemplate restTemplate; - private static final String CLOUDFLARE_API = "https://api.cloudflare.com/client/v4"; - - public CustomHostnameResult createCustomHostname(String hostname) { - if (!config.isConfigured()) { - log.warn("Cloudflare is not configured"); - return null; - } - - try { - HttpHeaders headers = createHeaders(); - - Map ssl = Map.of( - "method", "http", - "type", "dv" - ); - - Map body = Map.of( - "hostname", hostname, - "ssl", ssl - ); - - HttpEntity> request = new HttpEntity<>(body, headers); - String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames"; - - ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - Boolean success = (Boolean) response.getBody().get("success"); - if (Boolean.TRUE.equals(success)) { - return parseCustomHostnameResult(response.getBody()); - } else { - List> errors = (List>) response.getBody().get("errors"); - if (errors != null && !errors.isEmpty()) { - log.error("Cloudflare API error creating custom hostname: {}", errors); - } - } - } - return null; - } catch (Exception e) { - log.error("Failed to create custom hostname for {}", hostname, e); - return null; - } - } - - @SuppressWarnings("unchecked") - private CustomHostnameResult parseCustomHostnameResult(Map responseBody) { - Map result = (Map) responseBody.get("result"); - if (result == null) { - return null; - } - - String id = (String) result.get("id"); - String hostname = (String) result.get("hostname"); - String status = (String) result.get("status"); - - CustomHostnameResult.SslStatus sslStatus = null; - Map ssl = (Map) result.get("ssl"); - if (ssl != null) { - sslStatus = new CustomHostnameResult.SslStatus( - (String) ssl.get("status"), - (String) ssl.get("method"), - (String) ssl.get("type") - ); - } - - String ownershipHttpUrl = null; - String ownershipHttpBody = null; - Map ownershipVerification = (Map) result.get("ownership_verification"); - if (ownershipVerification != null) { - ownershipHttpUrl = (String) ownershipVerification.get("http_url"); - ownershipHttpBody = (String) ownershipVerification.get("http_body"); - } - - return new CustomHostnameResult( - id, - hostname, - status, - sslStatus, - ownershipHttpUrl, - ownershipHttpBody - ); - } - - private HttpHeaders createHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", "Bearer " + config.getApiToken()); - return headers; - } - - public CustomHostnameResult getCustomHostname(String hostnameId) { - if (!config.isConfigured()) { - return null; - } - - try { - HttpHeaders headers = createHeaders(); - HttpEntity request = new HttpEntity<>(headers); - - String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames/" + hostnameId; - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, Map.class); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - Boolean success = (Boolean) response.getBody().get("success"); - if (Boolean.TRUE.equals(success)) { - return parseCustomHostnameResult(response.getBody()); - } - } - return null; - } catch (Exception e) { - log.error("Failed to get custom hostname {}", hostnameId, e); - return null; - } - } - - public CustomHostnameResult findCustomHostnameByName(String hostname) { - if (!config.isConfigured()) { - return null; - } - - try { - HttpHeaders headers = createHeaders(); - HttpEntity request = new HttpEntity<>(headers); - - String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames?hostname=" + hostname; - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, Map.class); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - Boolean success = (Boolean) response.getBody().get("success"); - if (Boolean.TRUE.equals(success)) { - List> results = (List>) response.getBody().get("result"); - if (results != null && !results.isEmpty()) { - Map modifiedBody = Map.of( - "success", true, - "result", results.get(0) - ); - return parseCustomHostnameResult(modifiedBody); - } - } - } - return null; - } catch (Exception e) { - log.error("Failed to find custom hostname {}", hostname, e); - return null; - } - } - - public boolean deleteCustomHostname(String hostnameId) { - if (!config.isConfigured()) { - return false; - } - - try { - HttpHeaders headers = createHeaders(); - HttpEntity request = new HttpEntity<>(headers); - - String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames/" + hostnameId; - ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, request, Map.class); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - return Boolean.TRUE.equals(response.getBody().get("success")); - } - return false; - } catch (Exception e) { - log.error("Failed to delete custom hostname {}", hostnameId, e); - return false; - } - } - - public record CustomHostnameResult( - String id, - String hostname, - String status, - SslStatus ssl, - String ownershipVerificationHttpUrl, - String ownershipVerificationHttpBody - ) { - public record SslStatus(String status, String method, String type) {} - } -} +package gg.modl.backend.cloudflare.external; + +import gg.modl.backend.cloudflare.config.CloudflareConfiguration; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CloudflareClient { + private static final String CLOUDFLARE_API = "https://api.cloudflare.com/client/v4"; + private static final int HOSTNAME_PAGE_SIZE = 50; + private static final int MAX_HOSTNAME_PAGES = 200; + private static final ParameterizedTypeReference> MAP_RESPONSE = + new ParameterizedTypeReference<>() {}; + private final CloudflareConfiguration config; + private final RestTemplate restTemplate; + + public CustomHostnameResult createCustomHostname(String hostname) { + if (!config.isConfigured()) { + log.warn("Cloudflare is not configured"); + return null; + } + + try { + HttpHeaders headers = createHeaders(); + + Map ssl = Map.of( + "method", "http", + "type", "dv" + ); + + Map body = Map.of( + "hostname", hostname, + "ssl", ssl + ); + + HttpEntity> request = new HttpEntity<>(body, headers); + String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames"; + + ResponseEntity> response = restTemplate.exchange(url, HttpMethod.POST, request, MAP_RESPONSE); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + Boolean success = (Boolean) response.getBody().get("success"); + if (Boolean.TRUE.equals(success)) { + return parseCustomHostnameResult(response.getBody()); + } else { + Object errors = response.getBody().get("errors"); + if (errors instanceof List errorList && !errorList.isEmpty()) { + log.error("Cloudflare API error creating custom hostname: {}", errorList); + } + } + } + return null; + } catch (Exception e) { + log.error("Failed to create custom hostname for {}", hostname, e); + return null; + } + } + + private CustomHostnameResult parseCustomHostnameResult(Map responseBody) { + Map result = (Map) responseBody.get("result"); + if (result == null) { + return null; + } + + String id = (String) result.get("id"); + String hostname = (String) result.get("hostname"); + String status = (String) result.get("status"); + + CustomHostnameResult.SslStatus sslStatus = null; + Map ssl = (Map) result.get("ssl"); + if (ssl != null) { + sslStatus = new CustomHostnameResult.SslStatus( + (String) ssl.get("status"), + (String) ssl.get("method"), + (String) ssl.get("type") + ); + } + + String ownershipHttpUrl = null; + String ownershipHttpBody = null; + Map ownershipVerification = (Map) result.get("ownership_verification"); + if (ownershipVerification != null) { + ownershipHttpUrl = (String) ownershipVerification.get("http_url"); + ownershipHttpBody = (String) ownershipVerification.get("http_body"); + } + + return new CustomHostnameResult( + id, + hostname, + status, + sslStatus, + ownershipHttpUrl, + ownershipHttpBody + ); + } + + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + config.getApiToken()); + return headers; + } + + public CustomHostnameResult getCustomHostname(String hostnameId) { + requireConfigured(); + + try { + HttpHeaders headers = createHeaders(); + HttpEntity request = new HttpEntity<>(headers); + + String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames/" + hostnameId; + ResponseEntity> response = restTemplate.exchange(url, HttpMethod.GET, request, MAP_RESPONSE); + + return parseCustomHostnameResult(requireSuccessBody(response, "get custom hostname " + hostnameId)); + } catch (HttpClientErrorException.NotFound notFound) { + return null; + } catch (ExternalServiceException e) { + throw e; + } catch (Exception e) { + log.error("Failed to get custom hostname {}", hostnameId, e); + throw new ExternalServiceException("Cloudflare API request failed.", e); + } + } + + public CustomHostnameResult findCustomHostnameByName(String hostname) { + requireConfigured(); + + try { + HttpHeaders headers = createHeaders(); + HttpEntity request = new HttpEntity<>(headers); + + String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames?hostname=" + hostname; + ResponseEntity> response = restTemplate.exchange(url, HttpMethod.GET, request, MAP_RESPONSE); + + Map body = requireSuccessBody(response, "find custom hostname " + hostname); + Object resultsValue = body.get("result"); + if (resultsValue instanceof List results && !results.isEmpty()) { + return parseCustomHostnameResult(Map.of("success", true, "result", results.get(0))); + } + return null; + } catch (ExternalServiceException e) { + throw e; + } catch (Exception e) { + log.error("Failed to find custom hostname {}", hostname, e); + throw new ExternalServiceException("Cloudflare API request failed.", e); + } + } + + private void requireConfigured() { + if (!config.isConfigured()) { + throw new ExternalServiceException("Cloudflare is not configured."); + } + } + + private Map requireSuccessBody(ResponseEntity> response, String operation) { + Map body = response.getBody(); + if (!response.getStatusCode().is2xxSuccessful() || body == null || !Boolean.TRUE.equals(body.get("success"))) { + log.error("Cloudflare API returned non-success for {}: {}", operation, body == null ? null : body.get("errors")); + throw new ExternalServiceException("Cloudflare API request failed."); + } + return body; + } + + public List listAllCustomHostnames() { + if (!config.isConfigured()) { + return List.of(); + } + + List hostnames = new ArrayList<>(); + HttpHeaders headers = createHeaders(); + HttpEntity request = new HttpEntity<>(headers); + int page = 1; + + while (page <= MAX_HOSTNAME_PAGES) { + String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + + "/custom_hostnames?page=" + page + "&per_page=" + HOSTNAME_PAGE_SIZE; + try { + ResponseEntity> response = restTemplate.exchange(url, HttpMethod.GET, request, MAP_RESPONSE); + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null + || !Boolean.TRUE.equals(response.getBody().get("success"))) { + log.error("Cloudflare API returned non-success listing custom hostnames on page {}", page); + return List.of(); + } + + Object resultsValue = response.getBody().get("result"); + if (resultsValue instanceof List results) { + for (Object entry : results) { + if (entry instanceof Map) { + CustomHostnameResult parsed = parseCustomHostnameResult(Map.of("success", true, "result", entry)); + if (parsed != null) { + hostnames.add(parsed); + } + } + } + } + + int totalPages = extractTotalPages(response.getBody()); + if (page >= totalPages) { + break; + } + page++; + } catch (Exception e) { + log.error("Failed to list custom hostnames on page {}", page, e); + return List.of(); + } + } + return hostnames; + } + + private int extractTotalPages(Map body) { + if (body.get("result_info") instanceof Map resultInfo + && resultInfo.get("total_pages") instanceof Number totalPages) { + return totalPages.intValue(); + } + return 1; + } + + public boolean deleteCustomHostname(String hostnameId) { + if (!config.isConfigured()) { + return false; + } + + try { + HttpHeaders headers = createHeaders(); + HttpEntity request = new HttpEntity<>(headers); + + String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames/" + hostnameId; + ResponseEntity> response = restTemplate.exchange(url, HttpMethod.DELETE, request, MAP_RESPONSE); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + return Boolean.TRUE.equals(response.getBody().get("success")); + } + return false; + } catch (Exception e) { + log.error("Failed to delete custom hostname {}", hostnameId, e); + return false; + } + } + + public record CustomHostnameResult( + String id, + String hostname, + String status, + SslStatus ssl, + String ownershipVerificationHttpUrl, + String ownershipVerificationHttpBody + ) { + public record SslStatus(String status, String method, String type) {} + } +} diff --git a/src/main/java/gg/modl/backend/dashboard/controller/DashboardController.java b/src/main/java/gg/modl/backend/dashboard/controller/DashboardController.java index 3ea91ae..360b984 100644 --- a/src/main/java/gg/modl/backend/dashboard/controller/DashboardController.java +++ b/src/main/java/gg/modl/backend/dashboard/controller/DashboardController.java @@ -1,83 +1,85 @@ -package gg.modl.backend.dashboard.controller; - -import gg.modl.backend.auth.session.AuthSessionData; -import gg.modl.backend.dashboard.dto.response.ActivityItemResponse; -import gg.modl.backend.infrastructure.exception.UnauthorizedException; -import gg.modl.backend.dashboard.dto.response.DashboardMetricsResponse; -import gg.modl.backend.dashboard.dto.response.RecentPunishmentResponse; -import gg.modl.backend.dashboard.dto.response.RecentTicketResponse; -import gg.modl.backend.dashboard.service.DashboardService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import gg.modl.proto.modl.v1.DashboardActivityResponse; -import gg.modl.proto.modl.v1.DashboardRecentPunishmentsResponse; -import gg.modl.proto.modl.v1.DashboardRecentTicketsResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_DASHBOARD) -@RequiredArgsConstructor -@Validated -public class DashboardController { - private final DashboardService dashboardService; - - @GetMapping("/metrics") - public ResponseEntity getMetrics( - @RequestParam(name = "period", defaultValue = "7d") String period, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - DashboardMetricsResponse metrics = dashboardService.getMetrics(server, period); - return ResponseEntity.ok(DashboardProtoMapper.toMetrics(metrics)); - } - - @GetMapping("/recent-tickets") - public ResponseEntity getRecentTickets( - @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List tickets = dashboardService.getRecentTickets(server, limit); - return ResponseEntity.ok(DashboardProtoMapper.toRecentTickets(tickets)); - } - - @GetMapping("/recent-punishments") - public ResponseEntity getRecentPunishments( - @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List punishments = dashboardService.getRecentPunishments(server, limit); - return ResponseEntity.ok(DashboardProtoMapper.toRecentPunishments(punishments)); - } - - @GetMapping("/activity/recent") - public ResponseEntity getRecentActivity( - @RequestParam(defaultValue = "20") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - @RequestParam(defaultValue = "7") @Min(1) @Max(DashboardService.MAX_DAYS) int days, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - AuthSessionData session = RequestUtil.getSession(request); - - if (session == null || session.getEmail() == null) { - throw new UnauthorizedException("Not authenticated"); - } - - String staffEmail = session.getEmail(); - List activities = dashboardService.getRecentActivity(server, staffEmail, limit, days); - return ResponseEntity.ok(DashboardProtoMapper.toActivity(activities)); - } -} +package gg.modl.backend.dashboard.controller; + +import gg.modl.backend.auth.session.AuthSessionData; +import gg.modl.backend.dashboard.dto.response.ActivityItemResponse; +import gg.modl.backend.infrastructure.exception.UnauthorizedException; +import gg.modl.backend.dashboard.dto.response.DashboardMetricsResponse; +import gg.modl.backend.dashboard.dto.response.RecentPunishmentResponse; +import gg.modl.backend.dashboard.dto.response.RecentTicketResponse; +import gg.modl.backend.dashboard.service.DashboardService; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.proto.modl.v1.DashboardActivityResponse; +import gg.modl.proto.modl.v1.DashboardRecentPunishmentsResponse; +import gg.modl.proto.modl.v1.DashboardRecentTicketsResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_DASHBOARD) +@RequiresPanelPermission("admin.audit.view.dashboard") +@RequiredArgsConstructor +@Validated +public class DashboardController { + private final DashboardService dashboardService; + + @GetMapping("/metrics") + public ResponseEntity getMetrics( + @RequestParam(name = "period", defaultValue = "7d") String period, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + DashboardMetricsResponse metrics = dashboardService.getMetrics(server, period); + return ResponseEntity.ok(DashboardProtoMapper.toMetrics(metrics)); + } + + @GetMapping("/recent-tickets") + public ResponseEntity getRecentTickets( + @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List tickets = dashboardService.getRecentTickets(server, limit); + return ResponseEntity.ok(DashboardProtoMapper.toRecentTickets(tickets)); + } + + @GetMapping("/recent-punishments") + public ResponseEntity getRecentPunishments( + @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List punishments = dashboardService.getRecentPunishments(server, limit); + return ResponseEntity.ok(DashboardProtoMapper.toRecentPunishments(punishments)); + } + + @GetMapping("/activity/recent") + public ResponseEntity getRecentActivity( + @RequestParam(defaultValue = "20") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + @RequestParam(defaultValue = "7") @Min(1) @Max(DashboardService.MAX_DAYS) int days, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + AuthSessionData session = RequestUtil.getSession(request); + + if (session == null || session.getEmail() == null) { + throw new UnauthorizedException("Not authenticated"); + } + + String staffEmail = session.getEmail(); + List activities = dashboardService.getRecentActivity(server, staffEmail, limit, days); + return ResponseEntity.ok(DashboardProtoMapper.toActivity(activities)); + } +} diff --git a/src/main/java/gg/modl/backend/dashboard/dto/response/ActivityItemResponse.java b/src/main/java/gg/modl/backend/dashboard/dto/response/ActivityItemResponse.java index c0101c1..57755ed 100644 --- a/src/main/java/gg/modl/backend/dashboard/dto/response/ActivityItemResponse.java +++ b/src/main/java/gg/modl/backend/dashboard/dto/response/ActivityItemResponse.java @@ -1,21 +1,21 @@ -package gg.modl.backend.dashboard.dto.response; - -import java.util.Date; -import java.util.List; - -public record ActivityItemResponse( - String id, - String type, - String color, - String title, - Date time, - String description, - List actions -) { - public record ActivityAction( - String label, - String link, - boolean primary - ) { - } -} +package gg.modl.backend.dashboard.dto.response; + +import java.util.Date; +import java.util.List; + +public record ActivityItemResponse( + String id, + String type, + String color, + String title, + Date time, + String description, + List actions +) { + public record ActivityAction( + String label, + String link, + boolean primary + ) { + } +} diff --git a/src/main/java/gg/modl/backend/dashboard/dto/response/DashboardMetricsResponse.java b/src/main/java/gg/modl/backend/dashboard/dto/response/DashboardMetricsResponse.java index 5614440..096fb9d 100644 --- a/src/main/java/gg/modl/backend/dashboard/dto/response/DashboardMetricsResponse.java +++ b/src/main/java/gg/modl/backend/dashboard/dto/response/DashboardMetricsResponse.java @@ -1,13 +1,13 @@ -package gg.modl.backend.dashboard.dto.response; - -public record DashboardMetricsResponse( - long totalTickets, - long openTickets, - long totalPlayers, - long totalPunishments, - long activePunishments, - long totalStaff, - int ticketsTrend, - int playersTrend -) { -} +package gg.modl.backend.dashboard.dto.response; + +public record DashboardMetricsResponse( + long totalTickets, + long openTickets, + long totalPlayers, + long totalPunishments, + long activePunishments, + long totalStaff, + int ticketsTrend, + int playersTrend +) { +} diff --git a/src/main/java/gg/modl/backend/dashboard/dto/response/RecentPunishmentResponse.java b/src/main/java/gg/modl/backend/dashboard/dto/response/RecentPunishmentResponse.java index 9a32a2d..9e7c5e9 100644 --- a/src/main/java/gg/modl/backend/dashboard/dto/response/RecentPunishmentResponse.java +++ b/src/main/java/gg/modl/backend/dashboard/dto/response/RecentPunishmentResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.dashboard.dto.response; - -import java.util.Date; - -public record RecentPunishmentResponse( - String id, - String playerName, - String playerUuid, - String type, - String reason, - String issuerName, - Date issued, - boolean active -) { -} +package gg.modl.backend.dashboard.dto.response; + +import java.util.Date; + +public record RecentPunishmentResponse( + String id, + String playerName, + String playerUuid, + String type, + String reason, + String issuerName, + Date issued, + boolean active +) { +} diff --git a/src/main/java/gg/modl/backend/dashboard/dto/response/RecentTicketResponse.java b/src/main/java/gg/modl/backend/dashboard/dto/response/RecentTicketResponse.java index 75fb980..ceccbc7 100644 --- a/src/main/java/gg/modl/backend/dashboard/dto/response/RecentTicketResponse.java +++ b/src/main/java/gg/modl/backend/dashboard/dto/response/RecentTicketResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.dashboard.dto.response; - -import java.util.Date; - -public record RecentTicketResponse( - String id, - String title, - String initialMessage, - String status, - String priority, - Date createdAt, - String playerName, - String type -) { -} +package gg.modl.backend.dashboard.dto.response; + +import java.util.Date; + +public record RecentTicketResponse( + String id, + String title, + String initialMessage, + String status, + String priority, + Date createdAt, + String playerName, + String type +) { +} diff --git a/src/main/java/gg/modl/backend/dashboard/service/DashboardService.java b/src/main/java/gg/modl/backend/dashboard/service/DashboardService.java index e121841..f89f442 100644 --- a/src/main/java/gg/modl/backend/dashboard/service/DashboardService.java +++ b/src/main/java/gg/modl/backend/dashboard/service/DashboardService.java @@ -1,388 +1,388 @@ -package gg.modl.backend.dashboard.service; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import gg.modl.backend.dashboard.dto.response.ActivityItemResponse; -import gg.modl.backend.dashboard.dto.response.DashboardMetricsResponse; -import gg.modl.backend.dashboard.dto.response.MinecraftDashboardStatsResponse; -import gg.modl.backend.dashboard.dto.response.RecentPunishmentResponse; -import gg.modl.backend.dashboard.dto.response.RecentTicketResponse; -import gg.modl.backend.database.mongo.fields.PlayerFields; -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.database.mongo.repository.TicketMongoRepository; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.player.data.punishment.EnforcementCategory; -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.service.PlayerStatusCalculator; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeIndex; -import gg.modl.backend.settings.service.PunishmentTypeService; -import gg.modl.backend.staff.service.StaffService; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketCategory; -import gg.modl.backend.ticket.data.TicketPriority; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.data.TicketStatus; -import gg.modl.backend.ticket.util.TicketAssigneeUtil; -import gg.modl.backend.infrastructure.util.DateRangeUtil; -import gg.modl.backend.player.service.PlayerDataUtils; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.bson.Document; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class DashboardService { - private final TicketMongoRepository ticketRepository; - private final PlayerMongoRepository playerRepository; - private final PunishmentMongoRepository punishmentRepository; - private final StaffMongoRepository staffRepository; - private final StaffService staffService; - private final PunishmentTypeService punishmentTypeService; - private final PlayerStatusCalculator statusCalculator; - - private final Cache activePunishmentCountsCache = Caffeine.newBuilder() - .expireAfterWrite(Duration.ofSeconds(60)) - .maximumSize(500) - .build(); - - private static final int RECENT_PUNISHMENT_WINDOW_DAYS = 7; - private static final int MAX_RECENT_TICKETS_LIMIT = 20; - private static final int MAX_RECENT_PUNISHMENTS_LIMIT = 20; - private static final int MAX_ACTIVITY_LIMIT = 100; - public static final int MAX_DAYS = 90; - private static final int MAX_QUERY_RESULTS = 200; - - public MinecraftDashboardStatsResponse getMinecraftStats(Server server) { - long unresolvedReports = ticketRepository.countUnresolvedReports(server); - long unresolvedTickets = ticketRepository.countUnresolvedTickets(server); - long onlineStaff = countActiveStaff(server); - long onlinePlayers = playerRepository.countOnlinePlayers(server); - long totalPlayers = playerRepository.countAll(server); - - ActivePunishmentCounts punishmentCounts = countActivePunishments(server); - - return new MinecraftDashboardStatsResponse( - unresolvedReports, - unresolvedTickets, - onlineStaff, - onlinePlayers, - punishmentCounts.bans, - punishmentCounts.mutes, - punishmentCounts.total, - totalPlayers - ); - } - - private long countActiveStaff(Server server) { - List assignedUuids = staffRepository.findAssignedMinecraftUuids(server); - if (assignedUuids.isEmpty()) { - return 0; - } - return playerRepository.countOnlineByUuids(server, assignedUuids); - } - - private ActivePunishmentCounts countActivePunishments(Server server) { - return activePunishmentCountsCache.get(server.getId(), key -> computeActivePunishments(server)); - } - - private ActivePunishmentCounts computeActivePunishments(Server server) { - Map punishmentTypesByOrdinal = buildPunishmentTypeByOrdinal(server); - long activeBans = 0; - long activeMutes = 0; - long totalPunishments = 0; - - for (Player player : punishmentRepository.findWithPunishmentsProjected(server)) { - if (player.getPunishments().isEmpty()) { - continue; - } - - for (Punishment punishment : player.getPunishments()) { - if (!isPunishmentActiveSafely(punishment)) { - continue; - } - - totalPunishments++; - PunishmentType punishmentType = punishmentTypesByOrdinal.get(punishment.getTypeOrdinal()); - if (punishmentType == null) { - continue; - } - - String category = statusCalculator.getEffectiveCategory(punishment, punishmentTypesByOrdinal); - if (EnforcementCategory.BAN.name().equals(category)) { - activeBans++; - } else if (EnforcementCategory.MUTE.name().equals(category)) { - activeMutes++; - } - } - } - - return new ActivePunishmentCounts(activeBans, activeMutes, totalPunishments); - } - - private Map buildPunishmentTypeByOrdinal(Server server) { - return PunishmentTypeIndex.byOrdinal(punishmentTypeService.getPunishmentTypes(server)); - } - - private boolean isPunishmentActiveSafely(Punishment punishment) { - try { - return statusCalculator.isPunishmentActive(punishment); - } catch (Exception exception) { - log.warn("Failed to calculate punishment active state for punishment id={}", punishment.getId(), exception); - return false; - } - } - - public DashboardMetricsResponse getMetrics(Server server, String period) { - int windowDays = DateRangeUtil.resolveRangeDays(period); - Date windowStart = DateRangeUtil.daysAgo(windowDays); - Date priorWindowStart = DateRangeUtil.daysAgo(windowDays * 2); - - long totalTickets = ticketRepository.countAll(server); - long openTickets = ticketRepository.countByStatus(server, TicketStatus.OPEN); - long totalPlayers = playerRepository.countAll(server); - long totalStaff = staffService.countStaffIncludingSuperAdmin(server); - - long activePunishments = countActivePunishments(server).total(); - long totalPunishments = punishmentRepository.countAllPunishments(server); - - long recentTickets = ticketRepository.countCreatedAfter(server, windowStart); - long prevTickets = ticketRepository.countCreatedBetween(server, priorWindowStart, windowStart); - int ticketsTrend = prevTickets > 0 ? (int) Math.round(((double) (recentTickets - prevTickets) / prevTickets) * 100) : 0; - - long recentPlayers = playerRepository.countFirstJoinedAfter(server, windowStart); - long prevPlayers = playerRepository.countFirstJoinedBetween(server, priorWindowStart, windowStart); - int playersTrend = prevPlayers > 0 ? (int) Math.round(((double) (recentPlayers - prevPlayers) / prevPlayers) * 100) : 0; - - return new DashboardMetricsResponse( - totalTickets, - openTickets, - totalPlayers, - totalPunishments, - activePunishments, - totalStaff, - ticketsTrend, - playersTrend - ); - } - - public List getRecentTickets(Server server, int limit) { - int safeLimit = clampLimit(limit, MAX_RECENT_TICKETS_LIMIT); - List tickets = ticketRepository.findRecentWithProjection(server, safeLimit); - - return tickets.stream() - .map(ticket -> { - String initialMessage = null; - if (ticket.getReplies() != null && !ticket.getReplies().isEmpty()) { - TicketReply firstReply = ticket.getReplies().get(0); - if (firstReply.getContent() != null) { - initialMessage = firstReply.getContent(); - } - } - - return new RecentTicketResponse( - ticket.getId(), - ticket.getSubject() != null ? ticket.getSubject() : "No Subject", - initialMessage, - ticket.getStatus() != null ? ticket.getStatus().getId() : TicketStatus.OPEN.getId(), - ticket.getPriority() != null ? ticket.getPriority().getId() : TicketPriority.NORMAL.getId(), - ticket.getCreated(), - ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unknown", - ticket.getType() != null ? ticket.getType().getId() : TicketCategory.SUPPORT.getId() - ); - }) - .toList(); - } - - private int clampLimit(int value, int max) { - return Math.max(1, Math.min(value, max)); - } - - public List getRecentPunishments(Server server, int limit) { - int safeLimit = clampLimit(limit, MAX_RECENT_PUNISHMENTS_LIMIT); - Date cutoff = DateRangeUtil.daysAgo(RECENT_PUNISHMENT_WINDOW_DAYS); - - Map punishmentTypeNameByOrdinal = buildPunishmentTypeNameByOrdinal(server); - List punishmentRows = punishmentRepository.fetchRecentPunishmentRows(server, cutoff, safeLimit); - - List results = new ArrayList<>(); - for (Document row : punishmentRows) { - Punishment punishment = readPunishment(server, row.get("punishment", Document.class)); - if (punishment == null || punishment.getIssued() == null || punishment.getIssued().before(cutoff)) { - continue; - } - - String reason = ""; - if (punishment.getData() != null && punishment.getData().get("reason") != null) { - reason = String.valueOf(punishment.getData().get("reason")); - } - - String typeName = punishmentTypeNameByOrdinal.getOrDefault(punishment.getTypeOrdinal(), "Unknown"); - String playerName = PlayerDataUtils.extractLatestUsername(row.get(PlayerFields.USERNAMES)); - String playerUuid = PlayerDataUtils.extractMinecraftUuid(row); - - results.add(new RecentPunishmentResponse( - punishment.getId(), - playerName, - playerUuid, - typeName, - reason, - punishment.getIssuerName() != null ? punishment.getIssuerName() : "Unknown", - punishment.getIssued(), - isPunishmentActiveSafely(punishment) - )); - } - - if (results.size() > safeLimit) { - return results.subList(0, safeLimit); - } - - return results; - } - - private Punishment readPunishment(Server server, Document punishmentDocument) { - if (punishmentDocument == null) { - return null; - } - - try { - return punishmentRepository.readPunishment(server, punishmentDocument); - } catch (Exception exception) { - log.warn("Failed to parse punishment document for dashboard response", exception); - return null; - } - } - - private Map buildPunishmentTypeNameByOrdinal(Server server) { - Map names = new HashMap<>(); - buildPunishmentTypeByOrdinal(server).forEach((ordinal, type) -> - names.put(ordinal, type.getName())); - return names; - } - - - public List getRecentActivity(Server server, String staffEmail, int limit, int days) { - List activities = new ArrayList<>(); - - int safeLimit = clampLimit(limit, MAX_ACTIVITY_LIMIT); - int safeDays = clampLimit(days, MAX_DAYS); - - String staffUsername = staffRepository.findUsernameByEmail(server, staffEmail).orElse(null); - if (staffUsername == null) { - return activities; - } - - Date cutoffDate = DateRangeUtil.daysAgo(safeDays); - - fetchTicketActivities(server, staffUsername, cutoffDate, activities); - fetchPunishmentActivities(server, staffUsername, cutoffDate, activities); - - activities.sort((left, right) -> right.time().compareTo(left.time())); - - if (activities.size() > safeLimit) { - return activities.subList(0, safeLimit); - } - - return activities; - } - - private void fetchTicketActivities(Server server, String staffUsername, Date cutoffDate, List activities) { - try { - String normalizedStaffUsername = TicketAssigneeUtil.normalizeSingle(staffUsername); - List tickets = ticketRepository.findStaffActivityTickets(server, staffUsername, normalizedStaffUsername, cutoffDate, MAX_QUERY_RESULTS); - - for (Ticket ticket : tickets) { - if (ticket.getCreatorName() != null - && ticket.getCreatorName().equals(staffUsername) - && ticket.getCreated() != null - && ticket.getCreated().after(cutoffDate)) { - activities.add(new ActivityItemResponse( - "ticket-created-" + ticket.getId(), - "new_ticket", - "blue", - "Created ticket: " + (ticket.getSubject() != null ? ticket.getSubject() : "No Subject"), - ticket.getCreated(), - "Created " + displayCategory(ticket) + " ticket", - List.of(new ActivityItemResponse.ActivityAction("View Ticket", "/panel/tickets/" + ticket.getId(), true)) - )); - } - - if (ticket.getReplies() != null) { - for (TicketReply reply : ticket.getReplies()) { - if (reply.getCreated() == null || !reply.getCreated().after(cutoffDate)) { - continue; - } - - boolean isStaffReply = staffUsername.equalsIgnoreCase(reply.getName()); - String actionType = isStaffReply ? "My reply" : "New reply"; - String color = isStaffReply ? "green" : "blue"; - String replyName = reply.getName() != null ? reply.getName() : "Unknown"; - String description = isStaffReply - ? "You replied to " + displayCategory(ticket) + " ticket" - : replyName + " replied to " + displayCategory(ticket) + " ticket"; - - activities.add(new ActivityItemResponse( - "ticket-reply-" + ticket.getId() + "-" + reply.getCreated().getTime(), - "mod_action", - color, - actionType + " on ticket: " + (ticket.getSubject() != null ? ticket.getSubject() : "No Subject"), - reply.getCreated(), - description, - List.of(new ActivityItemResponse.ActivityAction("View Ticket", "/panel/tickets/" + ticket.getId(), true)) - )); - } - } - } - } catch (Exception exception) { - log.error("Error fetching ticket activities", exception); - } - } - - private String displayCategory(Ticket ticket) { - return ticket.getType() != null ? ticket.getType().getDisplayName() : TicketCategory.SUPPORT.getDisplayName(); - } - - private void fetchPunishmentActivities(Server server, String staffUsername, Date cutoffDate, List activities) { - try { - Map punishmentTypeNameByOrdinal = buildPunishmentTypeNameByOrdinal(server); - List punishmentRows = punishmentRepository.fetchRecentPunishmentRowsByIssuer(server, staffUsername, cutoffDate, MAX_QUERY_RESULTS); - - for (Document row : punishmentRows) { - Punishment punishment = readPunishment(server, row.get("punishment", Document.class)); - if (punishment == null || punishment.getIssued() == null || punishment.getIssued().before(cutoffDate)) { - continue; - } - - - String username = PlayerDataUtils.extractLatestUsername(row.get(PlayerFields.USERNAMES)); - String punishmentTypeName = punishmentTypeNameByOrdinal.getOrDefault(punishment.getTypeOrdinal(), "Unknown"); - String playerUuid = PlayerDataUtils.extractMinecraftUuid(row); - - activities.add(new ActivityItemResponse( - "punishment-" + punishment.getId(), - "new_punishment", - "red", - "Applied " + punishmentTypeName + " to " + username, - punishment.getIssued(), - "Applied " + punishmentTypeName + " punishment", - List.of(new ActivityItemResponse.ActivityAction("View Player", "/panel/players/" + playerUuid, true)) - )); - } - } catch (Exception exception) { - log.error("Error fetching punishment activities", exception); - } - } - - private record ActivePunishmentCounts(long bans, long mutes, long total) {} -} +package gg.modl.backend.dashboard.service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import gg.modl.backend.dashboard.dto.response.ActivityItemResponse; +import gg.modl.backend.dashboard.dto.response.DashboardMetricsResponse; +import gg.modl.backend.dashboard.dto.response.MinecraftDashboardStatsResponse; +import gg.modl.backend.dashboard.dto.response.RecentPunishmentResponse; +import gg.modl.backend.dashboard.dto.response.RecentTicketResponse; +import gg.modl.backend.database.mongo.fields.PlayerFields; +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.punishment.EnforcementCategory; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.service.PlayerStatusCalculator; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeIndex; +import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.backend.staff.service.StaffService; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketCategory; +import gg.modl.backend.ticket.data.TicketPriority; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.data.TicketStatus; +import gg.modl.backend.ticket.util.TicketAssigneeUtil; +import gg.modl.backend.infrastructure.util.DateRangeUtil; +import gg.modl.backend.player.service.PlayerDataUtils; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DashboardService { + private final TicketMongoRepository ticketRepository; + private final PlayerMongoRepository playerRepository; + private final PunishmentMongoRepository punishmentRepository; + private final StaffMongoRepository staffRepository; + private final StaffService staffService; + private final PunishmentTypeService punishmentTypeService; + private final PlayerStatusCalculator statusCalculator; + + private final Cache activePunishmentCountsCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(60)) + .maximumSize(500) + .build(); + + private static final int RECENT_PUNISHMENT_WINDOW_DAYS = 7; + private static final int MAX_RECENT_TICKETS_LIMIT = 20; + private static final int MAX_RECENT_PUNISHMENTS_LIMIT = 20; + private static final int MAX_ACTIVITY_LIMIT = 100; + public static final int MAX_DAYS = 90; + private static final int MAX_QUERY_RESULTS = 200; + + public MinecraftDashboardStatsResponse getMinecraftStats(Server server) { + long unresolvedReports = ticketRepository.countUnresolvedReports(server); + long unresolvedTickets = ticketRepository.countUnresolvedTickets(server); + long onlineStaff = countActiveStaff(server); + long onlinePlayers = playerRepository.countOnlinePlayers(server); + long totalPlayers = playerRepository.countAll(server); + + ActivePunishmentCounts punishmentCounts = countActivePunishments(server); + + return new MinecraftDashboardStatsResponse( + unresolvedReports, + unresolvedTickets, + onlineStaff, + onlinePlayers, + punishmentCounts.bans, + punishmentCounts.mutes, + punishmentCounts.total, + totalPlayers + ); + } + + private long countActiveStaff(Server server) { + List assignedUuids = staffRepository.findAssignedMinecraftUuids(server); + if (assignedUuids.isEmpty()) { + return 0; + } + return playerRepository.countOnlineByUuids(server, assignedUuids); + } + + private ActivePunishmentCounts countActivePunishments(Server server) { + return activePunishmentCountsCache.get(server.getId(), key -> computeActivePunishments(server)); + } + + private ActivePunishmentCounts computeActivePunishments(Server server) { + Map punishmentTypesByOrdinal = buildPunishmentTypeByOrdinal(server); + long activeBans = 0; + long activeMutes = 0; + long totalPunishments = 0; + + for (Player player : punishmentRepository.findWithPunishmentsProjected(server)) { + if (player.getPunishments().isEmpty()) { + continue; + } + + for (Punishment punishment : player.getPunishments()) { + if (!isPunishmentActiveSafely(punishment)) { + continue; + } + + totalPunishments++; + PunishmentType punishmentType = punishmentTypesByOrdinal.get(punishment.getTypeOrdinal()); + if (punishmentType == null) { + continue; + } + + String category = statusCalculator.getEffectiveCategory(punishment, punishmentTypesByOrdinal); + if (EnforcementCategory.BAN.name().equals(category)) { + activeBans++; + } else if (EnforcementCategory.MUTE.name().equals(category)) { + activeMutes++; + } + } + } + + return new ActivePunishmentCounts(activeBans, activeMutes, totalPunishments); + } + + private Map buildPunishmentTypeByOrdinal(Server server) { + return PunishmentTypeIndex.byOrdinal(punishmentTypeService.getPunishmentTypes(server)); + } + + private boolean isPunishmentActiveSafely(Punishment punishment) { + try { + return statusCalculator.isPunishmentActive(punishment); + } catch (Exception exception) { + log.warn("Failed to calculate punishment active state for punishment id={}", punishment.getId(), exception); + return false; + } + } + + public DashboardMetricsResponse getMetrics(Server server, String period) { + int windowDays = DateRangeUtil.resolveRangeDays(period); + Date windowStart = DateRangeUtil.daysAgo(windowDays); + Date priorWindowStart = DateRangeUtil.daysAgo(windowDays * 2); + + long totalTickets = ticketRepository.countAll(server); + long openTickets = ticketRepository.countByStatus(server, TicketStatus.OPEN); + long totalPlayers = playerRepository.countAll(server); + long totalStaff = staffService.countStaffIncludingSuperAdmin(server); + + long activePunishments = countActivePunishments(server).total(); + long totalPunishments = punishmentRepository.countAllPunishments(server); + + long recentTickets = ticketRepository.countCreatedAfter(server, windowStart); + long prevTickets = ticketRepository.countCreatedBetween(server, priorWindowStart, windowStart); + int ticketsTrend = prevTickets > 0 ? (int) Math.round(((double) (recentTickets - prevTickets) / prevTickets) * 100) : 0; + + long recentPlayers = playerRepository.countFirstJoinedAfter(server, windowStart); + long prevPlayers = playerRepository.countFirstJoinedBetween(server, priorWindowStart, windowStart); + int playersTrend = prevPlayers > 0 ? (int) Math.round(((double) (recentPlayers - prevPlayers) / prevPlayers) * 100) : 0; + + return new DashboardMetricsResponse( + totalTickets, + openTickets, + totalPlayers, + totalPunishments, + activePunishments, + totalStaff, + ticketsTrend, + playersTrend + ); + } + + public List getRecentTickets(Server server, int limit) { + int safeLimit = clampLimit(limit, MAX_RECENT_TICKETS_LIMIT); + List tickets = ticketRepository.findRecentWithProjection(server, safeLimit); + + return tickets.stream() + .map(ticket -> { + String initialMessage = null; + if (ticket.getReplies() != null && !ticket.getReplies().isEmpty()) { + TicketReply firstReply = ticket.getReplies().get(0); + if (firstReply.getContent() != null) { + initialMessage = firstReply.getContent(); + } + } + + return new RecentTicketResponse( + ticket.getId(), + ticket.getSubject() != null ? ticket.getSubject() : "No Subject", + initialMessage, + ticket.getStatus() != null ? ticket.getStatus().getId() : TicketStatus.OPEN.getId(), + ticket.getPriority() != null ? ticket.getPriority().getId() : TicketPriority.NORMAL.getId(), + ticket.getCreated(), + ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unknown", + ticket.getType() != null ? ticket.getType().getId() : TicketCategory.SUPPORT.getId() + ); + }) + .toList(); + } + + private int clampLimit(int value, int max) { + return Math.max(1, Math.min(value, max)); + } + + public List getRecentPunishments(Server server, int limit) { + int safeLimit = clampLimit(limit, MAX_RECENT_PUNISHMENTS_LIMIT); + Date cutoff = DateRangeUtil.daysAgo(RECENT_PUNISHMENT_WINDOW_DAYS); + + Map punishmentTypeNameByOrdinal = buildPunishmentTypeNameByOrdinal(server); + List punishmentRows = punishmentRepository.fetchRecentPunishmentRows(server, cutoff, safeLimit); + + List results = new ArrayList<>(); + for (Document row : punishmentRows) { + Punishment punishment = readPunishment(server, row.get("punishment", Document.class)); + if (punishment == null || punishment.getIssued() == null || punishment.getIssued().before(cutoff)) { + continue; + } + + String reason = punishment.data().reason(); + if (reason == null) { + reason = ""; + } + + String typeName = punishmentTypeNameByOrdinal.getOrDefault(punishment.getTypeOrdinal(), "Unknown"); + String playerName = PlayerDataUtils.extractLatestUsername(row.get(PlayerFields.USERNAMES)); + String playerUuid = PlayerDataUtils.extractMinecraftUuid(row); + + results.add(new RecentPunishmentResponse( + punishment.getId(), + playerName, + playerUuid, + typeName, + reason, + punishment.getIssuerName() != null ? punishment.getIssuerName() : "Unknown", + punishment.getIssued(), + isPunishmentActiveSafely(punishment) + )); + } + + if (results.size() > safeLimit) { + return results.subList(0, safeLimit); + } + + return results; + } + + private Punishment readPunishment(Server server, Document punishmentDocument) { + if (punishmentDocument == null) { + return null; + } + + try { + return punishmentRepository.readPunishment(server, punishmentDocument); + } catch (Exception exception) { + log.warn("Failed to parse punishment document for dashboard response", exception); + return null; + } + } + + private Map buildPunishmentTypeNameByOrdinal(Server server) { + Map names = new HashMap<>(); + buildPunishmentTypeByOrdinal(server).forEach((ordinal, type) -> + names.put(ordinal, type.getName())); + return names; + } + + + public List getRecentActivity(Server server, String staffEmail, int limit, int days) { + List activities = new ArrayList<>(); + + int safeLimit = clampLimit(limit, MAX_ACTIVITY_LIMIT); + int safeDays = clampLimit(days, MAX_DAYS); + + String staffUsername = staffRepository.findUsernameByEmail(server, staffEmail).orElse(null); + if (staffUsername == null) { + return activities; + } + + Date cutoffDate = DateRangeUtil.daysAgo(safeDays); + + fetchTicketActivities(server, staffUsername, cutoffDate, activities); + fetchPunishmentActivities(server, staffUsername, cutoffDate, activities); + + activities.sort((left, right) -> right.time().compareTo(left.time())); + + if (activities.size() > safeLimit) { + return activities.subList(0, safeLimit); + } + + return activities; + } + + private void fetchTicketActivities(Server server, String staffUsername, Date cutoffDate, List activities) { + try { + String normalizedStaffUsername = TicketAssigneeUtil.normalizeSingle(staffUsername); + List tickets = ticketRepository.findStaffActivityTickets(server, staffUsername, normalizedStaffUsername, cutoffDate, MAX_QUERY_RESULTS); + + for (Ticket ticket : tickets) { + if (ticket.getCreatorName() != null + && ticket.getCreatorName().equals(staffUsername) + && ticket.getCreated() != null + && ticket.getCreated().after(cutoffDate)) { + activities.add(new ActivityItemResponse( + "ticket-created-" + ticket.getId(), + "new_ticket", + "blue", + "Created ticket: " + (ticket.getSubject() != null ? ticket.getSubject() : "No Subject"), + ticket.getCreated(), + "Created " + displayCategory(ticket) + " ticket", + List.of(new ActivityItemResponse.ActivityAction("View Ticket", "/panel/tickets/" + ticket.getId(), true)) + )); + } + + if (ticket.getReplies() != null) { + for (TicketReply reply : ticket.getReplies()) { + if (reply.getCreated() == null || !reply.getCreated().after(cutoffDate)) { + continue; + } + + boolean isStaffReply = staffUsername.equalsIgnoreCase(reply.getName()); + String actionType = isStaffReply ? "My reply" : "New reply"; + String color = isStaffReply ? "green" : "blue"; + String replyName = reply.getName() != null ? reply.getName() : "Unknown"; + String description = isStaffReply + ? "You replied to " + displayCategory(ticket) + " ticket" + : replyName + " replied to " + displayCategory(ticket) + " ticket"; + + activities.add(new ActivityItemResponse( + "ticket-reply-" + ticket.getId() + "-" + reply.getCreated().getTime(), + "mod_action", + color, + actionType + " on ticket: " + (ticket.getSubject() != null ? ticket.getSubject() : "No Subject"), + reply.getCreated(), + description, + List.of(new ActivityItemResponse.ActivityAction("View Ticket", "/panel/tickets/" + ticket.getId(), true)) + )); + } + } + } + } catch (Exception exception) { + log.error("Error fetching ticket activities", exception); + } + } + + private String displayCategory(Ticket ticket) { + return ticket.getType() != null ? ticket.getType().getDisplayName() : TicketCategory.SUPPORT.getDisplayName(); + } + + private void fetchPunishmentActivities(Server server, String staffUsername, Date cutoffDate, List activities) { + try { + Map punishmentTypeNameByOrdinal = buildPunishmentTypeNameByOrdinal(server); + List punishmentRows = punishmentRepository.fetchRecentPunishmentRowsByIssuer(server, staffUsername, cutoffDate, MAX_QUERY_RESULTS); + + for (Document row : punishmentRows) { + Punishment punishment = readPunishment(server, row.get("punishment", Document.class)); + if (punishment == null || punishment.getIssued() == null || punishment.getIssued().before(cutoffDate)) { + continue; + } + + + String username = PlayerDataUtils.extractLatestUsername(row.get(PlayerFields.USERNAMES)); + String punishmentTypeName = punishmentTypeNameByOrdinal.getOrDefault(punishment.getTypeOrdinal(), "Unknown"); + String playerUuid = PlayerDataUtils.extractMinecraftUuid(row); + + activities.add(new ActivityItemResponse( + "punishment-" + punishment.getId(), + "new_punishment", + "red", + "Applied " + punishmentTypeName + " to " + username, + punishment.getIssued(), + "Applied " + punishmentTypeName + " punishment", + List.of(new ActivityItemResponse.ActivityAction("View Player", "/panel/players/" + playerUuid, true)) + )); + } + } catch (Exception exception) { + log.error("Error fetching punishment activities", exception); + } + } + + private record ActivePunishmentCounts(long bans, long mutes, long total) {} +} diff --git a/src/main/java/gg/modl/backend/database/CollectionName.java b/src/main/java/gg/modl/backend/database/CollectionName.java index 0595926..724b48d 100644 --- a/src/main/java/gg/modl/backend/database/CollectionName.java +++ b/src/main/java/gg/modl/backend/database/CollectionName.java @@ -1,7 +1,6 @@ package gg.modl.backend.database; public final class CollectionName { - // Global public static final String MODL_SERVERS = "servers"; public static final String METRIC_SNAPSHOTS = "metric_snapshots"; public static final String SERVER_INSTANCE_SNAPSHOTS = "server_instance_snapshots"; @@ -9,52 +8,45 @@ public final class CollectionName { public static final String BETA_AUDIT = "beta_audit"; public static final String SCHEDULER_LEASES = "scheduler_leases"; - // Core public static final String PLAYERS = "players"; public static final String SESSIONS = "sessions"; public static final String AUTH_CODES = "auth_codes"; public static final String SETTINGS = "settings"; - // Staff & Roles public static final String STAFF = "staffs"; public static final String STAFF_ROLES = "staffroles"; public static final String INVITATIONS = "invitations"; - // Tickets public static final String TICKETS = "tickets"; public static final String TICKET_VERIFICATIONS = "ticket_verifications"; public static final String CHAT_LOGS = "chat_logs"; public static final String COMMAND_LOGS = "command_logs"; - // Audit public static final String LOGS = "logs"; - // WebAuthn public static final String WEBAUTHN_CREDENTIALS = "webauthn_credentials"; public static final String WEBAUTHN_CHALLENGES = "webauthn_challenges"; - // Knowledgebase & Homepage public static final String KNOWLEDGEBASE_CATEGORIES = "knowledgebasecategories"; public static final String KNOWLEDGEBASE_ARTICLES = "knowledgebasearticles"; public static final String HOMEPAGE_CARDS = "homepagecards"; - // System public static final String SYSTEM_LOGS = "system_logs"; public static final String SYSTEM_ALERTS = "system_alerts"; public static final String SECURITY_EVENTS = "security_events"; public static final String MIGRATIONS = "migrations"; public static final String TENANT_MIGRATIONS = "tenant_migrations"; + public static final String ADMIN_USERS = "admin_users"; + public static final String SYSTEM_CONFIG = "system_config"; + public static final String SYSTEM_PROMPTS = "systemprompts"; - // Replays public static final String REPLAYS = "replays"; public static final String REPLAY_LITE_REPLAYS = "replay_lite_replays"; public static final String REPLAY_LITE_DAILY_QUOTAS = "replay_lite_daily_quotas"; - // Training public static final String TRAINING_DATABASE = "training_data"; public static final String TRAINING_SEGMENTS = "training_segments"; - // Storage public static final String STORAGE_FILES = "storage_files"; public static final String EVIDENCE_UPLOAD_TOKENS = "evidence_upload_tokens"; } diff --git a/src/main/java/gg/modl/backend/database/CustomDomainOverrideDedupeMigration.java b/src/main/java/gg/modl/backend/database/CustomDomainOverrideDedupeMigration.java new file mode 100644 index 0000000..73bfa06 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/CustomDomainOverrideDedupeMigration.java @@ -0,0 +1,87 @@ +package gg.modl.backend.database; + +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.Updates; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.CustomDomainStatus; +import jakarta.annotation.PostConstruct; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomDomainOverrideDedupeMigration { + private static final String ID_FIELD = "_id"; + private static final Comparator KEEPER_PREFERENCE = Comparator + .comparing(CustomDomainOverrideDedupeMigration::isActive) + .thenComparing(CustomDomainOverrideDedupeMigration::lastChecked, + Comparator.nullsFirst(Comparator.naturalOrder())) + .reversed(); + + private final TenantMongoAccess tenantMongoAccess; + + @PostConstruct + public void run() { + dedupe(tenantMongoAccess.global()); + } + + private void dedupe(MongoTemplate template) { + MongoCollection servers = template.getCollection(CollectionName.MODL_SERVERS); + List withOverride = servers + .find(Filters.type(ServerFields.CUSTOM_DOMAIN_OVERRIDE, "string")) + .into(new ArrayList<>()); + + Map> byDomain = new LinkedHashMap<>(); + for (Document server : withOverride) { + byDomain.computeIfAbsent(server.getString(ServerFields.CUSTOM_DOMAIN_OVERRIDE), key -> new ArrayList<>()) + .add(server); + } + + long clearedLosers = 0; + for (List group : byDomain.values()) { + if (group.size() < 2) { + continue; + } + group.sort(KEEPER_PREFERENCE); + for (Document loser : group.subList(1, group.size())) { + clearOverride(servers, loser.get(ID_FIELD)); + clearedLosers++; + } + } + + if (clearedLosers > 0) { + log.warn("Cleared duplicate customDomainOverride values on {} losing server documents", clearedLosers); + } + } + + private void clearOverride(MongoCollection servers, Object serverId) { + servers.updateOne(Filters.eq(ID_FIELD, serverId), Updates.combine( + Updates.unset(ServerFields.CUSTOM_DOMAIN_OVERRIDE), + Updates.unset(ServerFields.CUSTOM_DOMAIN_STATUS), + Updates.unset(ServerFields.CUSTOM_DOMAIN_CLOUDFLARE_ID), + Updates.unset(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED), + Updates.unset(ServerFields.CUSTOM_DOMAIN_ERROR), + Updates.set(ServerFields.UPDATED_AT, new Date()) + )); + } + + private static boolean isActive(Document server) { + return CustomDomainStatus.ACTIVE.name().equals(server.getString(ServerFields.CUSTOM_DOMAIN_STATUS)); + } + + private static Date lastChecked(Document server) { + return server.get(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED) instanceof Date date ? date : null; + } +} diff --git a/src/main/java/gg/modl/backend/database/DynamicMongoTemplateProvider.java b/src/main/java/gg/modl/backend/database/DynamicMongoTemplateProvider.java index 1bdd018..ed56fd9 100644 --- a/src/main/java/gg/modl/backend/database/DynamicMongoTemplateProvider.java +++ b/src/main/java/gg/modl/backend/database/DynamicMongoTemplateProvider.java @@ -4,12 +4,14 @@ import com.github.benmanes.caffeine.cache.Caffeine; import com.mongodb.client.MongoClient; import java.time.Duration; +import lombok.RequiredArgsConstructor; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; import org.springframework.data.mongodb.core.convert.MappingMongoConverter; import org.springframework.stereotype.Service; @Service +@RequiredArgsConstructor public class DynamicMongoTemplateProvider { private final MongoClient mongoClient; private final MappingMongoConverter mongoConverter; @@ -19,14 +21,6 @@ public class DynamicMongoTemplateProvider { .build(); private static final String GLOBAL_DATABASE_NAME = "modl"; - public DynamicMongoTemplateProvider( - MongoClient mongoClient, - MappingMongoConverter mongoConverter - ) { - this.mongoClient = mongoClient; - this.mongoConverter = mongoConverter; - } - public MongoTemplate getGlobalDatabase() { return getFromDatabaseName(GLOBAL_DATABASE_NAME); } diff --git a/src/main/java/gg/modl/backend/database/MongoIndexBootstrapService.java b/src/main/java/gg/modl/backend/database/MongoIndexReconciler.java similarity index 63% rename from src/main/java/gg/modl/backend/database/MongoIndexBootstrapService.java rename to src/main/java/gg/modl/backend/database/MongoIndexReconciler.java index 8c7b707..fc2879a 100644 --- a/src/main/java/gg/modl/backend/database/MongoIndexBootstrapService.java +++ b/src/main/java/gg/modl/backend/database/MongoIndexReconciler.java @@ -1,623 +1,578 @@ -package gg.modl.backend.database; - -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.ServerFields; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.scheduling.SchedulerLeaseService; -import jakarta.annotation.PostConstruct; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.atomic.AtomicInteger; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.bson.Document; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; -import org.springframework.data.annotation.Id; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.index.Index; -import org.springframework.data.mongodb.core.index.IndexField; -import org.springframework.data.mongodb.core.index.IndexInfo; -import org.springframework.data.mongodb.core.index.IndexOperations; -import org.springframework.data.mongodb.core.index.PartialIndexFilter; -import org.springframework.data.mongodb.core.query.Collation; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class MongoIndexBootstrapService { - private static final String TYPE_OPERATOR = "$type"; - private static final Map BSON_TYPE_CODES = Map.ofEntries( - Map.entry("double", 1), - Map.entry("string", 2), - Map.entry("object", 3), - Map.entry("array", 4), - Map.entry("binData", 5), - Map.entry("objectId", 7), - Map.entry("bool", 8), - Map.entry("date", 9), - Map.entry("null", 10), - Map.entry("regex", 11), - Map.entry("javascript", 13), - Map.entry("int", 16), - Map.entry("timestamp", 17), - Map.entry("long", 18), - Map.entry("decimal", 19), - Map.entry("minKey", -1), - Map.entry("maxKey", 127) - ); - - private static final int BOOTSTRAP_PARALLELISM = 4; - private static final String TENANT_BOOTSTRAP_LEASE = "tenant-schema-bootstrap"; - private static final Duration TENANT_BOOTSTRAP_LEASE_TTL = Duration.ofMinutes(30); - - private final TenantMongoAccess tenantMongoAccess; - private final TenantMigrationService tenantMigrationService; - private final SchedulerLeaseService schedulerLeaseService; - - @PostConstruct - public void initGlobalIndexes() { - try { - createGlobalIndexes(tenantMongoAccess.global()); - } catch (Exception e) { - log.error("Failed to create global database indexes", e); - } - try { - createTrainingDataIndexes(tenantMongoAccess.forDatabase(CollectionName.TRAINING_DATABASE)); - } catch (Exception e) { - log.error("Failed to create training data indexes", e); - } - } - - private void createTrainingDataIndexes(MongoTemplate template) { - ensureIndexes(template, CollectionName.TRAINING_SEGMENTS, List.of( - IndexSpec.standard( - "idx_training_segments_serverDatabaseName_replayId", - doc("serverDatabaseName", 1).append("replayId", 1), - false, - false - ) - )); - } - - @EventListener(ApplicationReadyEvent.class) - public void bootstrapExistingTenants() { - if (!schedulerLeaseService.tryAcquire(TENANT_BOOTSTRAP_LEASE, TENANT_BOOTSTRAP_LEASE_TTL)) { - log.info("Skipping tenant schema bootstrap; lease held by another instance"); - return; - } - List targets; - try { - targets = loadBootstrapTargets(); - } catch (Exception e) { - log.error("Failed to list servers for tenant bootstrap", e); - return; - } - - if (targets.isEmpty()) { - log.info("Bootstrapping schema for 0 existing tenants"); - return; - } - - dispatchTenantBootstrap(targets); - } - - private List loadBootstrapTargets() { - Query query = new Query(); - query.fields().include(ServerFields.ID).include(ServerFields.DATABASE_NAME); - return tenantMongoAccess.global() - .find(query, BootstrapTarget.class, CollectionName.MODL_SERVERS) - .stream() - .filter(target -> target.databaseName() != null && !target.databaseName().isBlank()) - .toList(); - } - - private void dispatchTenantBootstrap(List targets) { - log.info("Bootstrapping schema for {} existing tenants", targets.size()); - int parallelism = Math.min(BOOTSTRAP_PARALLELISM, targets.size()); - ExecutorService executor = Executors.newFixedThreadPool(parallelism, bootstrapThreadFactory()); - AtomicInteger succeeded = new AtomicInteger(); - AtomicInteger failed = new AtomicInteger(); - - CompletableFuture[] tasks = targets.stream() - .map(target -> CompletableFuture.runAsync(() -> bootstrapTenant(target, succeeded, failed), executor)) - .toArray(CompletableFuture[]::new); - - CompletableFuture.allOf(tasks).whenComplete((ignored, throwable) -> { - log.info("Tenant schema bootstrap complete succeeded={} failed={}", succeeded.get(), failed.get()); - executor.shutdown(); - }); - } - - private void bootstrapTenant(BootstrapTarget target, AtomicInteger succeeded, AtomicInteger failed) { - try { - log.debug("Bootstrapping schema for server id={} database={}", - target.id(), target.databaseName()); - MongoTemplate template = tenantMongoAccess.forDatabase(target.databaseName()); - tenantMigrationService.applyMigrationsForTenant(template); - createTenantIndexes(template); - succeeded.incrementAndGet(); - } catch (Exception e) { - failed.incrementAndGet(); - log.warn("Failed to bootstrap schema for server id={} database={}", - target.id(), target.databaseName(), e); - } - } - - private ThreadFactory bootstrapThreadFactory() { - AtomicInteger threadNumber = new AtomicInteger(); - return runnable -> { - Thread thread = new Thread(runnable, "tenant-bootstrap-" + threadNumber.incrementAndGet()); - thread.setDaemon(true); - return thread; - }; - } - - private void createGlobalIndexes(MongoTemplate template) { - ensureIndexes(template, CollectionName.MODL_SERVERS, List.of( - IndexSpec.standard("uidx_servers_serverName", doc("serverName", 1), true, false), - IndexSpec.standard("uidx_servers_customDomain", doc("customDomain", 1), true, false), - IndexSpec.standard("uidx_servers_adminEmail", doc("adminEmail", 1), true, false), - IndexSpec.standard("idx_servers_emailVerified", doc("emailVerified", 1), false, false), - IndexSpec.standard("uidx_servers_emailVerificationToken", doc("emailVerificationToken", 1), true, true), - IndexSpec.standard("idx_servers_provisioningStatus", doc("provisioningStatus", 1), false, false), - IndexSpec.standard( - "idx_servers_registration_cleanup", - doc("emailVerified", 1).append("provisioningStatus", 1).append("createdAt", 1).append("emailVerificationToken", 1), - false, - true - ), - IndexSpec.standard("uidx_servers_provisioningSignInToken", doc("provisioningSignInToken", 1), true, true), - IndexSpec.standard("uidx_servers_stripeCustomerId", doc("stripeCustomerId", 1), true, true), - IndexSpec.standard("uidx_servers_stripeSubscriptionId", doc("stripeSubscriptionId", 1), true, true), - IndexSpec.standard("uidx_servers_customDomainOverride", doc("customDomainOverride", 1), true, true), - IndexSpec.standard("uidx_servers_customDomainCloudflareId", doc("customDomainCloudflareId", 1), true, true), - IndexSpec.standard("uidx_servers_cliSetupToken", doc("cliSetupToken", 1), true, true), - IndexSpec.standard("uidx_servers_apiKey", doc("apiKey", 1), true, true), - IndexSpec.standard("idx_servers_userCount", doc("userCount", 1), false, false), - IndexSpec.standard("idx_servers_ticketCount", doc("ticketCount", 1), false, false), - IndexSpec.standard("idx_servers_lastStatsUpdatedAt", doc("lastStatsUpdatedAt", 1), false, false), - IndexSpec.standard("idx_servers_createdAt", doc("createdAt", 1), false, false), - IndexSpec.standard("idx_servers_lastActivityAt", doc("lastActivityAt", -1), false, true) - )); - - dropSupersededIndexes(template, CollectionName.METRIC_SNAPSHOTS, List.of("idx_metric_snapshots_date")); - ensureIndexes(template, CollectionName.METRIC_SNAPSHOTS, List.of( - IndexSpec.standard("uidx_metric_snapshots_date", doc("date", 1), true, false) - )); - - ensureIndexes(template, CollectionName.REPLAY_LITE_REPLAYS, List.of( - IndexSpec.standard("uidx_replay_lite_objectKey", doc("objectKey", 1), true, false), - IndexSpec.standard("idx_replay_lite_expiresAt", doc("expiresAt", 1), false, true), - IndexSpec.standard( - "idx_replay_lite_server_status_confirmedAt", - doc("pluginServerUuid", 1).append("status", 1).append("confirmedAt", 1), - false, - false - ), - IndexSpec.standard( - "idx_replay_lite_server_status_createdAt", - doc("pluginServerUuid", 1).append("status", 1).append("createdAt", 1), - false, - false - ), - IndexSpec.standard("idx_replay_lite_status_createdAt", doc("status", 1).append("createdAt", 1).append("_id", 1), false, false), - IndexSpec.standard("idx_replay_lite_status_expiresAt", doc("status", 1).append("expiresAt", 1).append("_id", 1), false, false) - )); - - ensureIndexes(template, CollectionName.REPLAY_LITE_DAILY_QUOTAS, List.of( - IndexSpec.standard( - "uidx_replay_lite_daily_quotas_server_day", - doc("pluginServerUuid", 1).append("day", 1), - true, - false - ), - IndexSpec.ttl("idx_replay_lite_daily_quotas_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, "admin_users", List.of( - IndexSpec.standard("uidx_admin_users_email", doc("email", 1), true, false) - )); - - ensureIndexes(template, CollectionName.SYSTEM_ALERTS, List.of( - IndexSpec.standard("idx_system_alerts_expiresAt", doc("expiresAt", 1), false, false), - IndexSpec.standard("idx_system_alerts_createdAt", doc("createdAt", -1), false, false), - IndexSpec.standard("idx_system_alerts_audience_expiresAt", doc("audience", 1).append("expiresAt", 1), false, false) - )); - - ensureIndexes(template, CollectionName.SERVER_INSTANCE_SNAPSHOTS, List.of( - IndexSpec.standard("uidx_server_instance_snapshots_date", doc("date", 1), true, false) - )); - - ensureIndexes(template, CollectionName.EVIDENCE_UPLOAD_TOKENS, List.of( - IndexSpec.ttl("idx_evidence_upload_tokens_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, CollectionName.BETA_AUDIT, List.of( - IndexSpec.standard("idx_beta_audit_serverId_timestamp", doc("serverId", 1).append("timestamp", -1), false, false) - )); - } - - public void createTenantIndexes(MongoTemplate template) { - ensureIndexes(template, CollectionName.SETTINGS, List.of( - IndexSpec.standard("uidx_settings_type", doc("type", 1), true, false) - )); - - ensureIndexes(template, CollectionName.PLAYERS, List.of( - IndexSpec.partialUnique("uidx_players_minecraftUuid", doc("minecraftUuid", 1), - new Document("minecraftUuid", new Document("$type", "string"))), - IndexSpec.standard("idx_players_punishments_issued_desc", doc("punishments.issued", -1), false, false), - IndexSpec.standard( - "idx_players_punishments_issuerName_issued_desc", - doc("punishments.issuerName", 1).append("punishments.issued", -1), - false, - false - ), - IndexSpec.standard( - "idx_players_punishments_issuerId_issued_desc", - doc("punishments.issuerId", 1).append("punishments.issued", -1), - false, - true - ), - IndexSpec.standard("idx_players_ipAddresses_ipAddress", doc("ipAddresses.ipAddress", 1), false, false), - IndexSpec.standard("idx_players_usernames_username", doc("usernames.username", 1), false, false), - IndexSpec.collated("idx_players_usernames_username_ci", doc("usernames.username", 1), - Collation.of("en").strength(2)), - IndexSpec.standard("idx_players_punishments_id", doc("punishments.id", 1), false, true), - IndexSpec.standard("idx_players_data_isOnline", doc("data.isOnline", 1), false, true), - IndexSpec.standard("idx_players_ipAddresses_firstLogin", doc("ipAddresses.firstLogin", -1), false, false) - )); - - ensureIndexes(template, CollectionName.STAFF, List.of( - IndexSpec.standard("uidx_staff_email", doc("email", 1), true, false), - IndexSpec.standard("uidx_staff_username", doc("username", 1), true, false), - IndexSpec.standard("sidx_staff_assignedMinecraftUuid", doc("assignedMinecraftUuid", 1), false, true) - )); - - ensureIndexes(template, CollectionName.STAFF_ROLES, List.of( - IndexSpec.standard("uidx_staff_roles_name", doc("name", 1), true, false), - IndexSpec.standard("idx_staff_roles_order", doc("order", 1), false, false) - )); - - ensureIndexes(template, CollectionName.INVITATIONS, List.of( - IndexSpec.standard("idx_invitations_email", doc("email", 1), false, false), - IndexSpec.standard("uidx_invitations_token", doc("token", 1), true, false), - IndexSpec.ttl("idx_invitations_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, CollectionName.TICKET_VERIFICATIONS, List.of( - IndexSpec.ttl("idx_ticket_verifications_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, CollectionName.TICKETS, List.of( - IndexSpec.standard("idx_tickets_status_created", doc("status", 1).append("created", -1), false, false), - IndexSpec.standard("idx_tickets_created", doc("created", -1), false, false), - IndexSpec.standard("idx_tickets_updatedAt", doc("updatedAt", -1), false, false), - IndexSpec.standard("idx_tickets_type_created", doc("type", 1).append("created", -1), false, false), - IndexSpec.standard("idx_tickets_creatorUuid_created", doc("creatorUuid", 1).append("created", -1), false, false), - IndexSpec.standard("idx_tickets_reportedPlayerUuid_created", doc("reportedPlayerUuid", 1).append("created", -1), false, false), - IndexSpec.standard("idx_tickets_locked_created", doc("locked", 1).append("created", -1), false, false), - IndexSpec.standard("idx_tickets_assignedTo_updatedAt", doc("assignedTo", 1).append("updatedAt", -1), false, false), - IndexSpec.standard("idx_tickets_creatorName_created", doc("creatorName", 1).append("created", -1), false, false), - IndexSpec.standard("idx_tickets_replies_name_created", doc("replies.name", 1).append("replies.created", -1), false, false), - IndexSpec.standard("idx_tickets_tags", doc("tags", 1), false, false), - IndexSpec.standard("idx_tickets_replayUrl", doc("replayUrl", 1), false, true), - IndexSpec.standard("idx_tickets_replayId", doc("replayId", 1), false, true) - )); - - ensureIndexes(template, CollectionName.REPLAYS, List.of( - IndexSpec.standard("idx_replays_targetUuid_createdAt", doc("targetUuid", 1).append("createdAt", -1), false, true), - IndexSpec.standard("idx_replays_status_createdAt", doc("status", 1).append("createdAt", 1).append("_id", 1), false, false), - IndexSpec.standard("idx_replays_storageKey", doc("storageKey", 1), false, false) - )); - - ensureIndexes(template, CollectionName.STORAGE_FILES, List.of( - IndexSpec.standard("uidx_storage_files_key", doc("key", 1), true, false), - IndexSpec.standard("idx_storage_files_key_createdAt", doc("key", 1).append("createdAt", -1), false, false) - )); - - ensureIndexes(template, CollectionName.KNOWLEDGEBASE_CATEGORIES, List.of( - IndexSpec.standard("uidx_knowledgebase_categories_slug", doc("slug", 1), true, false), - IndexSpec.standard("idx_knowledgebase_categories_name", doc("name", 1), false, false), - IndexSpec.standard("idx_knowledgebase_categories_ordinal", doc("ordinal", 1), false, false), - IndexSpec.standard("idx_knowledgebase_categories_isVisible_ordinal", doc("isVisible", 1).append("ordinal", 1), false, false) - )); - - ensureIndexes(template, CollectionName.KNOWLEDGEBASE_ARTICLES, List.of( - IndexSpec.standard("uidx_knowledgebase_articles_slug", doc("slug", 1), true, false), - IndexSpec.standard("idx_knowledgebase_articles_categoryId_ordinal", doc("categoryId", 1).append("ordinal", 1), false, false), - IndexSpec.standard("idx_knowledgebase_articles_isVisible_categoryId_ordinal", doc("isVisible", 1).append("categoryId", 1).append("ordinal", 1), - false, false) - )); - - ensureIndexes(template, CollectionName.WEBAUTHN_CREDENTIALS, List.of( - IndexSpec.standard("idx_webauthn_credentials_email", doc("email", 1), false, false), - IndexSpec.standard("uidx_webauthn_credentials_credentialId", doc("credentialId", 1), true, false), - IndexSpec.standard("idx_webauthn_credentials_userHandle", doc("userHandle", 1), false, false) - )); - - ensureIndexes(template, CollectionName.WEBAUTHN_CHALLENGES, List.of( - IndexSpec.ttl("idx_webauthn_challenges_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, CollectionName.HOMEPAGE_CARDS, List.of( - IndexSpec.standard("idx_homepage_cards_ordinal", doc("ordinal", 1), false, false), - IndexSpec.standard("idx_homepage_cards_isEnabled_ordinal", doc("isEnabled", 1).append("ordinal", 1), false, false), - IndexSpec.standard("idx_homepage_cards_categoryId", doc("categoryId", 1), false, true) - )); - - ensureIndexes(template, CollectionName.SESSIONS, List.of( - IndexSpec.standard("idx_sessions_email", doc("email", 1), false, false), - IndexSpec.ttl("idx_sessions_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, CollectionName.AUTH_CODES, List.of( - IndexSpec.ttl("idx_auth_codes_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, CollectionName.SYSTEM_LOGS, List.of( - IndexSpec.standard("idx_system_logs_timestamp", doc("timestamp", -1), false, false), - IndexSpec.standard("idx_system_logs_level_timestamp", doc("level", 1).append("timestamp", -1), false, false), - IndexSpec.standard("idx_system_logs_source_timestamp", doc("source", 1).append("timestamp", -1), false, false) - )); - - ensureIndexes(template, CollectionName.SECURITY_EVENTS, List.of( - IndexSpec.standard("idx_security_events_timestamp", doc("timestamp", -1), false, false), - IndexSpec.standard("idx_security_events_severity_timestamp", doc("severity", 1).append("timestamp", -1), false, false) - )); - - ensureIndexes(template, CollectionName.CHAT_LOGS, List.of( - IndexSpec.standard("idx_chat_logs_uuid_timestamp", doc("uuid", 1).append("timestamp", -1), false, false) - )); - - ensureIndexes(template, CollectionName.COMMAND_LOGS, List.of( - IndexSpec.standard("idx_command_logs_uuid_timestamp", doc("uuid", 1).append("timestamp", -1), false, false) - )); - - ensureIndexes(template, CollectionName.LOGS, List.of( - IndexSpec.standard("idx_logs_created_desc", doc("created", -1), false, false) - )); - - ensureIndexes(template, CollectionName.MIGRATIONS, List.of( - IndexSpec.standard("idx_migrations_status_startedAt", doc("status", 1).append("startedAt", -1), false, false) - )); - } - - private void dropSupersededIndexes(MongoTemplate template, String collectionName, List legacyIndexNames) { - IndexOperations indexOps = template.indexOps(collectionName); - List existingNames = indexOps.getIndexInfo().stream().map(IndexInfo::getName).toList(); - for (String legacyName : legacyIndexNames) { - if (!existingNames.contains(legacyName)) { - continue; - } - try { - indexOps.dropIndex(legacyName); - log.info("Dropped superseded index name={} on collection={}", legacyName, collectionName); - } catch (Exception e) { - log.warn("Failed to drop superseded index name={} on collection={}", legacyName, collectionName, e); - } - } - } - - private void ensureIndexes(MongoTemplate template, String collectionName, List specs) { - IndexOperations indexOps = template.indexOps(collectionName); - List existingIndexes = indexOps.getIndexInfo(); - for (IndexSpec spec : specs) { - if (hasEquivalentIndex(existingIndexes, spec)) { - continue; - } - try { - boolean nameCollision = existingIndexes.stream() - .anyMatch(i -> spec.name().equals(i.getName())); - if (nameCollision) { - log.warn("Index spec drift on collection={} index={}: existing definition differs" - + " from source spec; dropping and recreating", collectionName, spec.name()); - try { - indexOps.dropIndex(spec.name()); - } catch (Exception e) { - log.error("Failed to drop conflicting index name={} on collection={};" - + " new spec NOT applied", spec.name(), collectionName, e); - continue; - } - } - createIndex(indexOps, spec); - } catch (Exception e) { - log.error("Failed to create index name={} on collection={}; continuing", - spec.name(), collectionName, e); - } - } - } - - private void createIndex(IndexOperations indexOps, IndexSpec spec) { - Index index = new Index().named(spec.name()); - - for (Map.Entry entry : spec.keys().entrySet()) { - Sort.Direction direction = directionFrom(entry.getValue()); - index.on(entry.getKey(), direction); - } - - if (spec.unique()) { - index.unique(); - } - if (spec.sparse()) { - index.sparse(); - } - if (spec.ttlSeconds() != null) { - index.expire(Duration.ofSeconds(spec.ttlSeconds())); - } - if (spec.partialFilter() != null) { - index.partial(PartialIndexFilter.of(spec.partialFilter())); - } - if (spec.collation() != null) { - index.collation(spec.collation()); - } - - indexOps.createIndex(index); - } - - private Sort.Direction directionFrom(Object value) { - if (value instanceof Number number) { - return number.intValue() < 0 ? Sort.Direction.DESC : Sort.Direction.ASC; - } - throw new ValidationException("Unsupported index direction value: " + value); - } - - private boolean hasEquivalentIndex(List existingIndexes, IndexSpec spec) { - List expectedFields = fieldsFor(spec.keys()); - for (IndexInfo existingIndex : existingIndexes) { - if (!existingIndex.getIndexFields().equals(expectedFields)) { - continue; - } - if (existingIndex.isUnique() != spec.unique()) { - continue; - } - if (existingIndex.isSparse() != spec.sparse()) { - continue; - } - - long existingTtlSeconds = existingIndex.getExpireAfter().map(Duration::getSeconds).orElse(-1L); - long expectedTtlSeconds = spec.ttlSeconds() == null ? -1L : spec.ttlSeconds(); - if (existingTtlSeconds != expectedTtlSeconds) { - continue; - } - - if (!hasEquivalentCollation(existingIndex, spec)) { - continue; - } - - String existingPartialJson = existingIndex.getPartialFilterExpression(); - Document specPartial = spec.partialFilter(); - if (existingPartialJson == null && specPartial == null) { - return true; - } - if (existingPartialJson == null || specPartial == null) { - continue; - } - if (!canonicalPartialFilter(Document.parse(existingPartialJson)) - .equals(canonicalPartialFilter(specPartial))) { - continue; - } - - return true; - } - return false; - } - - private boolean hasEquivalentCollation(IndexInfo existingIndex, IndexSpec spec) { - Document existingCollation = existingIndex.getCollation().orElse(null); - if (spec.collation() == null) { - return existingCollation == null; - } - if (existingCollation == null) { - return false; - } - Document specCollation = spec.collation().toDocument(); - for (Map.Entry entry : specCollation.entrySet()) { - if (!collationValueEquals(entry.getValue(), existingCollation.get(entry.getKey()))) { - return false; - } - } - return true; - } - - private boolean collationValueEquals(Object specValue, Object existingValue) { - if (specValue instanceof Number specNumber && existingValue instanceof Number existingNumber) { - return specNumber.doubleValue() == existingNumber.doubleValue(); - } - return Objects.equals(specValue, existingValue); - } - - private Document canonicalPartialFilter(Document filter) { - Document canonical = new Document(); - for (Map.Entry entry : filter.entrySet()) { - canonical.put(entry.getKey(), canonicalFilterValue(entry.getKey(), entry.getValue())); - } - return canonical; - } - - private Object canonicalFilterValue(String key, Object value) { - if (TYPE_OPERATOR.equals(key)) { - return canonicalBsonType(value); - } - if (value instanceof Document nested) { - return canonicalPartialFilter(nested); - } - if (value instanceof List elements) { - List canonical = new ArrayList<>(elements.size()); - for (Object element : elements) { - canonical.add(element instanceof Document nested ? canonicalPartialFilter(nested) : element); - } - return canonical; - } - return value; - } - - private Object canonicalBsonType(Object value) { - if (value instanceof Number number) { - return number.intValue(); - } - if (value instanceof String alias) { - Integer code = BSON_TYPE_CODES.get(alias); - return code != null ? code : alias; - } - if (value instanceof List aliases) { - List canonical = new ArrayList<>(aliases.size()); - for (Object alias : aliases) { - canonical.add(canonicalBsonType(alias)); - } - return canonical; - } - return value; - } - - private List fieldsFor(Document keys) { - List fields = new ArrayList<>(keys.size()); - for (Map.Entry entry : keys.entrySet()) { - fields.add(IndexField.create(entry.getKey(), directionFrom(entry.getValue()))); - } - return fields; - } - - private Document doc(String field, int direction) { - return new Document(field, direction); - } - - record BootstrapTarget(@Id String id, String databaseName) { - } - - private record IndexSpec( - String name, - Document keys, - boolean unique, - boolean sparse, - Long ttlSeconds, - Document partialFilter, - Collation collation - ) { - static IndexSpec standard(String name, Document keys, boolean unique, boolean sparse) { - return new IndexSpec(name, keys, unique, sparse, null, null, null); - } - - static IndexSpec ttl(String name, Document keys, long ttlSeconds) { - return new IndexSpec(name, keys, false, false, ttlSeconds, null, null); - } - - static IndexSpec partialUnique(String name, Document keys, Document partialFilter) { - return new IndexSpec(name, keys, true, false, null, partialFilter, null); - } - - static IndexSpec collated(String name, Document keys, Collation collation) { - return new IndexSpec(name, keys, false, false, null, null, collation); - } - } -} +package gg.modl.backend.database; + +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.AdminUserFields; +import gg.modl.backend.database.mongo.fields.AuthCodeFields; +import gg.modl.backend.database.mongo.fields.AuthSessionDataFields; +import gg.modl.backend.database.mongo.fields.BetaAuditFields; +import gg.modl.backend.database.mongo.fields.ChatLogFields; +import gg.modl.backend.database.mongo.fields.CommandLogFields; +import gg.modl.backend.database.mongo.fields.EvidenceUploadTokenDocumentFields; +import gg.modl.backend.database.mongo.fields.HomepageCardFields; +import gg.modl.backend.database.mongo.fields.InvitationFields; +import gg.modl.backend.database.mongo.fields.KnowledgebaseArticleFields; +import gg.modl.backend.database.mongo.fields.KnowledgebaseCategoryFields; +import gg.modl.backend.database.mongo.fields.MetricSnapshotFields; +import gg.modl.backend.database.mongo.fields.MigrationStatusFields; +import gg.modl.backend.database.mongo.fields.PlayerFields; +import gg.modl.backend.database.mongo.fields.ReplayDocumentFields; +import gg.modl.backend.database.mongo.fields.ReplayLiteDailyQuotaDocumentFields; +import gg.modl.backend.database.mongo.fields.ReplayLiteDocumentFields; +import gg.modl.backend.database.mongo.fields.SecurityEventFields; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.database.mongo.fields.ServerInstanceSnapshotFields; +import gg.modl.backend.database.mongo.fields.ServerLogFields; +import gg.modl.backend.database.mongo.fields.SettingsFields; +import gg.modl.backend.database.mongo.fields.StaffFields; +import gg.modl.backend.database.mongo.fields.StaffRoleFields; +import gg.modl.backend.database.mongo.fields.StorageFileDocumentFields; +import gg.modl.backend.database.mongo.fields.SystemAlertFields; +import gg.modl.backend.database.mongo.fields.SystemLogFields; +import gg.modl.backend.database.mongo.fields.TicketFields; +import gg.modl.backend.database.mongo.fields.TicketVerificationFields; +import gg.modl.backend.database.mongo.fields.TrainingSegmentDocumentFields; +import gg.modl.backend.database.mongo.fields.WebAuthnChallengeFields; +import gg.modl.backend.database.mongo.fields.WebAuthnCredentialFields; +import gg.modl.backend.infrastructure.exception.ValidationException; +import jakarta.annotation.PostConstruct; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.index.IndexField; +import org.springframework.data.mongodb.core.index.IndexInfo; +import org.springframework.data.mongodb.core.index.IndexOperations; +import org.springframework.data.mongodb.core.index.PartialIndexFilter; +import org.springframework.context.annotation.DependsOn; +import org.springframework.data.mongodb.core.query.Collation; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +@DependsOn("customDomainOverrideDedupeMigration") +public class MongoIndexReconciler { + private static final String TYPE_OPERATOR = "$type"; + private static final Map BSON_TYPE_CODES = Map.ofEntries( + Map.entry("double", 1), + Map.entry("string", 2), + Map.entry("object", 3), + Map.entry("array", 4), + Map.entry("binData", 5), + Map.entry("objectId", 7), + Map.entry("bool", 8), + Map.entry("date", 9), + Map.entry("null", 10), + Map.entry("regex", 11), + Map.entry("javascript", 13), + Map.entry("int", 16), + Map.entry("timestamp", 17), + Map.entry("long", 18), + Map.entry("decimal", 19), + Map.entry("minKey", -1), + Map.entry("maxKey", 127) + ); + + private final TenantMongoAccess tenantMongoAccess; + + @PostConstruct + public void initGlobalIndexes() { + try { + createGlobalIndexes(tenantMongoAccess.global()); + } catch (Exception e) { + log.error("Failed to create global database indexes", e); + } + try { + createTrainingDataIndexes(tenantMongoAccess.forDatabase(CollectionName.TRAINING_DATABASE)); + } catch (Exception e) { + log.error("Failed to create training data indexes", e); + } + } + + private void createTrainingDataIndexes(MongoTemplate template) { + ensureIndexes(template, CollectionName.TRAINING_SEGMENTS, List.of( + IndexSpec.standard( + "idx_training_segments_serverDatabaseName_replayId", + doc(TrainingSegmentDocumentFields.SERVER_DATABASE_NAME, 1).append(TrainingSegmentDocumentFields.REPLAY_ID, 1), + false, + false + ) + )); + } + + private void createGlobalIndexes(MongoTemplate template) { + ensureIndexes(template, CollectionName.MODL_SERVERS, List.of( + IndexSpec.standard("uidx_servers_serverName", doc(ServerFields.SERVER_NAME, 1), true, false), + IndexSpec.standard("uidx_servers_customDomain", doc(ServerFields.CUSTOM_DOMAIN, 1), true, false), + IndexSpec.standard("uidx_servers_adminEmail", doc(ServerFields.ADMIN_EMAIL, 1), true, false), + IndexSpec.standard("idx_servers_emailVerified", doc(ServerFields.EMAIL_VERIFIED, 1), false, false), + IndexSpec.standard("uidx_servers_emailVerificationToken", doc(ServerFields.EMAIL_VERIFICATION_TOKEN, 1), true, true), + IndexSpec.standard("idx_servers_provisioningStatus", doc(ServerFields.PROVISIONING_STATUS, 1), false, false), + IndexSpec.standard( + "idx_servers_registration_cleanup", + doc(ServerFields.EMAIL_VERIFIED, 1) + .append(ServerFields.PROVISIONING_STATUS, 1) + .append(ServerFields.CREATED_AT, 1) + .append(ServerFields.EMAIL_VERIFICATION_TOKEN, 1), + false, + true + ), + IndexSpec.standard("uidx_servers_provisioningSignInToken", doc(ServerFields.PROVISIONING_SIGN_IN_TOKEN, 1), true, true), + IndexSpec.standard("uidx_servers_stripeCustomerId", doc(ServerFields.STRIPE_CUSTOMER_ID, 1), true, true), + IndexSpec.standard("uidx_servers_stripeSubscriptionId", doc(ServerFields.STRIPE_SUBSCRIPTION_ID, 1), true, true), + IndexSpec.partialUnique("uidx_servers_customDomainOverride", doc(ServerFields.CUSTOM_DOMAIN_OVERRIDE, 1), + new Document(ServerFields.CUSTOM_DOMAIN_OVERRIDE, new Document(TYPE_OPERATOR, "string"))), + IndexSpec.standard("uidx_servers_customDomainCloudflareId", doc(ServerFields.CUSTOM_DOMAIN_CLOUDFLARE_ID, 1), true, true), + IndexSpec.standard("uidx_servers_cliSetupToken", doc(ServerFields.CLI_SETUP_TOKEN, 1), true, true), + IndexSpec.standard("uidx_servers_apiKey", doc(ServerFields.API_KEY, 1), true, true), + IndexSpec.standard("idx_servers_userCount", doc(ServerFields.USER_COUNT, 1), false, false), + IndexSpec.standard("idx_servers_ticketCount", doc(ServerFields.TICKET_COUNT, 1), false, false), + IndexSpec.standard("idx_servers_lastStatsUpdatedAt", doc(ServerFields.LAST_STATS_UPDATED_AT, 1), false, false), + IndexSpec.standard("idx_servers_createdAt", doc(ServerFields.CREATED_AT, 1), false, false), + IndexSpec.standard("idx_servers_lastActivityAt", doc(ServerFields.LAST_ACTIVITY_AT, -1), false, true) + )); + + dropSupersededIndexes(template, CollectionName.METRIC_SNAPSHOTS, List.of("idx_metric_snapshots_date")); + ensureIndexes(template, CollectionName.METRIC_SNAPSHOTS, List.of( + IndexSpec.standard("uidx_metric_snapshots_date", doc(MetricSnapshotFields.DATE, 1), true, false) + )); + + ensureIndexes(template, CollectionName.REPLAY_LITE_REPLAYS, List.of( + IndexSpec.standard("uidx_replay_lite_objectKey", doc(ReplayLiteDocumentFields.OBJECT_KEY, 1), true, false), + IndexSpec.standard("idx_replay_lite_expiresAt", doc(ReplayLiteDocumentFields.EXPIRES_AT, 1), false, true), + IndexSpec.standard( + "idx_replay_lite_server_status_confirmedAt", + doc(ReplayLiteDocumentFields.PLUGIN_SERVER_UUID, 1) + .append(ReplayLiteDocumentFields.STATUS, 1) + .append(ReplayLiteDocumentFields.CONFIRMED_AT, 1), + false, + false + ), + IndexSpec.standard( + "idx_replay_lite_server_status_createdAt", + doc(ReplayLiteDocumentFields.PLUGIN_SERVER_UUID, 1) + .append(ReplayLiteDocumentFields.STATUS, 1) + .append(ReplayLiteDocumentFields.CREATED_AT, 1), + false, + false + ), + IndexSpec.standard("idx_replay_lite_status_createdAt", + doc(ReplayLiteDocumentFields.STATUS, 1).append(ReplayLiteDocumentFields.CREATED_AT, 1).append(ReplayLiteDocumentFields.ID, 1), + false, false), + IndexSpec.standard("idx_replay_lite_status_expiresAt", + doc(ReplayLiteDocumentFields.STATUS, 1).append(ReplayLiteDocumentFields.EXPIRES_AT, 1).append(ReplayLiteDocumentFields.ID, 1), + false, false) + )); + + ensureIndexes(template, CollectionName.REPLAY_LITE_DAILY_QUOTAS, List.of( + IndexSpec.standard( + "uidx_replay_lite_daily_quotas_server_day", + doc(ReplayLiteDailyQuotaDocumentFields.PLUGIN_SERVER_UUID, 1).append(ReplayLiteDailyQuotaDocumentFields.DAY, 1), + true, + false + ), + IndexSpec.ttl("idx_replay_lite_daily_quotas_expiresAt_ttl", doc(ReplayLiteDailyQuotaDocumentFields.EXPIRES_AT, 1), 0) + )); + + ensureIndexes(template, CollectionName.ADMIN_USERS, List.of( + IndexSpec.standard("uidx_admin_users_email", doc(AdminUserFields.EMAIL, 1), true, false) + )); + + ensureIndexes(template, CollectionName.SYSTEM_ALERTS, List.of( + IndexSpec.standard("idx_system_alerts_expiresAt", doc(SystemAlertFields.EXPIRES_AT, 1), false, false), + IndexSpec.standard("idx_system_alerts_createdAt", doc(SystemAlertFields.CREATED_AT, -1), false, false), + IndexSpec.standard("idx_system_alerts_audience_expiresAt", doc(SystemAlertFields.AUDIENCE, 1).append(SystemAlertFields.EXPIRES_AT, 1), false, false) + )); + + ensureIndexes(template, CollectionName.SERVER_INSTANCE_SNAPSHOTS, List.of( + IndexSpec.standard("uidx_server_instance_snapshots_date", doc(ServerInstanceSnapshotFields.DATE, 1), true, false) + )); + + ensureIndexes(template, CollectionName.EVIDENCE_UPLOAD_TOKENS, List.of( + IndexSpec.ttl("idx_evidence_upload_tokens_expiresAt_ttl", doc(EvidenceUploadTokenDocumentFields.EXPIRES_AT, 1), 0) + )); + + ensureIndexes(template, CollectionName.BETA_AUDIT, List.of( + IndexSpec.standard("idx_beta_audit_serverId_timestamp", doc(BetaAuditFields.SERVER_ID, 1).append(BetaAuditFields.TIMESTAMP, -1), false, false) + )); + } + + public void createTenantIndexes(MongoTemplate template) { + ensureIndexes(template, CollectionName.SETTINGS, List.of( + IndexSpec.standard("uidx_settings_type", doc(SettingsFields.TYPE, 1), true, false) + )); + + ensureIndexes(template, CollectionName.PLAYERS, List.of( + IndexSpec.partialUnique("uidx_players_minecraftUuid", doc(PlayerFields.MINECRAFT_UUID, 1), + new Document(PlayerFields.MINECRAFT_UUID, new Document(TYPE_OPERATOR, "string"))), + IndexSpec.standard("idx_players_punishments_issued_desc", doc(PlayerFields.PUNISHMENT_ISSUED, -1), false, false), + IndexSpec.standard( + "idx_players_punishments_issuerName_issued_desc", + doc(PlayerFields.PUNISHMENT_ISSUER_NAME, 1).append(PlayerFields.PUNISHMENT_ISSUED, -1), + false, + false + ), + IndexSpec.standard( + "idx_players_punishments_issuerId_issued_desc", + doc(PlayerFields.PUNISHMENT_ISSUER_ID, 1).append(PlayerFields.PUNISHMENT_ISSUED, -1), + false, + true + ), + IndexSpec.standard("idx_players_ipAddresses_ipAddress", doc(PlayerFields.IP_ADDRESS, 1), false, false), + IndexSpec.standard("idx_players_usernames_username", doc(PlayerFields.USERNAME, 1), false, false), + IndexSpec.collated("idx_players_usernames_username_ci", doc(PlayerFields.USERNAME, 1), + Collation.of("en").strength(2)), + IndexSpec.standard("idx_players_punishments_id", doc(PlayerFields.PUNISHMENT_ID, 1), false, true), + IndexSpec.standard("idx_players_data_isOnline", doc(PlayerFields.DATA_IS_ONLINE, 1), false, true), + IndexSpec.standard("idx_players_ipAddresses_firstLogin", doc(PlayerFields.IP_FIRST_LOGIN, -1), false, false) + )); + + ensureIndexes(template, CollectionName.STAFF, List.of( + IndexSpec.standard("uidx_staff_email", doc(StaffFields.EMAIL, 1), true, false), + IndexSpec.standard("uidx_staff_username", doc(StaffFields.USERNAME, 1), true, false), + IndexSpec.standard("sidx_staff_assignedMinecraftUuid", doc(StaffFields.ASSIGNED_MINECRAFT_UUID, 1), false, true) + )); + + ensureIndexes(template, CollectionName.STAFF_ROLES, List.of( + IndexSpec.standard("uidx_staff_roles_name", doc(StaffRoleFields.NAME, 1), true, false), + IndexSpec.standard("idx_staff_roles_order", doc(StaffRoleFields.ORDER, 1), false, false) + )); + + ensureIndexes(template, CollectionName.INVITATIONS, List.of( + IndexSpec.standard("idx_invitations_email", doc(InvitationFields.EMAIL, 1), false, false), + IndexSpec.standard("uidx_invitations_token", doc(InvitationFields.TOKEN, 1), true, false), + IndexSpec.ttl("idx_invitations_expiresAt_ttl", doc(InvitationFields.EXPIRES_AT, 1), 0) + )); + + ensureIndexes(template, CollectionName.TICKET_VERIFICATIONS, List.of( + IndexSpec.ttl("idx_ticket_verifications_expiresAt_ttl", doc(TicketVerificationFields.EXPIRES_AT, 1), 0) + )); + + ensureIndexes(template, CollectionName.TICKETS, List.of( + IndexSpec.standard("idx_tickets_status_created", doc(TicketFields.STATUS, 1).append(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_created", doc(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_updatedAt", doc(TicketFields.UPDATED_AT, -1), false, false), + IndexSpec.standard("idx_tickets_type_created", doc(TicketFields.TYPE, 1).append(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_creatorUuid_created", doc(TicketFields.CREATOR_UUID, 1).append(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_reportedPlayerUuid_created", doc(TicketFields.REPORTED_PLAYER_UUID, 1).append(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_locked_created", doc(TicketFields.LOCKED, 1).append(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_assignedTo_updatedAt", doc(TicketFields.ASSIGNED_TO, 1).append(TicketFields.UPDATED_AT, -1), false, false), + IndexSpec.standard("idx_tickets_creatorName_created", doc(TicketFields.CREATOR_NAME, 1).append(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_replies_name_created", doc(TicketFields.REPLY_NAME, 1).append(TicketFields.REPLY_CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_tags", doc(TicketFields.TAGS, 1), false, false), + IndexSpec.standard("idx_tickets_replayUrl", doc(TicketFields.REPLAY_URL, 1), false, true), + IndexSpec.standard("idx_tickets_replayId", doc(TicketFields.REPLAY_ID, 1), false, true) + )); + + ensureIndexes(template, CollectionName.REPLAYS, List.of( + IndexSpec.standard("idx_replays_targetUuid_createdAt", doc(ReplayDocumentFields.TARGET_UUID, 1).append(ReplayDocumentFields.CREATED_AT, -1), false, true), + IndexSpec.standard("idx_replays_status_createdAt", + doc(ReplayDocumentFields.STATUS, 1).append(ReplayDocumentFields.CREATED_AT, 1).append(ReplayDocumentFields.ID, 1), false, false), + IndexSpec.standard("idx_replays_storageKey", doc(ReplayDocumentFields.STORAGE_KEY, 1), false, false) + )); + + ensureIndexes(template, CollectionName.STORAGE_FILES, List.of( + IndexSpec.standard("uidx_storage_files_key", doc(StorageFileDocumentFields.KEY, 1), true, false), + IndexSpec.standard("idx_storage_files_key_createdAt", doc(StorageFileDocumentFields.KEY, 1).append(StorageFileDocumentFields.CREATED_AT, -1), false, false) + )); + + ensureIndexes(template, CollectionName.KNOWLEDGEBASE_CATEGORIES, List.of( + IndexSpec.standard("uidx_knowledgebase_categories_slug", doc(KnowledgebaseCategoryFields.SLUG, 1), true, false), + IndexSpec.standard("idx_knowledgebase_categories_name", doc(KnowledgebaseCategoryFields.NAME, 1), false, false), + IndexSpec.standard("idx_knowledgebase_categories_ordinal", doc(KnowledgebaseCategoryFields.ORDINAL, 1), false, false), + IndexSpec.standard("idx_knowledgebase_categories_isVisible_ordinal", doc(KnowledgebaseCategoryFields.IS_VISIBLE, 1).append(KnowledgebaseCategoryFields.ORDINAL, 1), false, false) + )); + + ensureIndexes(template, CollectionName.KNOWLEDGEBASE_ARTICLES, List.of( + IndexSpec.standard("uidx_knowledgebase_articles_slug", doc(KnowledgebaseArticleFields.SLUG, 1), true, false), + IndexSpec.standard("idx_knowledgebase_articles_categoryId_ordinal", doc(KnowledgebaseArticleFields.CATEGORY_ID, 1).append(KnowledgebaseArticleFields.ORDINAL, 1), false, false), + IndexSpec.standard("idx_knowledgebase_articles_isVisible_categoryId_ordinal", + doc(KnowledgebaseArticleFields.IS_VISIBLE, 1).append(KnowledgebaseArticleFields.CATEGORY_ID, 1).append(KnowledgebaseArticleFields.ORDINAL, 1), + false, false) + )); + + ensureIndexes(template, CollectionName.WEBAUTHN_CREDENTIALS, List.of( + IndexSpec.standard("idx_webauthn_credentials_email", doc(WebAuthnCredentialFields.EMAIL, 1), false, false), + IndexSpec.standard("uidx_webauthn_credentials_credentialId", doc(WebAuthnCredentialFields.CREDENTIAL_ID, 1), true, false), + IndexSpec.standard("idx_webauthn_credentials_userHandle", doc(WebAuthnCredentialFields.USER_HANDLE, 1), false, false) + )); + + ensureIndexes(template, CollectionName.WEBAUTHN_CHALLENGES, List.of( + IndexSpec.ttl("idx_webauthn_challenges_expiresAt_ttl", doc(WebAuthnChallengeFields.EXPIRES_AT, 1), 0) + )); + + ensureIndexes(template, CollectionName.HOMEPAGE_CARDS, List.of( + IndexSpec.standard("idx_homepage_cards_ordinal", doc(HomepageCardFields.ORDINAL, 1), false, false), + IndexSpec.standard("idx_homepage_cards_isEnabled_ordinal", doc(HomepageCardFields.IS_ENABLED, 1).append(HomepageCardFields.ORDINAL, 1), false, false), + IndexSpec.standard("idx_homepage_cards_categoryId", doc(HomepageCardFields.CATEGORY_ID, 1), false, true) + )); + + ensureIndexes(template, CollectionName.SESSIONS, List.of( + IndexSpec.standard("idx_sessions_email", doc(AuthSessionDataFields.EMAIL, 1), false, false), + IndexSpec.ttl("idx_sessions_expiresAt_ttl", doc(AuthSessionDataFields.EXPIRES_AT, 1), 0) + )); + + ensureIndexes(template, CollectionName.AUTH_CODES, List.of( + IndexSpec.ttl("idx_auth_codes_expiresAt_ttl", doc(AuthCodeFields.EXPIRES_AT, 1), 0) + )); + + ensureIndexes(template, CollectionName.SYSTEM_LOGS, List.of( + IndexSpec.standard("idx_system_logs_timestamp", doc(SystemLogFields.TIMESTAMP, -1), false, false), + IndexSpec.standard("idx_system_logs_level_timestamp", doc(SystemLogFields.LEVEL, 1).append(SystemLogFields.TIMESTAMP, -1), false, false), + IndexSpec.standard("idx_system_logs_source_timestamp", doc(SystemLogFields.SOURCE, 1).append(SystemLogFields.TIMESTAMP, -1), false, false) + )); + + ensureIndexes(template, CollectionName.SECURITY_EVENTS, List.of( + IndexSpec.standard("idx_security_events_timestamp", doc(SecurityEventFields.TIMESTAMP, -1), false, false), + IndexSpec.standard("idx_security_events_severity_timestamp", doc(SecurityEventFields.SEVERITY, 1).append(SecurityEventFields.TIMESTAMP, -1), false, false) + )); + + ensureIndexes(template, CollectionName.CHAT_LOGS, List.of( + IndexSpec.standard("idx_chat_logs_uuid_timestamp", doc(ChatLogFields.UUID, 1).append(ChatLogFields.TIMESTAMP, -1), false, false) + )); + + ensureIndexes(template, CollectionName.COMMAND_LOGS, List.of( + IndexSpec.standard("idx_command_logs_uuid_timestamp", doc(CommandLogFields.UUID, 1).append(CommandLogFields.TIMESTAMP, -1), false, false) + )); + + ensureIndexes(template, CollectionName.LOGS, List.of( + IndexSpec.standard("idx_logs_created_desc", doc(ServerLogFields.CREATED, -1), false, false) + )); + + ensureIndexes(template, CollectionName.MIGRATIONS, List.of( + IndexSpec.standard("idx_migrations_status_startedAt", doc(MigrationStatusFields.STATUS, 1).append(MigrationStatusFields.STARTED_AT, -1), false, false) + )); + } + + private void dropSupersededIndexes(MongoTemplate template, String collectionName, List legacyIndexNames) { + IndexOperations indexOps = template.indexOps(collectionName); + List existingNames = indexOps.getIndexInfo().stream().map(IndexInfo::getName).toList(); + for (String legacyName : legacyIndexNames) { + if (!existingNames.contains(legacyName)) { + continue; + } + try { + indexOps.dropIndex(legacyName); + log.info("Dropped superseded index name={} on collection={}", legacyName, collectionName); + } catch (Exception e) { + log.warn("Failed to drop superseded index name={} on collection={}", legacyName, collectionName, e); + } + } + } + + private void ensureIndexes(MongoTemplate template, String collectionName, List specs) { + IndexOperations indexOps = template.indexOps(collectionName); + List existingIndexes = indexOps.getIndexInfo(); + for (IndexSpec spec : specs) { + if (hasEquivalentIndex(existingIndexes, spec)) { + continue; + } + try { + boolean nameCollision = existingIndexes.stream() + .anyMatch(i -> spec.name().equals(i.getName())); + if (nameCollision) { + log.warn("Index spec drift on collection={} index={}: existing definition differs" + + " from source spec; dropping and recreating", collectionName, spec.name()); + try { + indexOps.dropIndex(spec.name()); + } catch (Exception e) { + log.error("Failed to drop conflicting index name={} on collection={};" + + " new spec NOT applied", spec.name(), collectionName, e); + continue; + } + } + createIndex(indexOps, spec); + } catch (Exception e) { + log.error("Failed to create index name={} on collection={}; continuing", + spec.name(), collectionName, e); + } + } + } + + private void createIndex(IndexOperations indexOps, IndexSpec spec) { + Index index = new Index().named(spec.name()); + + for (Map.Entry entry : spec.keys().entrySet()) { + Sort.Direction direction = directionFrom(entry.getValue()); + index.on(entry.getKey(), direction); + } + + if (spec.unique()) { + index.unique(); + } + if (spec.sparse()) { + index.sparse(); + } + if (spec.ttlSeconds() != null) { + index.expire(Duration.ofSeconds(spec.ttlSeconds())); + } + if (spec.partialFilter() != null) { + index.partial(PartialIndexFilter.of(spec.partialFilter())); + } + if (spec.collation() != null) { + index.collation(spec.collation()); + } + + indexOps.createIndex(index); + } + + private Sort.Direction directionFrom(Object value) { + if (value instanceof Number number) { + return number.intValue() < 0 ? Sort.Direction.DESC : Sort.Direction.ASC; + } + throw new ValidationException("Unsupported index direction value: " + value); + } + + private boolean hasEquivalentIndex(List existingIndexes, IndexSpec spec) { + List expectedFields = fieldsFor(spec.keys()); + for (IndexInfo existingIndex : existingIndexes) { + if (!existingIndex.getIndexFields().equals(expectedFields)) { + continue; + } + if (existingIndex.isUnique() != spec.unique()) { + continue; + } + if (existingIndex.isSparse() != spec.sparse()) { + continue; + } + + long existingTtlSeconds = existingIndex.getExpireAfter().map(Duration::getSeconds).orElse(-1L); + long expectedTtlSeconds = spec.ttlSeconds() == null ? -1L : spec.ttlSeconds(); + if (existingTtlSeconds != expectedTtlSeconds) { + continue; + } + + if (!hasEquivalentCollation(existingIndex, spec)) { + continue; + } + + String existingPartialJson = existingIndex.getPartialFilterExpression(); + Document specPartial = spec.partialFilter(); + if (existingPartialJson == null && specPartial == null) { + return true; + } + if (existingPartialJson == null || specPartial == null) { + continue; + } + if (!canonicalPartialFilter(Document.parse(existingPartialJson)) + .equals(canonicalPartialFilter(specPartial))) { + continue; + } + + return true; + } + return false; + } + + private boolean hasEquivalentCollation(IndexInfo existingIndex, IndexSpec spec) { + Document existingCollation = existingIndex.getCollation().orElse(null); + if (spec.collation() == null) { + return existingCollation == null; + } + if (existingCollation == null) { + return false; + } + Document specCollation = spec.collation().toDocument(); + for (Map.Entry entry : specCollation.entrySet()) { + if (!collationValueEquals(entry.getValue(), existingCollation.get(entry.getKey()))) { + return false; + } + } + return true; + } + + private boolean collationValueEquals(Object specValue, Object existingValue) { + if (specValue instanceof Number specNumber && existingValue instanceof Number existingNumber) { + return specNumber.doubleValue() == existingNumber.doubleValue(); + } + return Objects.equals(specValue, existingValue); + } + + private Document canonicalPartialFilter(Document filter) { + Document canonical = new Document(); + for (Map.Entry entry : filter.entrySet()) { + canonical.put(entry.getKey(), canonicalFilterValue(entry.getKey(), entry.getValue())); + } + return canonical; + } + + private Object canonicalFilterValue(String key, Object value) { + if (TYPE_OPERATOR.equals(key)) { + return canonicalBsonType(value); + } + if (value instanceof Document nested) { + return canonicalPartialFilter(nested); + } + if (value instanceof List elements) { + List canonical = new ArrayList<>(elements.size()); + for (Object element : elements) { + canonical.add(element instanceof Document nested ? canonicalPartialFilter(nested) : element); + } + return canonical; + } + return value; + } + + private Object canonicalBsonType(Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String alias) { + Integer code = BSON_TYPE_CODES.get(alias); + return code != null ? code : alias; + } + if (value instanceof List aliases) { + List canonical = new ArrayList<>(aliases.size()); + for (Object alias : aliases) { + canonical.add(canonicalBsonType(alias)); + } + return canonical; + } + return value; + } + + private List fieldsFor(Document keys) { + List fields = new ArrayList<>(keys.size()); + for (Map.Entry entry : keys.entrySet()) { + fields.add(IndexField.create(entry.getKey(), directionFrom(entry.getValue()))); + } + return fields; + } + + private Document doc(String field, int direction) { + return new Document(field, direction); + } + + private record IndexSpec( + String name, + Document keys, + boolean unique, + boolean sparse, + Long ttlSeconds, + Document partialFilter, + Collation collation + ) { + static IndexSpec standard(String name, Document keys, boolean unique, boolean sparse) { + return new IndexSpec(name, keys, unique, sparse, null, null, null); + } + + static IndexSpec ttl(String name, Document keys, long ttlSeconds) { + return new IndexSpec(name, keys, false, false, ttlSeconds, null, null); + } + + static IndexSpec partialUnique(String name, Document keys, Document partialFilter) { + return new IndexSpec(name, keys, true, false, null, partialFilter, null); + } + + static IndexSpec collated(String name, Document keys, Collation collation) { + return new IndexSpec(name, keys, false, false, null, null, collation); + } + } +} diff --git a/src/main/java/gg/modl/backend/database/TenantMigrationService.java b/src/main/java/gg/modl/backend/database/TenantMigrationService.java index 62c73a5..bc2cb41 100644 --- a/src/main/java/gg/modl/backend/database/TenantMigrationService.java +++ b/src/main/java/gg/modl/backend/database/TenantMigrationService.java @@ -12,8 +12,10 @@ import com.mongodb.client.model.Updates; import com.mongodb.client.result.UpdateResult; import gg.modl.backend.database.mongo.fields.AuditLogFields; +import gg.modl.backend.database.mongo.fields.InvitationFields; import gg.modl.backend.database.mongo.fields.PlayerFields; import gg.modl.backend.database.mongo.fields.SettingsFields; +import gg.modl.backend.database.mongo.fields.StaffFields; import gg.modl.backend.database.mongo.fields.StaffRoleFields; import gg.modl.backend.database.mongo.fields.TicketFields; import gg.modl.backend.player.data.Player; @@ -53,7 +55,6 @@ public class TenantMigrationService { static final String NORMALIZE_PLAYER_IDS_MIGRATION_ID = "normalize-player-ids"; static final String BACKFILL_TICKET_REPLAY_ID_MIGRATION_ID = "backfill-ticket-replay-id"; private static final Pattern UPPERCASE_HEX_PATTERN = Pattern.compile("[A-F]"); - private static final String ROLE_FIELD = "role"; private static final String ID_FIELD = "_id"; private static final String LOG_METADATA_PLAYER_ID = AuditLogFields.METADATA + ".playerId"; private static final String STATUS_FIELD = "status"; @@ -224,12 +225,12 @@ private void backfillStaffRoleIds(MongoTemplate template) { name, template.getDb().getName()); continue; } - staffUpdated += staff.updateMany(Filters.eq(ROLE_FIELD, name), Updates.set(ROLE_FIELD, id)).getModifiedCount(); - invitationsUpdated += invitations.updateMany(Filters.eq(ROLE_FIELD, name), Updates.set(ROLE_FIELD, id)).getModifiedCount(); + staffUpdated += staff.updateMany(Filters.eq(StaffFields.ROLE_ID, name), Updates.set(StaffFields.ROLE_ID, id)).getModifiedCount(); + invitationsUpdated += invitations.updateMany(Filters.eq(InvitationFields.ROLE_ID, name), Updates.set(InvitationFields.ROLE_ID, id)).getModifiedCount(); } - long staffOrphans = staff.countDocuments(Filters.nin(ROLE_FIELD, roleIds)); - long invitationOrphans = invitations.countDocuments(Filters.nin(ROLE_FIELD, roleIds)); + long staffOrphans = staff.countDocuments(Filters.nin(StaffFields.ROLE_ID, roleIds)); + long invitationOrphans = invitations.countDocuments(Filters.nin(InvitationFields.ROLE_ID, roleIds)); if (staffOrphans > 0 || invitationOrphans > 0) { log.warn("Role-id backfill left unresolved role references in database={} staffOrphans={} invitationOrphans={}", template.getDb().getName(), staffOrphans, invitationOrphans); diff --git a/src/main/java/gg/modl/backend/database/TenantSchemaBootstrapper.java b/src/main/java/gg/modl/backend/database/TenantSchemaBootstrapper.java new file mode 100644 index 0000000..a252a17 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/TenantSchemaBootstrapper.java @@ -0,0 +1,110 @@ +package gg.modl.backend.database; + +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.infrastructure.scheduling.SchedulerLeaseService; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TenantSchemaBootstrapper { + private static final int BOOTSTRAP_PARALLELISM = 4; + private static final String TENANT_BOOTSTRAP_LEASE = "tenant-schema-bootstrap"; + private static final Duration TENANT_BOOTSTRAP_LEASE_TTL = Duration.ofMinutes(30); + + private final TenantMongoAccess tenantMongoAccess; + private final TenantMigrationService tenantMigrationService; + private final MongoIndexReconciler mongoIndexReconciler; + private final SchedulerLeaseService schedulerLeaseService; + + @EventListener(ApplicationReadyEvent.class) + public void bootstrapExistingTenants() { + if (!schedulerLeaseService.tryAcquire(TENANT_BOOTSTRAP_LEASE, TENANT_BOOTSTRAP_LEASE_TTL)) { + log.info("Skipping tenant schema bootstrap; lease held by another instance"); + return; + } + List targets; + try { + targets = loadBootstrapTargets(); + } catch (Exception e) { + log.error("Failed to list servers for tenant bootstrap", e); + return; + } + + if (targets.isEmpty()) { + log.info("Bootstrapping schema for 0 existing tenants"); + return; + } + + dispatchTenantBootstrap(targets); + } + + private List loadBootstrapTargets() { + Query query = new Query(); + query.fields().include(ServerFields.ID).include(ServerFields.DATABASE_NAME); + return tenantMongoAccess.global() + .find(query, BootstrapTarget.class, CollectionName.MODL_SERVERS) + .stream() + .filter(target -> target.databaseName() != null && !target.databaseName().isBlank()) + .toList(); + } + + private void dispatchTenantBootstrap(List targets) { + log.info("Bootstrapping schema for {} existing tenants", targets.size()); + int parallelism = Math.min(BOOTSTRAP_PARALLELISM, targets.size()); + ExecutorService executor = Executors.newFixedThreadPool(parallelism, bootstrapThreadFactory()); + AtomicInteger succeeded = new AtomicInteger(); + AtomicInteger failed = new AtomicInteger(); + + CompletableFuture[] tasks = targets.stream() + .map(target -> CompletableFuture.runAsync(() -> bootstrapTenant(target, succeeded, failed), executor)) + .toArray(CompletableFuture[]::new); + + CompletableFuture.allOf(tasks).whenComplete((ignored, throwable) -> { + log.info("Tenant schema bootstrap complete succeeded={} failed={}", succeeded.get(), failed.get()); + executor.shutdown(); + }); + } + + private void bootstrapTenant(BootstrapTarget target, AtomicInteger succeeded, AtomicInteger failed) { + try { + log.debug("Bootstrapping schema for server id={} database={}", + target.id(), target.databaseName()); + MongoTemplate template = tenantMongoAccess.forDatabase(target.databaseName()); + tenantMigrationService.applyMigrationsForTenant(template); + mongoIndexReconciler.createTenantIndexes(template); + succeeded.incrementAndGet(); + } catch (Exception e) { + failed.incrementAndGet(); + log.warn("Failed to bootstrap schema for server id={} database={}", + target.id(), target.databaseName(), e); + } + } + + private ThreadFactory bootstrapThreadFactory() { + AtomicInteger threadNumber = new AtomicInteger(); + return runnable -> { + Thread thread = new Thread(runnable, "tenant-bootstrap-" + threadNumber.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } + + record BootstrapTarget(@Id String id, String databaseName) { + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/AbstractServerMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/AbstractServerMongoRepository.java index 95408b2..579addc 100644 --- a/src/main/java/gg/modl/backend/database/mongo/AbstractServerMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/AbstractServerMongoRepository.java @@ -6,10 +6,13 @@ import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.function.BiFunction; +import org.springframework.data.mongodb.core.BulkOperations; import org.springframework.data.mongodb.core.FindAndModifyOptions; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; @@ -53,6 +56,10 @@ public T saveEntity(Server server, T entity) { return save(tenantMongoAccess.forServer(server), entity); } + public T insertEntity(Server server, T entity) { + return insert(tenantMongoAccess.forServer(server), entity); + } + public void insertAll(Server server, Collection entities) { insertAll(tenantMongoAccess.forServer(server), entities); } @@ -85,6 +92,27 @@ public AggregationResults aggregate(Server server, Aggregation aggregatio return aggregate(tenantMongoAccess.forServer(server), aggregation, outputType); } + protected void bulkUpdateByIds(Server server, List ids, BiFunction updateFactory) { + bulkUpdateByIds(server, ids, (id, index) -> Criteria.where("_id").is(id), updateFactory); + } + + protected void bulkUpdateByIds( + Server server, + List ids, + BiFunction criteriaFactory, + BiFunction updateFactory + ) { + if (ids.isEmpty()) { + return; + } + BulkOperations bulk = serverTemplate(server).bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); + for (int index = 0; index < ids.size(); index++) { + String id = ids.get(index); + bulk.updateOne(Query.query(criteriaFactory.apply(id, index)), updateFactory.apply(id, index)); + } + bulk.execute(); + } + protected MongoTemplate serverTemplate(Server server) { return tenantMongoAccess.forServer(server); } diff --git a/src/main/java/gg/modl/backend/database/mongo/AbstractTenantMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/AbstractTenantMongoRepository.java index b12109b..f503169 100644 --- a/src/main/java/gg/modl/backend/database/mongo/AbstractTenantMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/AbstractTenantMongoRepository.java @@ -94,10 +94,6 @@ protected AggregationResults aggregate(MongoTemplate template, Aggregatio return template.aggregate(aggregation, collectionName, outputType); } - protected MongoTemplate rawTemplate(MongoTemplate template) { - return template; - } - protected Class entityType() { return entityType; } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/AdminDatabaseBrowserRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/AdminDatabaseBrowserRepository.java new file mode 100644 index 0000000..2950de7 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/AdminDatabaseBrowserRepository.java @@ -0,0 +1,28 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.server.data.Server; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.bson.Document; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class AdminDatabaseBrowserRepository { + private final TenantMongoAccess tenantMongoAccess; + + public List readTable(Server server, String collectionName, int limit, int skip) { + Query query = new Query() + .with(Sort.by(Sort.Direction.DESC, "_id")) + .skip(skip) + .limit(limit); + return tenantMongoAccess.forServer(server).find(query, Document.class, collectionName); + } + + public long countCollection(Server server, String collectionName) { + return tenantMongoAccess.forServer(server).count(new Query(), collectionName); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/AdminUserMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/AdminUserMongoRepository.java index c352cdf..736aeac 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/AdminUserMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/AdminUserMongoRepository.java @@ -1,6 +1,7 @@ package gg.modl.backend.database.mongo.repository; import gg.modl.backend.admin.data.AdminUser; +import gg.modl.backend.database.CollectionName; import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; import gg.modl.backend.email.EmailAddressUtil; import gg.modl.backend.database.mongo.TenantMongoAccess; @@ -15,10 +16,9 @@ @Repository public class AdminUserMongoRepository extends AbstractGlobalMongoRepository { - private static final String COLLECTION_NAME = "admin_users"; public AdminUserMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(AdminUser.class, COLLECTION_NAME, tenantMongoAccess); + super(AdminUser.class, CollectionName.ADMIN_USERS, tenantMongoAccess); } public Optional findByEmailIgnoreCase(String email) { diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/AnalyticsMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/AnalyticsMongoRepository.java index 7df1e0d..bc88fc5 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/AnalyticsMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/AnalyticsMongoRepository.java @@ -7,6 +7,7 @@ import gg.modl.backend.database.mongo.fields.AuditLogFields; import gg.modl.backend.database.mongo.fields.PlayerFields; import gg.modl.backend.database.mongo.fields.TicketFields; +import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; import gg.modl.backend.server.data.Server; import gg.modl.backend.ticket.data.TicketStatus; import java.util.ArrayList; @@ -38,10 +39,15 @@ public class AnalyticsMongoRepository { private static final String ALIAS_N = "n"; private static final String ALIAS_COUNT = "count"; private static final String ALIAS_DATE = "date"; + private static final String ALIAS_AVG_MILLIS = "avgMillis"; private static final String EARLIEST_FIRST_LOGIN = "earliestFirstLogin"; private final TenantMongoAccess tenantMongoAccess; + private static Document matchDateTyped(String field) { + return new Document("$match", new Document(field, new Document("$type", "date"))); + } + @NotNull public OverviewStats loadOverviewStats(@NotNull Server server, @NotNull Date thirtyDaysAgo, @NotNull Date sixtyDaysAgo) { final MongoTemplate template = tenantMongoAccess.forServer(server); @@ -76,7 +82,29 @@ public OverviewStats loadOverviewStats(@NotNull Server server, @NotNull Date thi final long totalPlayers = template.getCollection(CollectionName.PLAYERS).countDocuments(); - return new OverviewStats(totalTickets, totalPlayers, activeTickets, recentTickets, previousTickets); + final List playerPipeline = List.of( + new Document("$addFields", new Document(EARLIEST_FIRST_LOGIN, new Document("$min", "$" + PlayerFields.IP_FIRST_LOGIN))), + matchDateTyped(EARLIEST_FIRST_LOGIN), + new Document("$facet", new Document() + .append(FACET_RECENT, List.of( + new Document("$match", new Document(EARLIEST_FIRST_LOGIN, new Document("$gte", thirtyDaysAgo))), + new Document("$count", ALIAS_N))) + .append(FACET_PREVIOUS, List.of( + new Document("$match", new Document(EARLIEST_FIRST_LOGIN, + new Document("$gte", sixtyDaysAgo).append("$lt", thirtyDaysAgo))), + new Document("$count", ALIAS_N)))) + ); + final List playerResult = template.getCollection(CollectionName.PLAYERS).aggregate(playerPipeline).into(new ArrayList<>()); + + long recentPlayers = 0, previousPlayers = 0; + if (!playerResult.isEmpty()) { + Document facets = playerResult.get(0); + recentPlayers = extractFacetCount(facets, FACET_RECENT); + previousPlayers = extractFacetCount(facets, FACET_PREVIOUS); + } + + return new OverviewStats(totalTickets, totalPlayers, activeTickets, recentTickets, previousTickets, + recentPlayers, previousPlayers); } public List aggregateTicketStatusCounts(Server server, Date startDate) { @@ -117,6 +145,8 @@ public List aggregateDailyTicketCounts(Server server, Date startD criteria = criteria.and(TicketFields.CREATED).gte(startDate); } + final AggregationOperation createdDateTypeMatch = context -> matchDateTyped(TicketFields.CREATED); + final AggregationOperation dayProjection = context -> new Document("$project", new Document(ALIAS_DATE, new Document("$dateToString", new Document("format", "%Y-%m-%d") @@ -125,6 +155,7 @@ public List aggregateDailyTicketCounts(Server server, Date startD final Aggregation aggregation = Aggregation.newAggregation( Aggregation.match(criteria), + createdDateTypeMatch, dayProjection, Aggregation.group(ALIAS_DATE).count().as(ALIAS_COUNT), Aggregation.sort(Sort.Direction.ASC, "_id") @@ -135,14 +166,37 @@ public List aggregateDailyTicketCounts(Server server, Date startD .getMappedResults(); } - public Document aggregatePunishmentAnalytics(Server server, Date startDate, String analyticsTimeZone) { + public List aggregateAvgResolutionByCategory(Server server, Date startDate) { + final List pipeline = new ArrayList<>(); + final Document match = new Document(TicketFields.STATUS, TicketStatus.CLOSED.getId()); + if (startDate != null) { + match.append(TicketFields.UPDATED_AT, new Document("$gte", startDate)); + } + pipeline.add(new Document("$match", match)); + pipeline.add(matchDateTyped(TicketFields.CREATED)); + pipeline.add(matchDateTyped(TicketFields.UPDATED_AT)); + pipeline.add(new Document("$group", new Document("_id", "$" + TicketFields.TYPE) + .append(ALIAS_AVG_MILLIS, new Document("$avg", + new Document("$subtract", List.of("$" + TicketFields.UPDATED_AT, "$" + TicketFields.CREATED)))))); + + final List results = tenantMongoAccess.forServer(server) + .getCollection(CollectionName.TICKETS) + .aggregate(pipeline) + .into(new ArrayList<>()); + + return results.stream() + .map(doc -> new CategoryAvgResolution(doc.getString("_id"), doubleValueOrZero(doc.get(ALIAS_AVG_MILLIS)))) + .toList(); + } + + public PunishmentAnalyticsFacet aggregatePunishmentAnalytics(Server server, Date startDate, String analyticsTimeZone) { final List pipeline = new ArrayList<>(); if (startDate != null) { pipeline.add(new Document("$match", new Document(PlayerFields.PUNISHMENT_ISSUED, new Document("$gte", startDate)))); } pipeline.add(new Document("$unwind", "$" + PlayerFields.PUNISHMENTS)); - pipeline.add(new Document("$match", new Document(PlayerFields.PUNISHMENT_ISSUED, new Document("$type", "date")))); + pipeline.add(matchDateTyped(PlayerFields.PUNISHMENT_ISSUED)); if (startDate != null) { pipeline.add(new Document("$match", new Document(PlayerFields.PUNISHMENT_ISSUED, new Document("$gte", startDate)))); @@ -173,7 +227,15 @@ public Document aggregatePunishmentAnalytics(Server server, Date startDate, Stri .aggregate(pipeline) .into(new ArrayList<>()); - return aggregateResults.isEmpty() ? null : aggregateResults.get(0); + if (aggregateResults.isEmpty()) { + return null; + } + Document facets = aggregateResults.get(0); + return new PunishmentAnalyticsFacet( + toTypeOrdinalCounts(facets.get(FACET_BY_TYPE)), + toIssuerCounts(facets.get(FACET_BY_STAFF)), + toDateCounts(facets.get(FACET_DAILY)) + ); } public List aggregateAuditLogLevelCounts(Server server, Date startDate) { @@ -210,38 +272,64 @@ public List aggregateHourlyAuditLogCounts(Server server, Date since, S .into(new ArrayList<>()); } - public Document aggregatePlayerActivity(Server server, Date startDate, String timeZone) { + public PlayerActivityFacet aggregatePlayerActivity(Server server, Date startDate, String timeZone) { final List pipeline = new ArrayList<>(); pipeline.add(new Document("$match", new Document(PlayerFields.IP_ADDRESSES, new Document("$exists", true).append("$ne", List.of())))); pipeline.add(new Document("$project", new Document(PlayerFields.IP_ADDRESSES, 1))); - final List newPlayerFacet = new ArrayList<>(); - newPlayerFacet.add(new Document("$addFields", new Document(EARLIEST_FIRST_LOGIN, new Document("$min", "$" + PlayerFields.IP_FIRST_LOGIN)))); + pipeline.add(new Document("$facet", new Document() + .append(FACET_NEW_PLAYERS, newPlayerFacet(startDate, timeZone)) + .append(FACET_BY_COUNTRY, countryFacet(startDate)) + .append(FACET_SUSPICIOUS, suspiciousFacet(startDate)))); + + final List results = tenantMongoAccess.forServer(server) + .getCollection(CollectionName.PLAYERS) + .aggregate(pipeline) + .into(new ArrayList<>()); + + if (results.isEmpty()) { + return null; + } + Document facets = results.get(0); + return new PlayerActivityFacet( + toDateCounts(facets.get(FACET_NEW_PLAYERS)), + toCountryCounts(facets.get(FACET_BY_COUNTRY)), + toSuspiciousCounts(facets.get(FACET_SUSPICIOUS)) + ); + } + + private static List newPlayerFacet(Date startDate, String timeZone) { + final List facet = new ArrayList<>(); + facet.add(new Document("$addFields", new Document(EARLIEST_FIRST_LOGIN, new Document("$min", "$" + PlayerFields.IP_FIRST_LOGIN)))); + facet.add(matchDateTyped(EARLIEST_FIRST_LOGIN)); if (startDate != null) { - newPlayerFacet.add(new Document("$match", new Document(EARLIEST_FIRST_LOGIN, + facet.add(new Document("$match", new Document(EARLIEST_FIRST_LOGIN, new Document("$gt", startDate)))); } - newPlayerFacet.add(new Document("$group", new Document("_id", + facet.add(new Document("$group", new Document("_id", new Document("$dateToString", new Document("format", "%Y-%m-%d") .append("date", "$" + EARLIEST_FIRST_LOGIN) .append("timezone", timeZone))) .append(ALIAS_COUNT, new Document("$sum", 1)))); - newPlayerFacet.add(new Document("$sort", new Document("_id", 1))); + facet.add(new Document("$sort", new Document("_id", 1))); + return facet; + } - final List countryFacet = new ArrayList<>(); - countryFacet.add(new Document("$unwind", "$" + PlayerFields.IP_ADDRESSES)); + private static List countryFacet(Date startDate) { + final List facet = new ArrayList<>(); + facet.add(new Document("$unwind", "$" + PlayerFields.IP_ADDRESSES)); if (startDate != null) { - countryFacet.add(new Document("$match", new Document("$or", List.of( + facet.add(new Document("$match", new Document("$or", List.of( new Document(PlayerFields.IP_FIRST_LOGIN, new Document("$gt", startDate)), new Document(PlayerFields.IP_ADDRESSES + ".logins", new Document("$elemMatch", new Document("$gt", startDate))) )))); } - countryFacet.add(new Document("$match", new Document(PlayerFields.IP_ADDRESSES + ".country", + facet.add(new Document("$match", new Document(PlayerFields.IP_ADDRESSES + ".country", new Document("$exists", true).append("$ne", "")))); Document loginCountExpr; @@ -258,35 +346,27 @@ public Document aggregatePlayerActivity(Server server, Date startDate, String ti new Document("$size", new Document("$ifNull", List.of("$" + PlayerFields.IP_ADDRESSES + ".logins", List.of()))))); } - countryFacet.add(new Document("$group", new Document("_id", "$" + PlayerFields.IP_ADDRESSES + ".country") + facet.add(new Document("$group", new Document("_id", "$" + PlayerFields.IP_ADDRESSES + ".country") .append(ALIAS_COUNT, new Document("$sum", loginCountExpr)))); - countryFacet.add(new Document("$sort", new Document(ALIAS_COUNT, -1))); - countryFacet.add(new Document("$limit", 20)); + facet.add(new Document("$sort", new Document(ALIAS_COUNT, -1))); + facet.add(new Document("$limit", 20)); + return facet; + } - final List suspiciousFacet = new ArrayList<>(); - suspiciousFacet.add(new Document("$unwind", "$" + PlayerFields.IP_ADDRESSES)); + private static List suspiciousFacet(Date startDate) { + final List facet = new ArrayList<>(); + facet.add(new Document("$unwind", "$" + PlayerFields.IP_ADDRESSES)); if (startDate != null) { - suspiciousFacet.add(new Document("$match", new Document("$or", List.of( + facet.add(new Document("$match", new Document("$or", List.of( new Document(PlayerFields.IP_FIRST_LOGIN, new Document("$gt", startDate)), new Document(PlayerFields.IP_ADDRESSES + ".logins", new Document("$elemMatch", new Document("$gt", startDate))) )))); } - suspiciousFacet.add(new Document("$group", new Document("_id", null) + facet.add(new Document("$group", new Document("_id", null) .append("proxyCount", new Document("$sum", new Document("$cond", List.of("$" + PlayerFields.IP_ADDRESSES + ".proxy", 1, 0)))) .append("hostingCount", new Document("$sum", new Document("$cond", List.of("$" + PlayerFields.IP_ADDRESSES + ".hosting", 1, 0)))))); - - pipeline.add(new Document("$facet", new Document() - .append(FACET_NEW_PLAYERS, newPlayerFacet) - .append(FACET_BY_COUNTRY, countryFacet) - .append(FACET_SUSPICIOUS, suspiciousFacet))); - - final List results = tenantMongoAccess.forServer(server) - .getCollection(CollectionName.PLAYERS) - .aggregate(pipeline) - .into(new ArrayList<>()); - - return results.isEmpty() ? null : results.get(0); + return facet; } public record OverviewStats( @@ -294,9 +374,95 @@ public record OverviewStats( long totalPlayers, long activeTickets, long recentTickets, - long previousTickets + long previousTickets, + long recentPlayers, + long previousPlayers ) { } public record IdCountResult(String id, int count) {} + + public record CategoryAvgResolution(String id, double avgMillis) {} + + public record PunishmentAnalyticsFacet( + List byType, + List byStaff, + List daily + ) {} + + public record PlayerActivityFacet( + List newPlayers, + List byCountry, + SuspiciousCounts suspicious + ) {} + + public record TypeOrdinalCount(Integer typeOrdinal, int count) {} + + public record IssuerCount(String issuerId, int count) {} + + public record DateCountResult(String date, int count) {} + + public record CountryCount(String country, int count) {} + + public record SuspiciousCounts(int proxyCount, int hostingCount) {} + + private static double doubleValueOrZero(Object value) { + return value instanceof Number number ? number.doubleValue() : 0.0; + } + + private static List facetDocuments(Object value) { + if (!(value instanceof List rawList)) { + return List.of(); + } + List documents = new ArrayList<>(); + for (Object item : rawList) { + if (item instanceof Document document) { + documents.add(document); + } + } + return documents; + } + + private static List toTypeOrdinalCounts(Object value) { + return facetDocuments(value).stream() + .map(doc -> new TypeOrdinalCount( + doc.get("_id") instanceof Number number ? number.intValue() : null, + ProtoMapperSupport.intValueOrZero(doc.get(ALIAS_COUNT)))) + .toList(); + } + + private static List toIssuerCounts(Object value) { + return facetDocuments(value).stream() + .map(doc -> new IssuerCount( + doc.get("_id") instanceof String issuerId ? issuerId : null, + ProtoMapperSupport.intValueOrZero(doc.get(ALIAS_COUNT)))) + .toList(); + } + + private static List toDateCounts(Object value) { + return facetDocuments(value).stream() + .map(doc -> new DateCountResult( + doc.getString("_id"), + ProtoMapperSupport.intValueOrZero(doc.get(ALIAS_COUNT)))) + .toList(); + } + + private static List toCountryCounts(Object value) { + return facetDocuments(value).stream() + .map(doc -> new CountryCount( + doc.getString("_id"), + ProtoMapperSupport.intValueOrZero(doc.get(ALIAS_COUNT)))) + .toList(); + } + + private static SuspiciousCounts toSuspiciousCounts(Object value) { + List documents = facetDocuments(value); + if (documents.isEmpty()) { + return new SuspiciousCounts(0, 0); + } + Document first = documents.get(0); + return new SuspiciousCounts( + ProtoMapperSupport.intValueOrZero(first.get("proxyCount")), + ProtoMapperSupport.intValueOrZero(first.get("hostingCount"))); + } } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/AuditLogRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/AuditLogRepository.java new file mode 100644 index 0000000..b90b08f --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/AuditLogRepository.java @@ -0,0 +1,66 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.audit.data.AuditLog; +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.AuditLogFields; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.List; +import java.util.regex.Pattern; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Repository; + +@Repository +public class AuditLogRepository extends AbstractServerMongoRepository { + private static final String LEVEL_MODERATION = "moderation"; + private static final String LEVEL_INFO = "info"; + + public AuditLogRepository(TenantMongoAccess tenantMongoAccess) { + super(AuditLog.class, CollectionName.LOGS, tenantMongoAccess); + } + + public void saveAuditLog(Server server, AuditLog auditLog) { + saveEntity(server, auditLog); + } + + public List findPunishmentLogs(Server server, Date startDate, int limit, boolean canRollbackOnly) { + Criteria criteria = Criteria.where(AuditLogFields.CREATED).gte(startDate) + .orOperator( + Criteria.where(AuditLogFields.LEVEL).is(LEVEL_MODERATION), + Criteria.where(AuditLogFields.DESCRIPTION).regex(Pattern.compile("ban|mute|kick|warn", Pattern.CASE_INSENSITIVE)) + ); + + if (canRollbackOnly) { + criteria = criteria.and(AuditLogFields.METADATA_CAN_ROLLBACK).ne(false); + } + + Query query = Query.query(criteria) + .with(Sort.by(Sort.Direction.DESC, AuditLogFields.CREATED)) + .limit(limit); + + return find(server, query); + } + + public long countEvidenceUploads(Server server, String username, Date startDate) { + Criteria baseCriteria = Criteria.where(AuditLogFields.SOURCE).is(username); + if (startDate != null) { + baseCriteria = baseCriteria.and(AuditLogFields.CREATED).gte(startDate); + } + + Query query = Query.query( + baseCriteria.orOperator( + Criteria.where(AuditLogFields.DESCRIPTION).regex(Pattern.compile("evidence|upload|file", Pattern.CASE_INSENSITIVE)), + Criteria.where(AuditLogFields.LEVEL) + .is(LEVEL_INFO) + .and(AuditLogFields.DESCRIPTION) + .regex(Pattern.compile("uploaded|attachment", Pattern.CASE_INSENSITIVE)) + ) + ); + + return count(server, query); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/GlobalMongoAdminRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/GlobalMongoAdminRepository.java index b7565c7..2723ec6 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/GlobalMongoAdminRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/GlobalMongoAdminRepository.java @@ -1,5 +1,6 @@ package gg.modl.backend.database.mongo.repository; +import gg.modl.backend.database.mongo.MongoAggregationResults; import gg.modl.backend.database.mongo.TenantMongoAccess; import lombok.RequiredArgsConstructor; import org.bson.Document; @@ -16,7 +17,6 @@ public void ping() { public long getStorageSize() { Document dbStats = tenantMongoAccess.global().getDb().runCommand(new Document("dbStats", 1)); - Object value = dbStats.get("storageSize"); - return value instanceof Number number ? number.longValue() : 0L; + return MongoAggregationResults.extractLong(dbStats, "storageSize"); } } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/HomepageCardMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/HomepageCardMongoRepository.java index fcfba6f..d1a1aa0 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/HomepageCardMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/HomepageCardMongoRepository.java @@ -10,9 +10,7 @@ import java.util.List; import java.util.Optional; import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.BulkOperations; import org.springframework.data.mongodb.core.FindAndModifyOptions; -import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; @@ -112,15 +110,6 @@ public boolean deleteByCardId(Server server, String id) { } public void reorderCards(Server server, List ids) { - if (ids.isEmpty()) return; - - MongoTemplate template = serverTemplate(server); - BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); - for (int index = 0; index < ids.size(); index++) { - Query query = Query.query(Criteria.where(HomepageCardFields.ID).is(ids.get(index))); - Update update = new Update().set(HomepageCardFields.ORDINAL, index); - bulk.updateOne(query, update); - } - bulk.execute(); + bulkUpdateByIds(server, ids, (id, index) -> new Update().set(HomepageCardFields.ORDINAL, index)); } } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/InvitationMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/InvitationMongoRepository.java index f3bc8aa..0babe62 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/InvitationMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/InvitationMongoRepository.java @@ -1,54 +1,54 @@ -package gg.modl.backend.database.mongo.repository; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.InvitationFields; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.data.Invitation; -import java.util.Date; -import java.util.List; -import java.util.Optional; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class InvitationMongoRepository extends AbstractServerMongoRepository { - public InvitationMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(Invitation.class, CollectionName.INVITATIONS, tenantMongoAccess); - } - - public List findActiveInvitations(Server server, Date now) { - return find(server, Query.query(Criteria.where(InvitationFields.EXPIRES_AT).gt(now))); - } - - public long countActive(Server server, Date now) { - return count(server, Query.query(Criteria.where(InvitationFields.EXPIRES_AT).gt(now))); - } - - public boolean existsByEmailActive(Server server, String email, Date now) { - return exists(server, Query.query( - Criteria.where(InvitationFields.EMAIL).is(email) - .and(InvitationFields.EXPIRES_AT).gt(now) - )); - } - - public Optional findByToken(Server server, String token) { - return findOne(server, Query.query(Criteria.where(InvitationFields.TOKEN).is(token))); - } - - public boolean deleteById(Server server, String id) { - return remove(server, Query.query(Criteria.where(InvitationFields.ID).is(id))).getDeletedCount() > 0; - } - - public void refreshToken(Server server, String invitationId, String newToken, Date newExpiresAt, Date updatedAt) { - Query query = Query.query(Criteria.where(InvitationFields.ID).is(invitationId)); - Update update = new Update(); - update.set(InvitationFields.TOKEN, newToken); - update.set(InvitationFields.EXPIRES_AT, newExpiresAt); - update.set(InvitationFields.UPDATED_AT, updatedAt); - updateFirst(server, query, update); - } -} +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.InvitationFields; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.staff.data.Invitation; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class InvitationMongoRepository extends AbstractServerMongoRepository { + public InvitationMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(Invitation.class, CollectionName.INVITATIONS, tenantMongoAccess); + } + + public List findActiveInvitations(Server server, Date now) { + return find(server, Query.query(Criteria.where(InvitationFields.EXPIRES_AT).gt(now))); + } + + public long countActive(Server server, Date now) { + return count(server, Query.query(Criteria.where(InvitationFields.EXPIRES_AT).gt(now))); + } + + public boolean existsByEmailActive(Server server, String email, Date now) { + return exists(server, Query.query( + Criteria.where(InvitationFields.EMAIL).is(email) + .and(InvitationFields.EXPIRES_AT).gt(now) + )); + } + + public Optional findByToken(Server server, String token) { + return findOne(server, Query.query(Criteria.where(InvitationFields.TOKEN).is(token))); + } + + public boolean deleteById(Server server, String id) { + return remove(server, Query.query(Criteria.where(InvitationFields.ID).is(id))).getDeletedCount() > 0; + } + + public void refreshToken(Server server, String invitationId, String newToken, Date newExpiresAt, Date updatedAt) { + Query query = Query.query(Criteria.where(InvitationFields.ID).is(invitationId)); + Update update = new Update(); + update.set(InvitationFields.TOKEN, newToken); + update.set(InvitationFields.EXPIRES_AT, newExpiresAt); + update.set(InvitationFields.UPDATED_AT, updatedAt); + updateFirst(server, query, update); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseArticleMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseArticleMongoRepository.java index b5388e6..d0a1e1a 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseArticleMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseArticleMongoRepository.java @@ -12,9 +12,7 @@ import java.util.Optional; import java.util.regex.Pattern; import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.BulkOperations; import org.springframework.data.mongodb.core.FindAndModifyOptions; -import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; @@ -132,18 +130,11 @@ public List searchVisibleArticles(Server server, String se } public void reorderArticles(Server server, String categoryId, List ids) { - if (ids.isEmpty()) return; - - MongoTemplate template = serverTemplate(server); - BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); - for (int index = 0; index < ids.size(); index++) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(KnowledgebaseArticleFields.ID).is(ids.get(index)), + bulkUpdateByIds(server, ids, + (id, index) -> new Criteria().andOperator( + Criteria.where(KnowledgebaseArticleFields.ID).is(id), Criteria.where(KnowledgebaseArticleFields.CATEGORY_ID).is(categoryId) - )); - Update update = new Update().set(KnowledgebaseArticleFields.ORDINAL, index); - bulk.updateOne(query, update); - } - bulk.execute(); + ), + (id, index) -> new Update().set(KnowledgebaseArticleFields.ORDINAL, index)); } } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseCategoryMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseCategoryMongoRepository.java index 0283f7c..113ab85 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseCategoryMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseCategoryMongoRepository.java @@ -2,7 +2,6 @@ import gg.modl.backend.database.CollectionName; import gg.modl.backend.database.mongo.AbstractServerMongoRepository; - import gg.modl.backend.database.mongo.TenantMongoAccess; import gg.modl.backend.database.mongo.fields.KnowledgebaseCategoryFields; import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; @@ -12,9 +11,7 @@ import java.util.List; import java.util.Optional; import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.BulkOperations; import org.springframework.data.mongodb.core.FindAndModifyOptions; -import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; @@ -97,15 +94,6 @@ public boolean deleteByCategoryId(Server server, String id) { } public void reorderCategories(Server server, List ids) { - if (ids.isEmpty()) return; - - MongoTemplate template = serverTemplate(server); - BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); - for (int index = 0; index < ids.size(); index++) { - Query query = Query.query(Criteria.where(KnowledgebaseCategoryFields.ID).is(ids.get(index))); - Update update = new Update().set(KnowledgebaseCategoryFields.ORDINAL, index); - bulk.updateOne(query, update); - } - bulk.execute(); + bulkUpdateByIds(server, ids, (id, index) -> new Update().set(KnowledgebaseCategoryFields.ORDINAL, index)); } } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/MigrationMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/MigrationMongoRepository.java index bdfd3d9..bcf8252 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/MigrationMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/MigrationMongoRepository.java @@ -1,109 +1,109 @@ -package gg.modl.backend.database.mongo.repository; - -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.MigrationStatusFields; -import gg.modl.backend.migration.data.MigrationStatus; -import gg.modl.backend.server.data.Server; -import java.util.Date; -import java.util.List; -import java.util.Optional; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class MigrationMongoRepository extends AbstractServerMongoRepository { - private static final String COLLECTION_NAME = "migrations"; - private static final List ACTIVE_STATUSES = - List.of("building_json", "uploading_json", "processing_data"); - - public MigrationMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(MigrationStatus.class, COLLECTION_NAME, tenantMongoAccess); - } - - public Optional findLatest(Server server) { - Query query = new Query().with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.STARTED_AT)).limit(1); - return findOne(server, query); - } - - public Optional findLatestCompletedOrFailed(Server server) { - Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in("completed", "failed").and(MigrationStatusFields.COOLDOWN_EXEMPT).ne(true)) - .with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.COMPLETED_AT)).limit(1); - return findOne(server, query); - } - - public Optional findActiveMigration(Server server) { - Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES)) - .with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.STARTED_AT)).limit(1); - return findOne(server, query); - } - - public Optional findActiveMigration(Server server, Date staleBefore) { - Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES).and(MigrationStatusFields.STARTED_AT).gte(staleBefore)) - .with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.STARTED_AT)).limit(1); - return findOne(server, query); - } - - public boolean existsActiveMigration(Server server) { - Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES)); - return exists(server, query); - } - - public boolean existsActiveMigration(Server server, Date staleBefore) { - Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES).and(MigrationStatusFields.STARTED_AT).gte(staleBefore)); - return exists(server, query); - } - - public long failStaleMigrations(Server server, Date staleBefore, Date now, String message) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES), - new Criteria().orOperator( - Criteria.where(MigrationStatusFields.STARTED_AT).lt(staleBefore), - Criteria.where(MigrationStatusFields.STARTED_AT).exists(false) - ) - )); - Update update = new Update() - .set(MigrationStatusFields.STATUS, "failed") - .set(MigrationStatusFields.COMPLETED_AT, now) - .set(MigrationStatusFields.ERROR, message) - .set(MigrationStatusFields.PROGRESS_MESSAGE, message) - .set(MigrationStatusFields.COOLDOWN_EXEMPT, true); - return updateMulti(server, query, update).getModifiedCount(); - } - - public void cancelMigration(Server server, String id, String error, Date completedAt, - String progressMessage, boolean cooldownExempt) { - Update update = new Update() - .set(MigrationStatusFields.STATUS, "failed") - .set(MigrationStatusFields.ERROR, error) - .set(MigrationStatusFields.COMPLETED_AT, completedAt) - .set(MigrationStatusFields.PROGRESS_MESSAGE, progressMessage) - .set(MigrationStatusFields.COOLDOWN_EXEMPT, cooldownExempt); - updateFirst(server, Query.query(Criteria.where(MigrationStatusFields.ID).is(id)), update); - } - - public void updateProgress(Server server, String id, String status, String message, - Integer recordsProcessed, Integer recordsSkipped, - Integer totalRecords, Date completedAt) { - Update update = new Update() - .set(MigrationStatusFields.STATUS, status) - .set(MigrationStatusFields.PROGRESS_MESSAGE, message); - - if (recordsProcessed != null) { - update.set(MigrationStatusFields.PROGRESS_RECORDS_PROCESSED, recordsProcessed); - } - if (recordsSkipped != null) { - update.set(MigrationStatusFields.PROGRESS_RECORDS_SKIPPED, recordsSkipped); - } - if (totalRecords != null) { - update.set(MigrationStatusFields.PROGRESS_TOTAL_RECORDS, totalRecords); - } - if (completedAt != null) { - update.set(MigrationStatusFields.COMPLETED_AT, completedAt); - } - updateFirst(server, Query.query(Criteria.where(MigrationStatusFields.ID).is(id)), update); - } -} +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.MigrationStatusFields; +import gg.modl.backend.migration.data.MigrationStatus; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class MigrationMongoRepository extends AbstractServerMongoRepository { + private static final List ACTIVE_STATUSES = + List.of("building_json", "uploading_json", "processing_data"); + + public MigrationMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(MigrationStatus.class, CollectionName.MIGRATIONS, tenantMongoAccess); + } + + public Optional findLatest(Server server) { + Query query = new Query().with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.STARTED_AT)).limit(1); + return findOne(server, query); + } + + public Optional findLatestCompletedOrFailed(Server server) { + Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in("completed", "failed").and(MigrationStatusFields.COOLDOWN_EXEMPT).ne(true)) + .with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.COMPLETED_AT)).limit(1); + return findOne(server, query); + } + + public Optional findActiveMigration(Server server) { + Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES)) + .with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.STARTED_AT)).limit(1); + return findOne(server, query); + } + + public Optional findActiveMigration(Server server, Date staleBefore) { + Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES).and(MigrationStatusFields.STARTED_AT).gte(staleBefore)) + .with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.STARTED_AT)).limit(1); + return findOne(server, query); + } + + public boolean existsActiveMigration(Server server) { + Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES)); + return exists(server, query); + } + + public boolean existsActiveMigration(Server server, Date staleBefore) { + Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES).and(MigrationStatusFields.STARTED_AT).gte(staleBefore)); + return exists(server, query); + } + + public long failStaleMigrations(Server server, Date staleBefore, Date now, String message) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES), + new Criteria().orOperator( + Criteria.where(MigrationStatusFields.STARTED_AT).lt(staleBefore), + Criteria.where(MigrationStatusFields.STARTED_AT).exists(false) + ) + )); + Update update = new Update() + .set(MigrationStatusFields.STATUS, "failed") + .set(MigrationStatusFields.COMPLETED_AT, now) + .set(MigrationStatusFields.ERROR, message) + .set(MigrationStatusFields.PROGRESS_MESSAGE, message) + .set(MigrationStatusFields.COOLDOWN_EXEMPT, true); + return updateMulti(server, query, update).getModifiedCount(); + } + + public void cancelMigration(Server server, String id, String error, Date completedAt, + String progressMessage, boolean cooldownExempt) { + Update update = new Update() + .set(MigrationStatusFields.STATUS, "failed") + .set(MigrationStatusFields.ERROR, error) + .set(MigrationStatusFields.COMPLETED_AT, completedAt) + .set(MigrationStatusFields.PROGRESS_MESSAGE, progressMessage) + .set(MigrationStatusFields.COOLDOWN_EXEMPT, cooldownExempt); + updateFirst(server, Query.query(Criteria.where(MigrationStatusFields.ID).is(id)), update); + } + + public void updateProgress(Server server, String id, String status, String message, + Integer recordsProcessed, Integer recordsSkipped, + Integer totalRecords, Date completedAt) { + Update update = new Update() + .set(MigrationStatusFields.STATUS, status) + .set(MigrationStatusFields.PROGRESS_MESSAGE, message); + + if (recordsProcessed != null) { + update.set(MigrationStatusFields.PROGRESS_RECORDS_PROCESSED, recordsProcessed); + } + if (recordsSkipped != null) { + update.set(MigrationStatusFields.PROGRESS_RECORDS_SKIPPED, recordsSkipped); + } + if (totalRecords != null) { + update.set(MigrationStatusFields.PROGRESS_TOTAL_RECORDS, totalRecords); + } + if (completedAt != null) { + update.set(MigrationStatusFields.COMPLETED_AT, completedAt); + } + updateFirst(server, Query.query(Criteria.where(MigrationStatusFields.ID).is(id)), update); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/PlayerMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/PlayerMongoRepository.java index 89abd76..533c075 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/PlayerMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/PlayerMongoRepository.java @@ -1,263 +1,262 @@ -package gg.modl.backend.database.mongo.repository; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; - -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.PlayerFields; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.server.data.Server; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.regex.Pattern; -import org.springframework.data.mongodb.core.BulkOperations; -import org.springframework.data.mongodb.core.query.Collation; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class PlayerMongoRepository extends AbstractServerMongoRepository { - public PlayerMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(Player.class, CollectionName.PLAYERS, tenantMongoAccess); - } - - public Optional findByMinecraftUuid(Server server, UUID minecraftUuid) { - return findByMinecraftUuid(server, minecraftUuid.toString()); - } - - public Optional findByMinecraftUuid(Server server, String minecraftUuid) { - return findOne(server, Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid))); - } - - public Optional findByUsernameIgnoreCase(Server server, String username) { - Query query = Query.query(Criteria.where(PlayerFields.USERNAME).is(username.trim())) - .collation(Collation.of("en").strength(2)); - return findOne(server, query); - } - - public List searchByUsernamePattern(Server server, String searchTerm, int limit) { - Pattern pattern = Pattern.compile(Pattern.quote(searchTerm), Pattern.CASE_INSENSITIVE); - Query query = Query.query(Criteria.where(PlayerFields.USERNAME).regex(pattern)); - query.limit(limit); - return find(server, query); - } - - public List findOnlinePlayers(Server server, int limit) { - Query query = Query.query(Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true)); - query.limit(limit); - return find(server, query); - } - - private static final int DEFAULT_QUERY_LIMIT = 1000; - - public List findByMinecraftUuids(Server server, Collection minecraftUuids) { - return findByMinecraftUuids(server, minecraftUuids, DEFAULT_QUERY_LIMIT); - } - - public List findByMinecraftUuids(Server server, Collection minecraftUuids, int limit) { - if (minecraftUuids == null || minecraftUuids.isEmpty()) { - return List.of(); - } - - Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).in(minecraftUuids)); - query.limit(limit); - return find(server, query); - } - - public List findByIpAddresses(Server server, Collection ipAddresses) { - return findByIpAddresses(server, ipAddresses, DEFAULT_QUERY_LIMIT); - } - - public List findByIpAddresses(Server server, Collection ipAddresses, int limit) { - if (ipAddresses == null || ipAddresses.isEmpty()) { - return List.of(); - } - - Query query = Query.query(Criteria.where(PlayerFields.IP_ADDRESS).in(ipAddresses)); - query.limit(limit); - return find(server, query); - } - - public List findByIpAddressesExcludingUuid(Server server, Collection ipAddresses, String excludedUuid, int limit) { - if (ipAddresses == null || ipAddresses.isEmpty()) { - return List.of(); - } - - Query query = Query.query(new Criteria().andOperator( - Criteria.where(PlayerFields.IP_ADDRESS).in(ipAddresses), - Criteria.where(PlayerFields.MINECRAFT_UUID).ne(excludedUuid) - )); - query.limit(limit); - return find(server, query); - } - - public List findAvailablePlayers(Server server, Collection assignedUuids, int limit) { - Query query = new Query(); - if (assignedUuids != null && !assignedUuids.isEmpty()) { - query.addCriteria(Criteria.where(PlayerFields.MINECRAFT_UUID).nin(assignedUuids)); - } - query.limit(limit); - return find(server, query); - } - - public void updateLoginState(Server server, Player player) { - Update update = new Update() - .set(PlayerFields.USERNAMES, player.getUsernames()) - .set(PlayerFields.IP_ADDRESSES, player.getIpAddresses()) - .set(PlayerFields.DATA, player.getData()); - updateById(server, player.getId(), update); - } - - private void updateById(Server server, String playerId, Update update) { - Query query = Query.query(Criteria.where(PlayerFields.ID).is(playerId)); - updateFirst(server, query, update); - } - - public void replaceUsernames(Server server, Player player) { - updateById(server, player.getId(), new Update().set(PlayerFields.USERNAMES, player.getUsernames())); - } - - public void replaceNotes(Server server, Player player) { - updateById(server, player.getId(), new Update().set(PlayerFields.NOTES, player.getNotes())); - } - - public void replaceIpAddresses(Server server, Player player) { - updateById(server, player.getId(), new Update().set(PlayerFields.IP_ADDRESSES, player.getIpAddresses())); - } - - public void replacePendingNotifications(Server server, Player player, List> notifications) { - updateById(server, player.getId(), new Update().set(PlayerFields.DATA_PENDING_NOTIFICATIONS, notifications)); - } - - public boolean pushPendingNotification(Server server, String minecraftUuid, Map notification) { - Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); - Update update = new Update().push(PlayerFields.DATA_PENDING_NOTIFICATIONS, notification); - return updateFirst(server, query, update).getMatchedCount() > 0; - } - - public void addLinkedAccounts(Server server, String minecraftUuid, Collection linkedUuids, Date when) { - if (linkedUuids == null || linkedUuids.isEmpty()) { - return; - } - Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); - Update update = new Update() - .addToSet(PlayerFields.DATA_LINKED_ACCOUNTS).each(linkedUuids.toArray()) - .set(PlayerFields.DATA_LAST_LINKED_UPDATE, when); - updateFirst(server, query, update); - } - - public void replaceData(Server server, Player player) { - updateById(server, player.getId(), new Update().set(PlayerFields.DATA, player.getData())); - } - - public void replaceLinkedAccounts(Server server, Player player) { - Map data = player.getData(); - Object linkedAccounts = data != null ? data.get("linkedAccounts") : null; - Object lastLinkedUpdate = data != null ? data.get("lastLinkedUpdate") : null; - - Update update = new Update().set(PlayerFields.DATA_LINKED_ACCOUNTS, linkedAccounts); - if (lastLinkedUpdate instanceof Date date) { - update.set(PlayerFields.DATA_LAST_LINKED_UPDATE, date); - } else { - update.unset(PlayerFields.DATA_LAST_LINKED_UPDATE); - } - updateById(server, player.getId(), update); - } - - public boolean markDisconnected(Server server, String minecraftUuid, long sessionDurationMs) { - Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); - Update update = new Update() - .set(PlayerFields.DATA_IS_ONLINE, false) - .set(PlayerFields.DATA_LAST_LOGOUT, new Date()); - - if (sessionDurationMs > 0) { - update.inc(PlayerFields.DATA_TOTAL_PLAYTIME_SECONDS, sessionDurationMs / 1000); - } - - return updateFirst(server, query, update).getMatchedCount() > 0; - } - - public boolean updateLastServer(Server server, String minecraftUuid, String serverName) { - Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); - Update update = new Update().set(PlayerFields.DATA_LAST_SERVER, serverName); - return updateFirst(server, query, update).getMatchedCount() > 0; - } - - public void markStalePlayersOffline(Server server, Collection onlineUuids, - String serverName, Date logoutTime) { - Criteria criteria = Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true) - .and(PlayerFields.MINECRAFT_UUID).nin(onlineUuids); - if (serverName != null && !serverName.isBlank()) { - criteria = criteria.and(PlayerFields.DATA_LAST_SERVER).is(serverName); - } - Update update = new Update() - .set(PlayerFields.DATA_IS_ONLINE, false) - .set(PlayerFields.DATA_LAST_LOGOUT, logoutTime); - updateMulti(server, Query.query(criteria), update); - } - - public List findByMinecraftUuids(Server server, List minecraftUuids) { - if (minecraftUuids == null || minecraftUuids.isEmpty()) { - return List.of(); - } - Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).in(minecraftUuids)); - query.limit(DEFAULT_QUERY_LIMIT); - return find(server, query); - } - - public BulkOperations bulkOps(Server server) { - return serverTemplate(server).bulkOps( - BulkOperations.BulkMode.UNORDERED, - Player.class, - CollectionName.PLAYERS - ); - } - - public void bulkMergeByUuid(Server server, Map updatesByUuid) { - if (updatesByUuid == null || updatesByUuid.isEmpty()) { - return; - } - BulkOperations ops = bulkOps(server); - for (Map.Entry entry : updatesByUuid.entrySet()) { - ops.updateOne( - Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(entry.getKey())), - entry.getValue() - ); - } - ops.execute(); - } - - public long countOnlinePlayers(Server server) { - return count(server, Query.query(Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true))); - } - - public long countAll(Server server) { - return count(server, new Query()); - } - - public long countFirstJoinedAfter(Server server, Date after) { - return count(server, Query.query(Criteria.where(PlayerFields.DATA_FIRST_JOIN).gte(after))); - } - - public long countFirstJoinedBetween(Server server, Date from, Date to) { - return count(server, Query.query(Criteria.where(PlayerFields.DATA_FIRST_JOIN).gte(from).lt(to))); - } - - public long countOnlineByUuids(Server server, Collection uuids) { - if (uuids == null || uuids.isEmpty()) { - return 0; - } - Query query = Query.query(new Criteria().andOperator( - Criteria.where(PlayerFields.MINECRAFT_UUID).in(uuids), - Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true) - )); - return count(server, query); - } - -} +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; + +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.PlayerFields; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.PlayerDataView; +import gg.modl.backend.server.data.Server; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Pattern; +import org.springframework.data.mongodb.core.BulkOperations; +import org.springframework.data.mongodb.core.query.Collation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class PlayerMongoRepository extends AbstractServerMongoRepository { + private static final int DEFAULT_QUERY_LIMIT = 1000; + + public PlayerMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(Player.class, CollectionName.PLAYERS, tenantMongoAccess); + } + + public Optional findByMinecraftUuid(Server server, UUID minecraftUuid) { + return findByMinecraftUuid(server, minecraftUuid.toString()); + } + + public Optional findByMinecraftUuid(Server server, String minecraftUuid) { + return findOne(server, Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid))); + } + + public Optional findByUsernameIgnoreCase(Server server, String username) { + Query query = Query.query(Criteria.where(PlayerFields.USERNAME).is(username.trim())) + .collation(Collation.of("en").strength(2)); + return findOne(server, query); + } + + public List searchByUsernamePattern(Server server, String searchTerm, int limit) { + Pattern pattern = Pattern.compile(Pattern.quote(searchTerm), Pattern.CASE_INSENSITIVE); + Query query = Query.query(Criteria.where(PlayerFields.USERNAME).regex(pattern)); + query.limit(limit); + return find(server, query); + } + + public List findOnlinePlayers(Server server, int limit) { + Query query = Query.query(Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true)); + query.limit(limit); + return find(server, query); + } + + public List findByMinecraftUuids(Server server, Collection minecraftUuids) { + return findByMinecraftUuids(server, minecraftUuids, DEFAULT_QUERY_LIMIT); + } + + public List findByMinecraftUuids(Server server, Collection minecraftUuids, int limit) { + if (minecraftUuids == null || minecraftUuids.isEmpty()) { + return List.of(); + } + + Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).in(minecraftUuids)); + query.limit(limit); + return find(server, query); + } + + public List findByMinecraftUuids(Server server, List minecraftUuids) { + if (minecraftUuids == null || minecraftUuids.isEmpty()) { + return List.of(); + } + Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).in(minecraftUuids)); + query.limit(DEFAULT_QUERY_LIMIT); + return find(server, query); + } + + public List findByIpAddresses(Server server, Collection ipAddresses) { + return findByIpAddresses(server, ipAddresses, DEFAULT_QUERY_LIMIT); + } + + public List findByIpAddresses(Server server, Collection ipAddresses, int limit) { + if (ipAddresses == null || ipAddresses.isEmpty()) { + return List.of(); + } + + Query query = Query.query(Criteria.where(PlayerFields.IP_ADDRESS).in(ipAddresses)); + query.limit(limit); + return find(server, query); + } + + public List findByIpAddressesExcludingUuid(Server server, Collection ipAddresses, String excludedUuid, int limit) { + if (ipAddresses == null || ipAddresses.isEmpty()) { + return List.of(); + } + + Query query = Query.query(new Criteria().andOperator( + Criteria.where(PlayerFields.IP_ADDRESS).in(ipAddresses), + Criteria.where(PlayerFields.MINECRAFT_UUID).ne(excludedUuid) + )); + query.limit(limit); + return find(server, query); + } + + public List findAvailablePlayers(Server server, Collection assignedUuids, int limit) { + Query query = new Query(); + if (assignedUuids != null && !assignedUuids.isEmpty()) { + query.addCriteria(Criteria.where(PlayerFields.MINECRAFT_UUID).nin(assignedUuids)); + } + query.limit(limit); + return find(server, query); + } + + public void updateLoginState(Server server, Player player) { + Update update = new Update() + .set(PlayerFields.USERNAMES, player.getUsernames()) + .set(PlayerFields.IP_ADDRESSES, player.getIpAddresses()) + .set(PlayerFields.DATA, player.data().asMap()); + updateById(server, player.getId(), update); + } + + private void updateById(Server server, String playerId, Update update) { + Query query = Query.query(Criteria.where(PlayerFields.ID).is(playerId)); + updateFirst(server, query, update); + } + + public void replaceUsernames(Server server, Player player) { + updateById(server, player.getId(), new Update().set(PlayerFields.USERNAMES, player.getUsernames())); + } + + public void replaceNotes(Server server, Player player) { + updateById(server, player.getId(), new Update().set(PlayerFields.NOTES, player.getNotes())); + } + + public void replaceIpAddresses(Server server, Player player) { + updateById(server, player.getId(), new Update().set(PlayerFields.IP_ADDRESSES, player.getIpAddresses())); + } + + public void replacePendingNotifications(Server server, Player player, List> notifications) { + updateById(server, player.getId(), new Update().set(PlayerFields.DATA_PENDING_NOTIFICATIONS, notifications)); + } + + public boolean pushPendingNotification(Server server, String minecraftUuid, Map notification) { + Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); + Update update = new Update().push(PlayerFields.DATA_PENDING_NOTIFICATIONS, notification); + return updateFirst(server, query, update).getMatchedCount() > 0; + } + + public void addLinkedAccounts(Server server, String minecraftUuid, Collection linkedUuids, Date when) { + if (linkedUuids == null || linkedUuids.isEmpty()) { + return; + } + Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); + Update update = new Update() + .addToSet(PlayerFields.DATA_LINKED_ACCOUNTS).each(linkedUuids.toArray()) + .set(PlayerFields.DATA_LAST_LINKED_UPDATE, when); + updateFirst(server, query, update); + } + + public void replaceData(Server server, Player player) { + updateById(server, player.getId(), new Update().set(PlayerFields.DATA, player.data().asMap())); + } + + public void replaceLinkedAccounts(Server server, Player player) { + PlayerDataView data = player.data(); + Date lastLinkedUpdate = data.lastLinkedUpdate(); + + Update update = new Update().set(PlayerFields.DATA_LINKED_ACCOUNTS, data.linkedAccountsValue()); + if (lastLinkedUpdate != null) { + update.set(PlayerFields.DATA_LAST_LINKED_UPDATE, lastLinkedUpdate); + } else { + update.unset(PlayerFields.DATA_LAST_LINKED_UPDATE); + } + updateById(server, player.getId(), update); + } + + public boolean markDisconnected(Server server, String minecraftUuid, long sessionDurationMs) { + Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); + Update update = new Update() + .set(PlayerFields.DATA_IS_ONLINE, false) + .set(PlayerFields.DATA_LAST_LOGOUT, new Date()); + + if (sessionDurationMs > 0) { + update.inc(PlayerFields.DATA_TOTAL_PLAYTIME_SECONDS, sessionDurationMs / 1000); + } + + return updateFirst(server, query, update).getMatchedCount() > 0; + } + + public boolean updateLastServer(Server server, String minecraftUuid, String serverName) { + Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); + Update update = new Update().set(PlayerFields.DATA_LAST_SERVER, serverName); + return updateFirst(server, query, update).getMatchedCount() > 0; + } + + public void markStalePlayersOffline(Server server, Collection onlineUuids, + String serverName, Date logoutTime) { + Criteria criteria = Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true) + .and(PlayerFields.MINECRAFT_UUID).nin(onlineUuids); + if (serverName != null && !serverName.isBlank()) { + criteria = criteria.and(PlayerFields.DATA_LAST_SERVER).is(serverName); + } + Update update = new Update() + .set(PlayerFields.DATA_IS_ONLINE, false) + .set(PlayerFields.DATA_LAST_LOGOUT, logoutTime); + updateMulti(server, Query.query(criteria), update); + } + + private BulkOperations bulkOps(Server server) { + return serverTemplate(server).bulkOps( + BulkOperations.BulkMode.UNORDERED, + Player.class, + CollectionName.PLAYERS + ); + } + + public void bulkMergeByUuid(Server server, Map updatesByUuid) { + if (updatesByUuid == null || updatesByUuid.isEmpty()) { + return; + } + BulkOperations ops = bulkOps(server); + for (Map.Entry entry : updatesByUuid.entrySet()) { + ops.updateOne( + Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(entry.getKey())), + entry.getValue() + ); + } + ops.execute(); + } + + public long countOnlinePlayers(Server server) { + return count(server, Query.query(Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true))); + } + + public long countAll(Server server) { + return count(server, new Query()); + } + + public long countFirstJoinedAfter(Server server, Date after) { + return count(server, Query.query(Criteria.where(PlayerFields.DATA_FIRST_JOIN).gte(after))); + } + + public long countFirstJoinedBetween(Server server, Date from, Date to) { + return count(server, Query.query(Criteria.where(PlayerFields.DATA_FIRST_JOIN).gte(from).lt(to))); + } + + public long countOnlineByUuids(Server server, Collection uuids) { + if (uuids == null || uuids.isEmpty()) { + return 0; + } + Query query = Query.query(new Criteria().andOperator( + Criteria.where(PlayerFields.MINECRAFT_UUID).in(uuids), + Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true) + )); + return count(server, query); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/PunishmentMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/PunishmentMongoRepository.java index 42ff9e7..9f0cbe2 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/PunishmentMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/PunishmentMongoRepository.java @@ -13,17 +13,21 @@ import gg.modl.backend.player.data.punishment.PunishmentNote; import gg.modl.backend.player.data.punishment.PunishmentStatus; import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentData; import gg.modl.backend.server.data.Server; +import java.util.ArrayList; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.regex.Pattern; import org.bson.Document; import org.jetbrains.annotations.Nullable; import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.aggregation.AggregationOperation; +import org.springframework.data.mongodb.core.aggregation.AggregationOptions; import org.springframework.data.mongodb.core.aggregation.AggregationResults; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; @@ -32,6 +36,26 @@ @Repository public class PunishmentMongoRepository extends AbstractServerMongoRepository { + private static final String DATA_STATUS_SUFFIX = ".status"; + private static final String DATA_DURATION_SUFFIX = ".duration"; + private static final String DATA_APPEAL_OUTCOME_SUFFIX = ".appealOutcome"; + private static final String DATA_APPEAL_TICKET_ID_SUFFIX = ".appealTicketId"; + private static final String DATA_STAT_WIPE_COMPLETED_SUFFIX = "." + PunishmentData.STAT_WIPE_COMPLETED; + private static final String DATA_STAT_WIPE_COMPLETED_AT_SUFFIX = "." + PunishmentData.STAT_WIPE_COMPLETED + "At"; + private static final String PUNISHMENTS_PREFIX = PlayerFields.PUNISHMENTS + "."; + private static final String ALIAS_PUNISHMENT_ID = "punishmentId"; + private static final String ALIAS_PLAYER_ID = "playerId"; + private static final String ALIAS_TYPE_ORDINAL = "typeOrdinal"; + private static final String ALIAS_ISSUER_NAME = "issuerName"; + private static final String ALIAS_ISSUER_ID = "issuerId"; + private static final String ALIAS_ISSUED = "issued"; + private static final String ALIAS_STARTED = "started"; + private static final String ALIAS_DATA = "data"; + private static final String ALIAS_MODIFICATIONS = "modifications"; + private static final String ALIAS_EVIDENCE = "evidence"; + private static final String ALIAS_ATTACHED_TICKET_IDS = "attachedTicketIds"; + private static final String ALIAS_USERNAMES = "usernames"; + public PunishmentMongoRepository(TenantMongoAccess tenantMongoAccess) { super(Player.class, CollectionName.PLAYERS, tenantMongoAccess); } @@ -62,7 +86,7 @@ public void appendPardon(Server server, String playerUuid, String punishmentId, Query query = punishmentQuery(playerUuid, punishmentId); Update update = new Update() .push(PlayerFields.PUNISHMENT_MODIFICATIONS, modification) - .set(PlayerFields.PUNISHMENT_DATA + ".status", status); + .set(PlayerFields.PUNISHMENT_DATA + DATA_STATUS_SUFFIX, status); if (!notes.isEmpty()) { update.push(PlayerFields.PUNISHMENT_NOTES).each(notes.toArray()); } @@ -75,7 +99,7 @@ public void appendDurationChange(Server server, String playerUuid, String punish Update update = new Update() .push(PlayerFields.PUNISHMENT_MODIFICATIONS, modification) .push(PlayerFields.PUNISHMENT_NOTES, note) - .set(PlayerFields.PUNISHMENT_DATA + ".duration", effectiveDuration); + .set(PlayerFields.PUNISHMENT_DATA + DATA_DURATION_SUFFIX, effectiveDuration); updateFirst(server, query, update); } @@ -115,6 +139,24 @@ public void setPunishmentData(Server server, String playerUuid, String punishmen updateFirst(server, query, update); } + public void markStatWipeAcknowledged(Server server, String playerUuid, String punishmentId, Date completedAt) { + Query query = punishmentQuery(playerUuid, punishmentId); + Update update = new Update() + .set(PlayerFields.PUNISHMENT_DATA + DATA_STAT_WIPE_COMPLETED_SUFFIX, true) + .set(PlayerFields.PUNISHMENT_DATA + DATA_STAT_WIPE_COMPLETED_AT_SUFFIX, completedAt); + updateFirst(server, query, update); + } + + public void appendAppealResultNote(Server server, String playerUuid, String punishmentId, + PunishmentNote note, String appealOutcome, String appealTicketId) { + Query query = punishmentQuery(playerUuid, punishmentId); + Update update = new Update() + .push(PlayerFields.PUNISHMENT_NOTES, note) + .set(PlayerFields.PUNISHMENT_DATA + DATA_APPEAL_OUTCOME_SUFFIX, appealOutcome) + .set(PlayerFields.PUNISHMENT_DATA + DATA_APPEAL_TICKET_ID_SUFFIX, appealTicketId); + updateFirst(server, query, update); + } + public void setPunishmentTickets(Server server, String playerUuid, String punishmentId, List ticketIds) { Query query = punishmentQuery(playerUuid, punishmentId); Update update = new Update().set(PlayerFields.PUNISHMENT_ATTACHED_TICKET_IDS, ticketIds); @@ -124,7 +166,7 @@ public void setPunishmentTickets(Server server, String playerUuid, String punish public boolean acknowledgePunishmentStart(Server server, String playerUuid, String punishmentId, Date started) { Update update = new Update() .set(PlayerFields.PUNISHMENT_STARTED, started) - .unset(PlayerFields.PUNISHMENT_DATA + ".status"); + .unset(PlayerFields.PUNISHMENT_DATA + DATA_STATUS_SUFFIX); return updateFirst(server, startedUnsetQuery(playerUuid, punishmentId), update).getModifiedCount() > 0; } @@ -167,8 +209,8 @@ public void applyAppealApproval(Server server, String playerUuid, String punishm Update update = new Update() .push(PlayerFields.PUNISHMENT_MODIFICATIONS, modification) .push(PlayerFields.PUNISHMENT_NOTES, note) - .set(PlayerFields.PUNISHMENT_DATA + ".appealOutcome", appealOutcome) - .set(PlayerFields.PUNISHMENT_DATA + ".appealTicketId", appealTicketId); + .set(PlayerFields.PUNISHMENT_DATA + DATA_APPEAL_OUTCOME_SUFFIX, appealOutcome) + .set(PlayerFields.PUNISHMENT_DATA + DATA_APPEAL_TICKET_ID_SUFFIX, appealTicketId); updateFirst(server, query, update); } @@ -307,7 +349,70 @@ public void unsetPunishmentStatus(Server server, String playerUuid, String punis Criteria.where(PlayerFields.MINECRAFT_UUID).is(playerUuid) .and(PlayerFields.PUNISHMENT_ID).is(punishmentId) ); - Update update = new Update().unset(PlayerFields.PUNISHMENT_DATA + ".status"); + Update update = new Update().unset(PlayerFields.PUNISHMENT_DATA + DATA_STATUS_SUFFIX); updateFirst(server, query, update); } + + public List aggregatePunishmentRows(Server server) { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where(PlayerFields.PUNISHMENTS).exists(true).ne(List.of())), + Aggregation.unwind(PlayerFields.PUNISHMENTS), + Aggregation.match(Criteria.where(PlayerFields.PUNISHMENT_TYPE_ORDINAL).ne(0) + .and(PlayerFields.PUNISHMENT_DATA_STATUS).ne(PunishmentStatus.UNSTARTED)), + Aggregation.sort(Sort.Direction.DESC, PlayerFields.PUNISHMENT_ISSUED), + Aggregation.project() + .and(PlayerFields.PUNISHMENT_ID).as(ALIAS_PUNISHMENT_ID) + .and(PlayerFields.MINECRAFT_UUID).as(ALIAS_PLAYER_ID) + .and(PlayerFields.PUNISHMENT_TYPE_ORDINAL).as(ALIAS_TYPE_ORDINAL) + .and(PlayerFields.PUNISHMENT_ISSUER_NAME).as(ALIAS_ISSUER_NAME) + .and(PlayerFields.PUNISHMENT_ISSUER_ID).as(ALIAS_ISSUER_ID) + .and(PlayerFields.PUNISHMENT_ISSUED).as(ALIAS_ISSUED) + .and(PUNISHMENTS_PREFIX + PunishmentFields.STARTED).as(ALIAS_STARTED) + .and(PUNISHMENTS_PREFIX + PunishmentFields.DATA).as(ALIAS_DATA) + .and(PUNISHMENTS_PREFIX + PunishmentFields.MODIFICATIONS).as(ALIAS_MODIFICATIONS) + .and(PUNISHMENTS_PREFIX + PunishmentFields.EVIDENCE).as(ALIAS_EVIDENCE) + .and(PUNISHMENTS_PREFIX + PunishmentFields.ATTACHED_TICKET_IDS).as(ALIAS_ATTACHED_TICKET_IDS) + .and(PlayerFields.USERNAMES).as(ALIAS_USERNAMES) + ).withOptions(AggregationOptions.builder().allowDiskUse(true).build()); + + return aggregate(server, aggregation, Document.class).getMappedResults(); + } + + public Document findPlayerByPunishmentId(Server server, String punishmentId) { + Query query = Query.query(Criteria.where(PlayerFields.PUNISHMENT_ID).is(punishmentId)); + query.fields().include(PlayerFields.ID, PlayerFields.MINECRAFT_UUID, + PlayerFields.USERNAMES, PlayerFields.PUNISHMENTS); + return serverTemplate(server).findOne(query, Document.class, collectionName()); + } + + public List findPlayersForRollback(Server server, String staffUsername, String staffId) { + List issuerMatch = new ArrayList<>(); + issuerMatch.add(Criteria.where(PunishmentFields.ISSUER_NAME).regex("^" + Pattern.quote(staffUsername) + "$", "i")); + if (staffId != null) { + issuerMatch.add(Criteria.where(PunishmentFields.ISSUER_ID).is(staffId)); + } + + Query query = Query.query(Criteria.where(PlayerFields.PUNISHMENTS).elemMatch( + new Criteria().orOperator(issuerMatch.toArray(new Criteria[0])) + )); + query.fields().include(PlayerFields.ID, PlayerFields.MINECRAFT_UUID, PlayerFields.PUNISHMENTS); + + return serverTemplate(server).find(query, Document.class, collectionName()); + } + + public List findPlayersForBulkAction(Server server, List typeOrdinals) { + Query query = Query.query(Criteria.where(PlayerFields.PUNISHMENTS).elemMatch( + Criteria.where(PunishmentFields.TYPE_ORDINAL).in(typeOrdinals) + )); + query.fields().include(PlayerFields.ID, PlayerFields.MINECRAFT_UUID, + PlayerFields.USERNAMES, PlayerFields.PUNISHMENTS); + return serverTemplate(server).find(query, Document.class, collectionName()); + } + + public void appendPunishmentModification(Server server, String playerId, String punishmentId, + Map modification) { + Update update = new Update().push(PlayerFields.PUNISHMENT_MODIFICATIONS, modification); + Query query = Query.query(Criteria.where(PlayerFields.ID).is(playerId).and(PlayerFields.PUNISHMENT_ID).is(punishmentId)); + serverTemplate(server).updateFirst(query, update, collectionName()); + } } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/SecurityEventMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/SecurityEventMongoRepository.java index 9f28be1..af03e85 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/SecurityEventMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/SecurityEventMongoRepository.java @@ -1,6 +1,7 @@ package gg.modl.backend.database.mongo.repository; import gg.modl.backend.admin.data.SecurityEvent; +import gg.modl.backend.database.CollectionName; import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; import gg.modl.backend.database.mongo.TenantMongoAccess; import gg.modl.backend.database.mongo.fields.SecurityEventFields; @@ -12,13 +13,12 @@ import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; @Repository public class SecurityEventMongoRepository extends AbstractGlobalMongoRepository { - public static final String COLLECTION_NAME = "security_events"; - public SecurityEventMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(SecurityEvent.class, COLLECTION_NAME, tenantMongoAccess); + super(SecurityEvent.class, CollectionName.SECURITY_EVENTS, tenantMongoAccess); } public List findSecurityEvents( @@ -48,16 +48,16 @@ private Query buildEventsQuery( Query query = new Query(); List criteriaList = new ArrayList<>(); - if (hasText(type)) { + if (StringUtils.hasText(type)) { criteriaList.add(Criteria.where(SecurityEventFields.TYPE).is(type)); } - if (hasText(severity)) { + if (StringUtils.hasText(severity)) { criteriaList.add(Criteria.where(SecurityEventFields.SEVERITY).is(severity)); } - if (hasText(source)) { + if (StringUtils.hasText(source)) { criteriaList.add(Criteria.where(SecurityEventFields.SOURCE).is(source)); } - if (hasText(search)) { + if (StringUtils.hasText(search)) { criteriaList.add(Criteria.where(SecurityEventFields.DESCRIPTION).regex(Pattern.quote(search), "i")); } if (startDate != null || endDate != null) { @@ -76,10 +76,6 @@ private Query buildEventsQuery( return query; } - private boolean hasText(String value) { - return value != null && !value.isBlank(); - } - public long countSecurityEvents( String type, String severity, diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerActivityRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerActivityRepository.java new file mode 100644 index 0000000..482a16d --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerActivityRepository.java @@ -0,0 +1,36 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerActivityRepository extends AbstractGlobalMongoRepository { + + public ServerActivityRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public void updateActivity(Server server, Date lastActivityAt) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(server.getId())), + new Update().set(ServerFields.LAST_ACTIVITY_AT, lastActivityAt) + ); + } + + public void updateActivityAndPlayerCount(Server server, Date lastActivityAt, long onlinePlayerCount) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(server.getId())), + new Update() + .set(ServerFields.LAST_ACTIVITY_AT, lastActivityAt) + .set(ServerFields.ONLINE_PLAYER_COUNT, onlinePlayerCount) + ); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerAdminRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerAdminRepository.java new file mode 100644 index 0000000..0ea7780 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerAdminRepository.java @@ -0,0 +1,164 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerAdminRepository extends AbstractGlobalMongoRepository { + private static final String FILTER_ALL = "all"; + private static final String FILTER_ACTIVE = "active"; + private static final String FILTER_PENDING = "pending"; + private static final String FILTER_FAILED = "failed"; + private static final String FILTER_UNVERIFIED = "unverified"; + + private static final String ORDER_ASC = "asc"; + private static final String INVALID_PLAN_SENTINEL = "__invalid_plan__"; + + private static final Set ADMIN_SORT_FIELDS = Set.of( + ServerFields.SERVER_NAME, + ServerFields.CUSTOM_DOMAIN, + ServerFields.ADMIN_EMAIL, + ServerFields.PLAN, + ServerFields.CREATED_AT, + ServerFields.UPDATED_AT, + ServerFields.USER_COUNT, + ServerFields.PROVISIONING_STATUS, + ServerFields.LAST_ACTIVITY_AT + ); + + public ServerAdminRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public List findAdminServers(String search, String plan, String status, String sortField, String sortOrder, int skip, int limit) { + Query query = buildAdminServerFilterQuery(search, plan, status); + query.with(Sort.by(resolveSortDirection(sortOrder), resolveAdminSortField(sortField))); + query.skip(skip).limit(limit); + query.fields() + .include(ServerFields.SERVER_NAME) + .include(ServerFields.CUSTOM_DOMAIN) + .include(ServerFields.ADMIN_EMAIL) + .include(ServerFields.PLAN) + .include(ServerFields.EMAIL_VERIFIED) + .include(ServerFields.PROVISIONING_STATUS) + .include(ServerFields.CREATED_AT) + .include(ServerFields.UPDATED_AT) + .include(ServerFields.USER_COUNT) + .include(ServerFields.TICKET_COUNT) + .include(ServerFields.LAST_ACTIVITY_AT); + return find(query); + } + + private Query buildAdminServerFilterQuery(String search, String plan, String status) { + Query query = new Query(); + List criteriaList = new ArrayList<>(); + + if (search != null && !search.trim().isEmpty()) { + String escapedSearch = Pattern.quote(search.trim()); + criteriaList.add(new Criteria().orOperator( + Criteria.where(ServerFields.SERVER_NAME).regex(escapedSearch, "i"), + Criteria.where(ServerFields.CUSTOM_DOMAIN).regex(escapedSearch, "i"), + Criteria.where(ServerFields.ADMIN_EMAIL).regex(escapedSearch, "i") + )); + } + + if (plan != null && !FILTER_ALL.equals(plan)) { + try { + criteriaList.add(Criteria.where(ServerFields.PLAN).is(ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)))); + } catch (IllegalArgumentException ignored) { + criteriaList.add(Criteria.where(ServerFields.PLAN).is(INVALID_PLAN_SENTINEL)); + } + } + + if (status != null && !FILTER_ALL.equals(status)) { + switch (status) { + case FILTER_ACTIVE -> { + criteriaList.add(Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED)); + criteriaList.add(Criteria.where(ServerFields.EMAIL_VERIFIED).is(true)); + } + case FILTER_PENDING -> criteriaList.add(Criteria.where(ServerFields.PROVISIONING_STATUS) + .in(ProvisioningStatus.PENDING, ProvisioningStatus.IN_PROGRESS)); + case FILTER_FAILED -> criteriaList.add(Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.FAILED)); + case FILTER_UNVERIFIED -> criteriaList.add(Criteria.where(ServerFields.EMAIL_VERIFIED).is(false)); + default -> { + } + } + } + + if (!criteriaList.isEmpty()) { + query.addCriteria(new Criteria().andOperator(criteriaList.toArray(new Criteria[0]))); + } + return query; + } + + private Sort.Direction resolveSortDirection(String sortOrder) { + return ORDER_ASC.equalsIgnoreCase(sortOrder) ? Sort.Direction.ASC : Sort.Direction.DESC; + } + + private String resolveAdminSortField(String sortField) { + return ADMIN_SORT_FIELDS.contains(sortField) ? sortField : ServerFields.CREATED_AT; + } + + public long countAdminServers(String search, String plan, String status) { + return count(buildAdminServerFilterQuery(search, plan, status)); + } + + public boolean deleteByServerId(String serverId) { + return remove(Query.query(Criteria.where(ServerFields.ID).is(serverId))).getDeletedCount() > 0; + } + + public long deleteByServerIds(List serverIds) { + return remove(Query.query(Criteria.where(ServerFields.ID).in(serverIds))).getDeletedCount(); + } + + public long bulkSuspend(List serverIds, Date updatedAt) { + Update update = new Update() + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED) + .set(ServerFields.UPDATED_AT, updatedAt); + return updateMulti(Query.query(Criteria.where(ServerFields.ID).in(serverIds)), update).getModifiedCount(); + } + + public long bulkActivate(List serverIds, Date updatedAt) { + Update update = new Update() + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.IN_PROGRESS) + .set(ServerFields.EMAIL_VERIFIED, true) + .set(ServerFields.UPDATED_AT, updatedAt); + return updateMulti(Query.query(Criteria.where(ServerFields.ID).in(serverIds)), update).getModifiedCount(); + } + + public Optional applyFieldUpdate(String serverId, Update update) { + if (update.getUpdateObject().isEmpty()) { + return findById(serverId); + } + return Optional.ofNullable(findAndModify( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + update, + FindAndModifyOptions.options().returnNew(true) + )); + } + + public long bulkUpdatePlan(List serverIds, ServerPlan plan, Date updatedAt) { + Update update = new Update() + .set(ServerFields.PLAN, plan) + .set(ServerFields.UPDATED_AT, updatedAt); + return updateMulti(Query.query(Criteria.where(ServerFields.ID).in(serverIds)), update).getModifiedCount(); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerBetaTesterRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerBetaTesterRepository.java new file mode 100644 index 0000000..798382d --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerBetaTesterRepository.java @@ -0,0 +1,71 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerBetaTesterRepository extends AbstractGlobalMongoRepository { + + public ServerBetaTesterRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public List findBetaTesters(String search, int skip, int limit) { + Query query = buildBetaTesterQuery(search); + query.with(Sort.by(Sort.Direction.DESC, ServerFields.BETA_TESTER_CREATED_AT)); + query.skip(skip).limit(limit); + return find(query); + } + + public long countBetaTesters(String search) { + return count(buildBetaTesterQuery(search)); + } + + public List findAllBetaTesters() { + return find(Query.query(Criteria.where(ServerFields.BETA_TESTER).is(true))); + } + + private Query buildBetaTesterQuery(String search) { + Criteria betaCriteria = Criteria.where(ServerFields.BETA_TESTER_CREATED_AT).exists(true); + if (search == null || search.trim().isEmpty()) { + return new Query(betaCriteria); + } + String escapedSearch = Pattern.quote(search.trim()); + return new Query(new Criteria().andOperator( + betaCriteria, + new Criteria().orOperator( + Criteria.where(ServerFields.SERVER_NAME).regex(escapedSearch, "i"), + Criteria.where(ServerFields.CUSTOM_DOMAIN).regex(escapedSearch, "i"), + Criteria.where(ServerFields.ADMIN_EMAIL).regex(escapedSearch, "i") + ) + )); + } + + public Optional updateBetaState(String serverId, ServerPlan plan, SubscriptionStatus subscriptionStatus, boolean betaTester) { + Update update = new Update() + .set(ServerFields.PLAN, plan) + .set(ServerFields.SUBSCRIPTION_STATUS, subscriptionStatus) + .set(ServerFields.BETA_TESTER, betaTester) + .set(ServerFields.UPDATED_AT, new Date()); + return Optional.ofNullable(findAndModify( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + update, + FindAndModifyOptions.options().returnNew(true) + )); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerCredentialRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerCredentialRepository.java new file mode 100644 index 0000000..68e1a16 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerCredentialRepository.java @@ -0,0 +1,43 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerCredentialRepository extends AbstractGlobalMongoRepository { + + public ServerCredentialRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public void updateAdminEmail(String serverId, String adminEmail) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.ADMIN_EMAIL, adminEmail) + .set(ServerFields.UPDATED_AT, new Date()) + ); + } + + public void updateApiKey(String serverId, String apiKey) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update().set(ServerFields.API_KEY, apiKey) + ); + } + + public void clearApiKey(String serverId) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update().unset(ServerFields.API_KEY) + ); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerCustomDomainRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerCustomDomainRepository.java new file mode 100644 index 0000000..03056bd --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerCustomDomainRepository.java @@ -0,0 +1,81 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.CustomDomainStatus; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.List; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerCustomDomainRepository extends AbstractGlobalMongoRepository { + + public ServerCustomDomainRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public boolean isCustomDomainOwnedByAnotherServer(String customDomain, String serverId) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.CUSTOM_DOMAIN_OVERRIDE).is(customDomain), + Criteria.where(ServerFields.ID).ne(serverId) + )); + return exists(query); + } + + public boolean isCustomDomainClaimed(String customDomain) { + return exists(Query.query(Criteria.where(ServerFields.CUSTOM_DOMAIN_OVERRIDE).is(customDomain))); + } + + public List findAllWithCustomDomainOverride() { + return find(Query.query(Criteria.where(ServerFields.CUSTOM_DOMAIN_OVERRIDE).exists(true).ne(null))); + } + + public void updateCustomDomain(String serverId, String customDomain, CustomDomainStatus status, + String cloudflareHostnameId, String error) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.CUSTOM_DOMAIN_OVERRIDE, customDomain) + .set(ServerFields.CUSTOM_DOMAIN_STATUS, status.name()) + .set(ServerFields.CUSTOM_DOMAIN_CLOUDFLARE_ID, cloudflareHostnameId) + .set(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED, new Date()) + .set(ServerFields.CUSTOM_DOMAIN_ERROR, error) + .set(ServerFields.UPDATED_AT, new Date()) + ); + } + + public boolean updateCustomDomainStatus(String serverId, String expectedDomain, CustomDomainStatus status, + String cloudflareHostnameId, String error) { + return updateFirst( + Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + Criteria.where(ServerFields.CUSTOM_DOMAIN_OVERRIDE).is(expectedDomain) + )), + new Update() + .set(ServerFields.CUSTOM_DOMAIN_STATUS, status.name()) + .set(ServerFields.CUSTOM_DOMAIN_CLOUDFLARE_ID, cloudflareHostnameId) + .set(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED, new Date()) + .set(ServerFields.CUSTOM_DOMAIN_ERROR, error) + .set(ServerFields.UPDATED_AT, new Date()) + ).getMatchedCount() > 0; + } + + public void clearCustomDomain(String serverId) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .unset(ServerFields.CUSTOM_DOMAIN_OVERRIDE) + .unset(ServerFields.CUSTOM_DOMAIN_STATUS) + .unset(ServerFields.CUSTOM_DOMAIN_CLOUDFLARE_ID) + .unset(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED) + .unset(ServerFields.CUSTOM_DOMAIN_ERROR) + .set(ServerFields.UPDATED_AT, new Date()) + ); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerInstanceSnapshotMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerInstanceSnapshotMongoRepository.java index 2dd279a..839a5ba 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/ServerInstanceSnapshotMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerInstanceSnapshotMongoRepository.java @@ -38,7 +38,7 @@ public void upsertServerEntry(Date date, String serverId, String serverName, int private boolean tryPersistServerEntry(Date date, ServerInstanceSnapshot.ServerEntry entry, Date createdAt) { Query updateQuery = Query.query( Criteria.where(ServerInstanceSnapshotFields.DATE).is(date) - .and("servers").elemMatch( + .and(ServerInstanceSnapshotFields.SERVERS).elemMatch( Criteria.where("serverId").is(entry.getServerId()).and("serverName").is(entry.getServerName())) ); Update updateExisting = new Update() @@ -54,7 +54,7 @@ private boolean tryPersistServerEntry(Date date, ServerInstanceSnapshot.ServerEn Query insertQuery = Query.query( Criteria.where(ServerInstanceSnapshotFields.DATE).is(date) - .and("servers").not().elemMatch( + .and(ServerInstanceSnapshotFields.SERVERS).not().elemMatch( Criteria.where("serverId").is(entry.getServerId()).and("serverName").is(entry.getServerName())) ); Update pushNew = new Update() diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerLogMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerLogMongoRepository.java index 7b95abb..0ec248a 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/ServerLogMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerLogMongoRepository.java @@ -4,7 +4,7 @@ import gg.modl.backend.database.mongo.AbstractServerMongoRepository; import gg.modl.backend.database.mongo.TenantMongoAccess; import gg.modl.backend.database.mongo.fields.ServerLogFields; -import gg.modl.backend.log.data.SystemLog; +import gg.modl.backend.log.data.ServerLog; import gg.modl.backend.server.data.Server; import java.util.List; import org.springframework.data.domain.Sort; @@ -12,12 +12,12 @@ import org.springframework.stereotype.Repository; @Repository -public class ServerLogMongoRepository extends AbstractServerMongoRepository { +public class ServerLogMongoRepository extends AbstractServerMongoRepository { public ServerLogMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(SystemLog.class, CollectionName.LOGS, tenantMongoAccess); + super(ServerLog.class, CollectionName.LOGS, tenantMongoAccess); } - public List findRecent(Server server, int limit) { + public List findRecent(Server server, int limit) { Query query = new Query() .with(Sort.by(Sort.Direction.DESC, ServerLogFields.CREATED)) .limit(limit); diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerLookupRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerLookupRepository.java new file mode 100644 index 0000000..279cd7e --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerLookupRepository.java @@ -0,0 +1,90 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.SubscriptionStatus; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerLookupRepository extends AbstractGlobalMongoRepository { + private static final String DOMAIN_STATUS_ACTIVE = "ACTIVE"; + + public ServerLookupRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public Optional findByCustomDomain(String customDomain) { + return findOne(Query.query(Criteria.where(ServerFields.CUSTOM_DOMAIN).is(customDomain))); + } + + public Optional findByActiveCustomDomainOverride(String domain) { + Criteria criteria = new Criteria().andOperator( + Criteria.where(ServerFields.CUSTOM_DOMAIN_OVERRIDE).is(domain), + Criteria.where(ServerFields.CUSTOM_DOMAIN_STATUS).is(DOMAIN_STATUS_ACTIVE) + ); + return findOne(new Query(criteria)); + } + + public Optional findMatchingIdentity(String email, String serverName, String subdomain) { + Criteria criteria = new Criteria().orOperator( + Criteria.where(ServerFields.ADMIN_EMAIL).is(email), + Criteria.where(ServerFields.SERVER_NAME).is(serverName), + Criteria.where(ServerFields.CUSTOM_DOMAIN).is(subdomain) + ); + return findOne(new Query(criteria)); + } + + public Optional findByDatabaseName(String databaseName) { + return findOne(Query.query(Criteria.where(ServerFields.DATABASE_NAME).is(databaseName))); + } + + public Optional findByApiKey(String apiKey) { + return findOne(Query.query(Criteria.where(ServerFields.API_KEY).is(apiKey))); + } + + public boolean existsByAdminEmailExcludingId(String adminEmail, String excludedServerId) { + Criteria criteria = Criteria.where(ServerFields.ADMIN_EMAIL) + .regex("^" + Pattern.quote(adminEmail) + "$", "i") + .and(ServerFields.ID).ne(excludedServerId); + return exists(Query.query(criteria)); + } + + public Optional findByEmailVerificationToken(String token) { + return findOne(Query.query(Criteria.where(ServerFields.EMAIL_VERIFICATION_TOKEN).is(token))); + } + + public Optional findByProvisioningSignInToken(String token) { + return findOne(Query.query(Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN).is(token))); + } + + public Optional findByCliSetupToken(String token) { + return findOne(Query.query(Criteria.where(ServerFields.CLI_SETUP_TOKEN).is(token))); + } + + public Optional findByStripeCustomerId(String customerId) { + if (customerId == null) { + return Optional.empty(); + } + return findOne(Query.query(Criteria.where(ServerFields.STRIPE_CUSTOMER_ID).is(customerId))); + } + + public Optional findByStripeSubscriptionId(String subscriptionId) { + return findOne(Query.query(Criteria.where(ServerFields.STRIPE_SUBSCRIPTION_ID).is(subscriptionId))); + } + + public List findCancelledWithPeriodEnd() { + Criteria criteria = new Criteria().andOperator( + Criteria.where(ServerFields.SUBSCRIPTION_STATUS).is(SubscriptionStatus.CANCELED), + Criteria.where(ServerFields.CURRENT_PERIOD_END).exists(true).ne(null) + ); + return find(new Query(criteria)); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerMetricsRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerMetricsRepository.java new file mode 100644 index 0000000..719c15b --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerMetricsRepository.java @@ -0,0 +1,259 @@ +package gg.modl.backend.database.mongo.repository; + +import static gg.modl.backend.database.mongo.MongoAggregationResults.extractFacetCount; +import static gg.modl.backend.database.mongo.MongoAggregationResults.extractLong; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.bson.Document; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.DateOperators; +import org.springframework.data.mongodb.core.aggregation.ProjectionOperation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerMetricsRepository extends AbstractGlobalMongoRepository { + private static final String ALIAS_TOTAL = "total"; + private static final String ALIAS_TOTAL_USERS = "totalUsers"; + private static final String ALIAS_TOTAL_TICKETS = "totalTickets"; + private static final String ALIAS_VALUE = "value"; + private static final String ALIAS_NAME = "name"; + private static final String ALIAS_DATE = "date"; + private static final String ALIAS_SERVERS = "servers"; + private static final String ALIAS_VALUE_SOURCE = "valueSource"; + + private static final String METRIC_USERS = "users"; + private static final String METRIC_TICKETS = "tickets"; + + public ServerMetricsRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public long countAll() { + return count(new Query()); + } + + public long countByProvisioningStatus(ProvisioningStatus status) { + return count(Query.query(Criteria.where(ServerFields.PROVISIONING_STATUS).is(status))); + } + + public long countActiveSince(Date activityCutoff) { + return count(Query.query(Criteria.where(ServerFields.LAST_ACTIVITY_AT).gte(activityCutoff))); + } + + public UsageTotals getUsageTotals() { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.group() + .sum(ServerFields.USER_COUNT).as(ALIAS_TOTAL_USERS) + .sum(ServerFields.TICKET_COUNT).as(ALIAS_TOTAL_TICKETS) + ); + Document result = aggregate(aggregation, Document.class).getUniqueMappedResult(); + return new UsageTotals( + extractLong(result, ALIAS_TOTAL_USERS), + extractLong(result, ALIAS_TOTAL_TICKETS) + ); + } + + public List aggregatePlanCounts() { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.group(ServerFields.PLAN).count().as(ALIAS_VALUE), + Aggregation.project().and("_id").as(ALIAS_NAME).and(ALIAS_VALUE).as(ALIAS_VALUE) + ); + return aggregate(aggregation, NameValueResult.class).getMappedResults(); + } + + public List aggregateProvisioningStatusCounts() { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.group(ServerFields.PROVISIONING_STATUS).count().as(ALIAS_VALUE), + Aggregation.project().and("_id").as(ALIAS_NAME).and(ALIAS_VALUE).as(ALIAS_VALUE) + ); + return aggregate(aggregation, NameValueResult.class).getMappedResults(); + } + + public List findRegistrationTrend(Date startDate) { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where(ServerFields.CREATED_AT).gte(startDate)), + Aggregation.project() + .and(DateOperators.DateToString.dateOf(ServerFields.CREATED_AT).toString("%Y-%m-%d")).as(ALIAS_DATE), + Aggregation.group(ALIAS_DATE).count().as(ALIAS_SERVERS), + Aggregation.sort(Sort.Direction.ASC, "_id"), + Aggregation.project().and("_id").as(ALIAS_DATE).and(ALIAS_SERVERS).as(ALIAS_SERVERS) + ); + return aggregate(aggregation, DateServersResult.class).getMappedResults(); + } + + public List findTopCompletedVerifiedByUserCount(int limit) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), + Criteria.where(ServerFields.EMAIL_VERIFIED).is(true), + Criteria.where(ServerFields.USER_COUNT).gt(0) + )); + query.with(Sort.by(Sort.Direction.DESC, ServerFields.USER_COUNT)); + query.limit(limit); + return find(query); + } + + public List aggregateHistoricalMetric(String metric, Date startDate) { + ProjectionOperation projectDateStage = Aggregation.project() + .and(DateOperators.DateToString.dateOf(ServerFields.CREATED_AT).toString("%Y-%m-%d")).as(ALIAS_DATE); + + if (METRIC_USERS.equals(metric) || METRIC_TICKETS.equals(metric)) { + String sumField = METRIC_USERS.equals(metric) ? ServerFields.USER_COUNT : ServerFields.TICKET_COUNT; + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where(ServerFields.CREATED_AT).gte(startDate)), + projectDateStage.and(sumField).as(ALIAS_VALUE_SOURCE), + Aggregation.group(ALIAS_DATE).sum(ALIAS_VALUE_SOURCE).as(ALIAS_VALUE), + Aggregation.sort(Sort.Direction.ASC, "_id"), + Aggregation.project().and("_id").as(ALIAS_DATE).and(ALIAS_VALUE).as(ALIAS_VALUE) + ); + return aggregate(aggregation, DateValueResult.class).getMappedResults(); + } + + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where(ServerFields.CREATED_AT).gte(startDate)), + projectDateStage, + Aggregation.group(ALIAS_DATE).count().as(ALIAS_VALUE), + Aggregation.sort(Sort.Direction.ASC, "_id"), + Aggregation.project().and("_id").as(ALIAS_DATE).and(ALIAS_VALUE).as(ALIAS_VALUE) + ); + return aggregate(aggregation, DateValueResult.class).getMappedResults(); + } + + public DashboardStats aggregateDashboardStats(Date startDate, Date previousStartDate) { + Document facet = new Document() + .append(ALIAS_TOTAL, List.of(new Document("$count", "n"))) + .append("active", List.of( + new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED.name()) + .append(ServerFields.EMAIL_VERIFIED, true)), + new Document("$count", "n") + )) + .append("withUsers", List.of( + new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED.name()) + .append(ServerFields.USER_COUNT, new Document("$gt", 0))), + new Document("$count", "n") + )) + .append("currentPeriod", List.of( + new Document("$match", new Document(ServerFields.CREATED_AT, new Document("$gte", startDate))), + new Document("$count", "n") + )) + .append("previousPeriod", List.of( + new Document("$match", new Document(ServerFields.CREATED_AT, + new Document("$gte", previousStartDate).append("$lt", startDate))), + new Document("$count", "n") + )) + .append("usage", List.of( + new Document("$group", new Document("_id", null) + .append(ALIAS_TOTAL_USERS, new Document("$sum", "$" + ServerFields.USER_COUNT)) + .append(ALIAS_TOTAL_TICKETS, new Document("$sum", "$" + ServerFields.TICKET_COUNT))) + )); + + List pipeline = List.of(new Document("$facet", facet)); + List results = globalTemplate().getCollection(collectionName()) + .aggregate(pipeline) + .into(new ArrayList<>()); + + if (results.isEmpty()) { + return new DashboardStats(0, 0, 0, 0, 0, 0, 0); + } + + Document doc = results.get(0); + long total = extractFacetCount(doc, ALIAS_TOTAL); + long active = extractFacetCount(doc, "active"); + long withUsers = extractFacetCount(doc, "withUsers"); + long currentPeriod = extractFacetCount(doc, "currentPeriod"); + long previousPeriod = extractFacetCount(doc, "previousPeriod"); + + long totalUsers = 0; + long totalTickets = 0; + List usageList = doc.getList("usage", Document.class, List.of()); + if (!usageList.isEmpty() && usageList.getFirst() instanceof Document usageDoc) { + totalUsers = extractLong(usageDoc, ALIAS_TOTAL_USERS); + totalTickets = extractLong(usageDoc, ALIAS_TOTAL_TICKETS); + } + + return new DashboardStats(total, active, withUsers, currentPeriod, previousPeriod, totalUsers, totalTickets); + } + + public MonitoringServerStats aggregateMonitoringServerStats(Date fiveMinutesAgo, Date oneWeekAgo) { + Document facet = new Document() + .append(ALIAS_TOTAL, List.of(new Document("$count", "n"))) + .append("active", List.of( + new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED.name()) + .append(ServerFields.EMAIL_VERIFIED, true)), + new Document("$count", "n") + )) + .append("concurrent", List.of( + new Document("$match", new Document(ServerFields.LAST_ACTIVITY_AT, new Document("$gte", fiveMinutesAgo))), + new Document("$count", "n") + )) + .append("concurrentPlayers", List.of( + new Document("$match", new Document(ServerFields.LAST_ACTIVITY_AT, new Document("$gte", fiveMinutesAgo))), + new Document("$group", new Document("_id", null) + .append("sum", new Document("$sum", "$" + ServerFields.ONLINE_PLAYER_COUNT))) + )) + .append("pending", List.of( + new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, + new Document("$in", List.of(ProvisioningStatus.PENDING.name(), ProvisioningStatus.IN_PROGRESS.name())))), + new Document("$count", "n") + )) + .append("failed", List.of( + new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED.name())), + new Document("$count", "n") + )) + .append("recentRegistrations", List.of( + new Document("$match", new Document(ServerFields.CREATED_AT, new Document("$gte", oneWeekAgo))), + new Document("$count", "n") + )); + + List pipeline = List.of(new Document("$facet", facet)); + List results = globalTemplate().getCollection(collectionName()) + .aggregate(pipeline) + .into(new ArrayList<>()); + + if (results.isEmpty()) { + return new MonitoringServerStats(0, 0, 0, 0, 0, 0, 0); + } + + Document doc = results.get(0); + long concurrentPlayers = 0; + List cpList = doc.getList("concurrentPlayers", Document.class, List.of()); + if (!cpList.isEmpty() && cpList.getFirst() instanceof Document cpDoc) { + concurrentPlayers = extractLong(cpDoc, "sum"); + } + + return new MonitoringServerStats( + extractFacetCount(doc, ALIAS_TOTAL), + extractFacetCount(doc, "active"), + extractFacetCount(doc, "concurrent"), + concurrentPlayers, + extractFacetCount(doc, "pending"), + extractFacetCount(doc, "failed"), + extractFacetCount(doc, "recentRegistrations") + ); + } + + public record MonitoringServerStats(long total, long active, long concurrent, long concurrentPlayers, + long pending, long failed, long recentRegistrations) {} + + public record DashboardStats(long totalServers, long activeServers, long serversWithData, + long currentPeriodServers, long previousPeriodServers, + long totalUsers, long totalTickets) {} + + public record UsageTotals(long totalUsers, long totalTickets) {} + + public record NameValueResult(String name, int value) {} + + public record DateServersResult(String date, int servers) {} + + public record DateValueResult(String date, long value) {} +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerMongoRepository.java deleted file mode 100644 index e062ff8..0000000 --- a/src/main/java/gg/modl/backend/database/mongo/repository/ServerMongoRepository.java +++ /dev/null @@ -1,1127 +0,0 @@ -package gg.modl.backend.database.mongo.repository; - -import static gg.modl.backend.database.mongo.MongoAggregationResults.extractFacetCount; -import static gg.modl.backend.database.mongo.MongoAggregationResults.extractLong; - -import com.mongodb.client.result.UpdateResult; -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; - -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.ServerFields; -import gg.modl.backend.server.data.CustomDomainStatus; -import gg.modl.backend.server.data.ProvisioningStatus; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.data.SubscriptionStatus; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.regex.Pattern; -import org.bson.Document; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.FindAndModifyOptions; -import org.springframework.data.mongodb.core.aggregation.Aggregation; -import org.springframework.data.mongodb.core.aggregation.AggregationExpression; -import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; -import org.springframework.data.mongodb.core.aggregation.DateOperators; -import org.springframework.data.mongodb.core.aggregation.ProjectionOperation; -import org.springframework.data.mongodb.core.aggregation.SetOperation; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class ServerMongoRepository extends AbstractGlobalMongoRepository { - private static final String FILTER_ALL = "all"; - private static final String FILTER_ACTIVE = "active"; - private static final String FILTER_PENDING = "pending"; - private static final String FILTER_FAILED = "failed"; - private static final String FILTER_UNVERIFIED = "unverified"; - private static final String DOMAIN_STATUS_ACTIVE = "ACTIVE"; - - private static final String ALIAS_TOTAL = "total"; - private static final String ALIAS_TOTAL_USERS = "totalUsers"; - private static final String ALIAS_TOTAL_TICKETS = "totalTickets"; - private static final String ALIAS_VALUE = "value"; - private static final String ALIAS_NAME = "name"; - private static final String ALIAS_DATE = "date"; - private static final String ALIAS_SERVERS = "servers"; - private static final String ALIAS_VALUE_SOURCE = "valueSource"; - - private static final String METRIC_USERS = "users"; - private static final String METRIC_TICKETS = "tickets"; - - private static final String ORDER_ASC = "asc"; - private static final String INVALID_PLAN_SENTINEL = "__invalid_plan__"; - private static final String RESET_MESSAGE = "Database reset - awaiting reprovisioning"; - - private static final Set ADMIN_SORT_FIELDS = Set.of( - ServerFields.SERVER_NAME, - ServerFields.CUSTOM_DOMAIN, - ServerFields.ADMIN_EMAIL, - ServerFields.PLAN, - ServerFields.CREATED_AT, - ServerFields.UPDATED_AT, - ServerFields.USER_COUNT, - ServerFields.PROVISIONING_STATUS, - ServerFields.LAST_ACTIVITY_AT - ); - - public ServerMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); - } - - public long countAll() { - return count(new Query()); - } - - public Optional findByCustomDomain(String customDomain) { - return findOne(Query.query(Criteria.where(ServerFields.CUSTOM_DOMAIN).is(customDomain))); - } - - public Optional findByActiveCustomDomainOverride(String domain) { - Criteria criteria = new Criteria().andOperator( - Criteria.where(ServerFields.CUSTOM_DOMAIN_OVERRIDE).is(domain), - Criteria.where(ServerFields.CUSTOM_DOMAIN_STATUS).is(DOMAIN_STATUS_ACTIVE) - ); - return findOne(new Query(criteria)); - } - - public Optional findMatchingIdentity(String email, String serverName, String subdomain) { - Criteria criteria = new Criteria().orOperator( - Criteria.where(ServerFields.ADMIN_EMAIL).is(email), - Criteria.where(ServerFields.SERVER_NAME).is(serverName), - Criteria.where(ServerFields.CUSTOM_DOMAIN).is(subdomain) - ); - return findOne(new Query(criteria)); - } - - public Optional findByDatabaseName(String databaseName) { - return findOne(Query.query(Criteria.where(ServerFields.DATABASE_NAME).is(databaseName))); - } - - public Optional findByApiKey(String apiKey) { - return findOne(Query.query(Criteria.where(ServerFields.API_KEY).is(apiKey))); - } - - public boolean existsByAdminEmailExcludingId(String adminEmail, String excludedServerId) { - Criteria criteria = Criteria.where(ServerFields.ADMIN_EMAIL) - .regex("^" + Pattern.quote(adminEmail) + "$", "i") - .and(ServerFields.ID).ne(excludedServerId); - return exists(Query.query(criteria)); - } - - public Optional findByEmailVerificationToken(String token) { - return findOne(Query.query(Criteria.where(ServerFields.EMAIL_VERIFICATION_TOKEN).is(token))); - } - - public Optional verifyEmailTokenAtomically(String token) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.EMAIL_VERIFICATION_TOKEN).is(token), - Criteria.where(ServerFields.EMAIL_VERIFIED).is(false), - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.PENDING), - noCleanupClaimCriteria() - )); - - Update update = new Update() - .set(ServerFields.EMAIL_VERIFIED, true) - .unset(ServerFields.EMAIL_VERIFICATION_TOKEN) - .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.IN_PROGRESS) - .set(ServerFields.UPDATED_AT, new Date()); - - return Optional.ofNullable(findAndModify( - query, - update, - FindAndModifyOptions.options().returnNew(true) - )); - } - - public Optional findByProvisioningSignInToken(String token) { - return findOne(Query.query(Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN).is(token))); - } - - public Optional consumeProvisioningSignInToken(String token, Date now) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN).is(token), - Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN_EXPIRES_AT).gt(now), - Criteria.where(ServerFields.EMAIL_VERIFIED).is(true), - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED) - )); - Update update = new Update() - .unset(ServerFields.PROVISIONING_SIGN_IN_TOKEN) - .unset(ServerFields.PROVISIONING_SIGN_IN_TOKEN_EXPIRES_AT) - .set(ServerFields.UPDATED_AT, now); - return Optional.ofNullable(findAndModify(query, update, FindAndModifyOptions.options().returnNew(true))); - } - - public Optional findByCliSetupToken(String token) { - return findOne(Query.query(Criteria.where(ServerFields.CLI_SETUP_TOKEN).is(token))); - } - - public Optional findByStripeCustomerId(String customerId) { - if (customerId == null) { - return Optional.empty(); - } - return findOne(Query.query(Criteria.where(ServerFields.STRIPE_CUSTOMER_ID).is(customerId))); - } - - public Optional findByStripeSubscriptionId(String subscriptionId) { - return findOne(Query.query(Criteria.where(ServerFields.STRIPE_SUBSCRIPTION_ID).is(subscriptionId))); - } - - public long countCompletedAndVerified() { - return count(Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), - Criteria.where(ServerFields.EMAIL_VERIFIED).is(true) - ))); - } - - public long countByProvisioningStatus(ProvisioningStatus status) { - return count(Query.query(Criteria.where(ServerFields.PROVISIONING_STATUS).is(status))); - } - - public long countByProvisioningStatuses(ProvisioningStatus... statuses) { - return count(Query.query(Criteria.where(ServerFields.PROVISIONING_STATUS).in((Object[]) statuses))); - } - - public long countActiveSince(Date activityCutoff) { - return count(Query.query(Criteria.where(ServerFields.LAST_ACTIVITY_AT).gte(activityCutoff))); - } - - public long countCompletedWithUsers() { - return count(Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), - Criteria.where(ServerFields.USER_COUNT).gt(0) - ))); - } - - public long countCreatedSince(Date startDate) { - return count(Query.query(Criteria.where(ServerFields.CREATED_AT).gte(startDate))); - } - - public long countCreatedBetween(Date startDate, Date endDate) { - return count(Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.CREATED_AT).gte(startDate), - Criteria.where(ServerFields.CREATED_AT).lt(endDate) - ))); - } - - public long sumOnlinePlayersSince(Date activityCutoff) { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.match(Criteria.where(ServerFields.LAST_ACTIVITY_AT).gte(activityCutoff)), - Aggregation.group().sum(ServerFields.ONLINE_PLAYER_COUNT).as(ALIAS_TOTAL) - ); - Document result = aggregate(aggregation, Document.class).getUniqueMappedResult(); - return extractLong(result, ALIAS_TOTAL); - } - - public UsageTotals getUsageTotals() { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group() - .sum(ServerFields.USER_COUNT).as(ALIAS_TOTAL_USERS) - .sum(ServerFields.TICKET_COUNT).as(ALIAS_TOTAL_TICKETS) - ); - Document result = aggregate(aggregation, Document.class).getUniqueMappedResult(); - return new UsageTotals( - extractLong(result, ALIAS_TOTAL_USERS), - extractLong(result, ALIAS_TOTAL_TICKETS) - ); - } - - public List aggregatePlanCounts() { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group(ServerFields.PLAN).count().as(ALIAS_VALUE), - Aggregation.project().and("_id").as(ALIAS_NAME).and(ALIAS_VALUE).as(ALIAS_VALUE) - ); - return aggregate(aggregation, NameValueResult.class).getMappedResults(); - } - - public List aggregateProvisioningStatusCounts() { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group(ServerFields.PROVISIONING_STATUS).count().as(ALIAS_VALUE), - Aggregation.project().and("_id").as(ALIAS_NAME).and(ALIAS_VALUE).as(ALIAS_VALUE) - ); - return aggregate(aggregation, NameValueResult.class).getMappedResults(); - } - - public List findRegistrationTrend(Date startDate) { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.match(Criteria.where(ServerFields.CREATED_AT).gte(startDate)), - Aggregation.project() - .and(DateOperators.DateToString.dateOf(ServerFields.CREATED_AT).toString("%Y-%m-%d")).as(ALIAS_DATE), - Aggregation.group(ALIAS_DATE).count().as(ALIAS_SERVERS), - Aggregation.sort(Sort.Direction.ASC, "_id"), - Aggregation.project().and("_id").as(ALIAS_DATE).and(ALIAS_SERVERS).as(ALIAS_SERVERS) - ); - return aggregate(aggregation, DateServersResult.class).getMappedResults(); - } - - public List findTopCompletedVerifiedByUserCount(int limit) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), - Criteria.where(ServerFields.EMAIL_VERIFIED).is(true), - Criteria.where(ServerFields.USER_COUNT).gt(0) - )); - query.with(Sort.by(Sort.Direction.DESC, ServerFields.USER_COUNT)); - query.limit(limit); - return find(query); - } - - public List findAdminServers(String search, String plan, String status, String sortField, String sortOrder, int skip, int limit) { - Query query = buildAdminServerFilterQuery(search, plan, status); - query.with(Sort.by(resolveSortDirection(sortOrder), resolveAdminSortField(sortField))); - query.skip(skip).limit(limit); - query.fields() - .include(ServerFields.SERVER_NAME) - .include(ServerFields.CUSTOM_DOMAIN) - .include(ServerFields.ADMIN_EMAIL) - .include(ServerFields.PLAN) - .include(ServerFields.EMAIL_VERIFIED) - .include(ServerFields.PROVISIONING_STATUS) - .include(ServerFields.CREATED_AT) - .include(ServerFields.UPDATED_AT) - .include(ServerFields.USER_COUNT) - .include(ServerFields.TICKET_COUNT) - .include(ServerFields.LAST_ACTIVITY_AT); - return find(query); - } - - private Query buildAdminServerFilterQuery(String search, String plan, String status) { - Query query = new Query(); - List criteriaList = new ArrayList<>(); - - if (search != null && !search.trim().isEmpty()) { - String escapedSearch = Pattern.quote(search.trim()); - criteriaList.add(new Criteria().orOperator( - Criteria.where(ServerFields.SERVER_NAME).regex(escapedSearch, "i"), - Criteria.where(ServerFields.CUSTOM_DOMAIN).regex(escapedSearch, "i"), - Criteria.where(ServerFields.ADMIN_EMAIL).regex(escapedSearch, "i") - )); - } - - if (plan != null && !FILTER_ALL.equals(plan)) { - try { - criteriaList.add(Criteria.where(ServerFields.PLAN).is(ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)))); - } catch (IllegalArgumentException ignored) { - criteriaList.add(Criteria.where(ServerFields.PLAN).is(INVALID_PLAN_SENTINEL)); - } - } - - if (status != null && !FILTER_ALL.equals(status)) { - switch (status) { - case FILTER_ACTIVE -> { - criteriaList.add(Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED)); - criteriaList.add(Criteria.where(ServerFields.EMAIL_VERIFIED).is(true)); - } - case FILTER_PENDING -> criteriaList.add(Criteria.where(ServerFields.PROVISIONING_STATUS) - .in(ProvisioningStatus.PENDING, ProvisioningStatus.IN_PROGRESS)); - case FILTER_FAILED -> criteriaList.add(Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.FAILED)); - case FILTER_UNVERIFIED -> criteriaList.add(Criteria.where(ServerFields.EMAIL_VERIFIED).is(false)); - default -> { - } - } - } - - if (!criteriaList.isEmpty()) { - query.addCriteria(new Criteria().andOperator(criteriaList.toArray(new Criteria[0]))); - } - return query; - } - - private Sort.Direction resolveSortDirection(String sortOrder) { - return ORDER_ASC.equalsIgnoreCase(sortOrder) ? Sort.Direction.ASC : Sort.Direction.DESC; - } - - private String resolveAdminSortField(String sortField) { - return ADMIN_SORT_FIELDS.contains(sortField) ? sortField : ServerFields.CREATED_AT; - } - - public long countAdminServers(String search, String plan, String status) { - return count(buildAdminServerFilterQuery(search, plan, status)); - } - - public List findUsageRefreshCandidates(Date staleCutoff, int limit) { - Criteria staleCriteria = new Criteria().orOperator( - Criteria.where(ServerFields.LAST_STATS_UPDATED_AT).exists(false), - Criteria.where(ServerFields.LAST_STATS_UPDATED_AT).lt(staleCutoff) - ); - - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.DATABASE_NAME).ne(null), - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), - Criteria.where(ServerFields.EMAIL_VERIFIED).is(true), - staleCriteria - )); - query.with(Sort.by(Sort.Direction.ASC, ServerFields.LAST_STATS_UPDATED_AT)); - query.limit(limit); - query.fields() - .include(ServerFields.SERVER_NAME) - .include(ServerFields.CUSTOM_DOMAIN) - .include(ServerFields.DATABASE_NAME) - .include(ServerFields.ADMIN_EMAIL) - .include(ServerFields.EMAIL_VERIFIED) - .include(ServerFields.PLAN) - .include(ServerFields.USER_COUNT) - .include(ServerFields.TICKET_COUNT) - .include(ServerFields.LAST_STATS_UPDATED_AT) - .include(ServerFields.LAST_ACTIVITY_AT) - .include(ServerFields.UPDATED_AT); - return find(query); - } - - public List findExpiredRegistrationCleanupCandidates(Date cutoff, int limit) { - return findExpiredRegistrationCleanupCandidates(cutoff, new Date(0), limit); - } - - public List findExpiredRegistrationCleanupCandidates(Date cutoff, Date claimCutoff, int limit) { - Query query = Query.query(expiredRegistrationCriteria(cutoff, claimCutoff)); - query.with(Sort.by(Sort.Direction.ASC, ServerFields.CREATED_AT)); - query.limit(limit); - query.fields() - .include(ServerFields.SERVER_NAME) - .include(ServerFields.CUSTOM_DOMAIN) - .include(ServerFields.DATABASE_NAME) - .include(ServerFields.ADMIN_EMAIL) - .include(ServerFields.EMAIL_VERIFIED) - .include(ServerFields.EMAIL_VERIFICATION_TOKEN) - .include(ServerFields.PROVISIONING_STATUS) - .include(ServerFields.API_KEY) - .include(ServerFields.ONLINE_PLAYER_COUNT) - .include(ServerFields.USER_COUNT) - .include(ServerFields.TICKET_COUNT) - .include(ServerFields.LAST_ACTIVITY_AT) - .include(ServerFields.CREATED_AT) - .include(ServerFields.UPDATED_AT) - .include(ServerFields.CLEANUP_CLAIM_ID) - .include(ServerFields.CLEANUP_CLAIMED_AT); - return find(query); - } - - public Optional claimExpiredRegistrationForCleanup(String serverId, Date cutoff, Instant claimedAt) { - return claimExpiredRegistrationForCleanup(serverId, cutoff, new Date(0), claimedAt); - } - - public Optional claimExpiredRegistrationForCleanup(String serverId, Date cutoff, Date claimCutoff, Instant claimedAt) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).is(serverId), - expiredRegistrationCriteria(cutoff, claimCutoff) - )); - Update update = new Update() - .set(ServerFields.CLEANUP_CLAIM_ID, UUID.randomUUID().toString()) - .set(ServerFields.CLEANUP_CLAIMED_AT, Date.from(claimedAt)) - .set(ServerFields.UPDATED_AT, Date.from(claimedAt)); - - return Optional.ofNullable(findAndModify( - query, - update, - FindAndModifyOptions.options().returnNew(true) - )); - } - - public boolean deleteClaimedExpiredRegistration(String serverId, String cleanupClaimId, Date cutoff) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).is(serverId), - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(cleanupClaimId), - explicitExpiredRegistrationCriteria(cutoff) - )); - return remove(query).getDeletedCount() > 0; - } - - public Optional confirmRegistrationCleanupClaim(String serverId, String cleanupClaimId, Date cutoff, Instant confirmedAt) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).is(serverId), - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(cleanupClaimId), - explicitExpiredRegistrationCriteria(cutoff) - )); - Update update = new Update() - .set(ServerFields.CLEANUP_CLAIMED_AT, Date.from(confirmedAt)) - .set(ServerFields.UPDATED_AT, Date.from(confirmedAt)); - - return Optional.ofNullable(findAndModify( - query, - update, - FindAndModifyOptions.options().returnNew(true) - )); - } - - public boolean releaseRegistrationCleanupClaim(String serverId, String cleanupClaimId) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).is(serverId), - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(cleanupClaimId) - )); - Update update = new Update() - .unset(ServerFields.CLEANUP_CLAIM_ID) - .unset(ServerFields.CLEANUP_CLAIMED_AT) - .set(ServerFields.UPDATED_AT, new Date()); - return updateFirst(query, update).getModifiedCount() > 0; - } - - public List findUsageTargetsByIds(List serverIds) { - Query query = Query.query(Criteria.where(ServerFields.ID).in(serverIds)); - query.fields() - .include(ServerFields.SERVER_NAME) - .include(ServerFields.CUSTOM_DOMAIN) - .include(ServerFields.DATABASE_NAME) - .include(ServerFields.ADMIN_EMAIL) - .include(ServerFields.EMAIL_VERIFIED) - .include(ServerFields.PLAN) - .include(ServerFields.USER_COUNT) - .include(ServerFields.TICKET_COUNT) - .include(ServerFields.LAST_STATS_UPDATED_AT) - .include(ServerFields.LAST_ACTIVITY_AT) - .include(ServerFields.UPDATED_AT); - return find(query); - } - - private Criteria expiredRegistrationCriteria(Date cutoff, Date claimCutoff) { - return new Criteria().andOperator( - explicitExpiredRegistrationCriteria(cutoff), - cleanupClaimEligibleCriteria(claimCutoff) - ); - } - - private Criteria explicitExpiredRegistrationCriteria(Date cutoff) { - return new Criteria().andOperator( - Criteria.where(ServerFields.EMAIL_VERIFIED).is(false), - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.PENDING), - Criteria.where(ServerFields.EMAIL_VERIFICATION_TOKEN).exists(true).nin(null, ""), - Criteria.where(ServerFields.CREATED_AT).exists(true).lt(cutoff), - Criteria.where(ServerFields.DATABASE_NAME).regex("^server_.+"), - notPresentOrBlank(ServerFields.API_KEY), - notPresent(ServerFields.LAST_ACTIVITY_AT), - notPositive(ServerFields.USER_COUNT), - notPositive(ServerFields.TICKET_COUNT), - notPositive(ServerFields.ONLINE_PLAYER_COUNT) - ); - } - - private Criteria cleanupClaimEligibleCriteria(Date claimCutoff) { - return new Criteria().orOperator( - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).exists(false), - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(null), - Criteria.where(ServerFields.CLEANUP_CLAIMED_AT).lt(claimCutoff) - ); - } - - private Criteria noCleanupClaimCriteria() { - return new Criteria().orOperator( - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).exists(false), - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(null) - ); - } - - private Criteria notPresent(String field) { - return new Criteria().orOperator( - Criteria.where(field).exists(false), - Criteria.where(field).is(null) - ); - } - - private Criteria notPresentOrBlank(String field) { - return new Criteria().orOperator( - Criteria.where(field).exists(false), - Criteria.where(field).is(null), - Criteria.where(field).is("") - ); - } - - private Criteria notPositive(String field) { - return new Criteria().orOperator( - Criteria.where(field).exists(false), - Criteria.where(field).is(null), - Criteria.where(field).lte(0) - ); - } - - public List findProvisioningCandidatesByIds(List serverIds) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).in(serverIds), - Criteria.where(ServerFields.DATABASE_NAME).exists(true).ne(null) - )); - return find(query); - } - - public List findCancelledWithPeriodEnd() { - Criteria criteria = new Criteria().andOperator( - Criteria.where(ServerFields.SUBSCRIPTION_STATUS).is(SubscriptionStatus.CANCELED), - Criteria.where(ServerFields.CURRENT_PERIOD_END).exists(true).ne(null) - ); - return find(new Query(criteria)); - } - - public void incrementAiRequests(String serverId, long additionalRequests) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update().inc(ServerFields.AI_REQUESTS_CURRENT_PERIOD, additionalRequests) - ); - } - - public void incrementStorageUsed(String serverId, long bytes) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update().inc(ServerFields.STORAGE_USED_BYTES, bytes) - ); - } - - public boolean tryIncrementStorageUsedWithinLimit(String serverId, long bytes, long maxBytes) { - long maxCurrentBytes = maxBytes - bytes; - if (bytes < 0 || maxCurrentBytes < 0) { - return false; - } - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).is(serverId), - new Criteria().orOperator( - Criteria.where(ServerFields.STORAGE_USED_BYTES).lte(maxCurrentBytes), - Criteria.where(ServerFields.STORAGE_USED_BYTES).exists(false), - Criteria.where(ServerFields.STORAGE_USED_BYTES).is(null) - ) - )); - UpdateResult result = updateFirst(query, new Update().inc(ServerFields.STORAGE_USED_BYTES, bytes)); - return result.getMatchedCount() == 1; - } - - public void decrementStorageUsed(String serverId, long bytes) { - AggregationUpdate update = AggregationUpdate.update().set( - SetOperation.set(ServerFields.STORAGE_USED_BYTES).toValueOf(flooredStorageAfterDecrement(bytes)) - ); - globalTemplate().updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - update, - collectionName() - ); - } - - private AggregationExpression flooredStorageAfterDecrement(long bytes) { - return context -> new Document("$max", List.of(0L, new Document("$subtract", - List.of("$" + ServerFields.STORAGE_USED_BYTES, bytes)))); - } - - public void setStorageUsed(String serverId, long bytes) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update().set(ServerFields.STORAGE_USED_BYTES, bytes) - ); - } - - public boolean setStorageUsedIfBelow(String serverId, long bytes) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).is(serverId), - new Criteria().orOperator( - Criteria.where(ServerFields.STORAGE_USED_BYTES).lt(bytes), - Criteria.where(ServerFields.STORAGE_USED_BYTES).exists(false) - ) - )); - UpdateResult result = updateFirst(query, new Update().set(ServerFields.STORAGE_USED_BYTES, bytes)); - return result.getModifiedCount() == 1; - } - - public Optional findAIUsageSnapshotById(String serverId) { - Query query = Query.query(Criteria.where(ServerFields.ID).is(serverId)); - query.fields() - .include(ServerFields.AI_REQUESTS_CURRENT_PERIOD) - .include(ServerFields.MAX_AI_OVERAGE_REQUESTS); - - Document document = globalTemplate().findOne(query, Document.class, collectionName()); - if (document == null) { - return Optional.empty(); - } - - return Optional.of(new AIUsageSnapshot( - extractLong(document, ServerFields.AI_REQUESTS_CURRENT_PERIOD), - extractLong(document, ServerFields.MAX_AI_OVERAGE_REQUESTS) - )); - } - - public void resetUsageCounters(String serverId) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.AI_REQUESTS_CURRENT_PERIOD, 0L) - ); - } - - public void resetUsageAndStatsCounters(String serverId) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.STORAGE_USED_BYTES, 0L) - .set(ServerFields.USER_COUNT, 0L) - .set(ServerFields.TICKET_COUNT, 0L) - .set(ServerFields.ONLINE_PLAYER_COUNT, 0L) - .set(ServerFields.AI_REQUESTS_CURRENT_PERIOD, 0L) - .set(ServerFields.UPDATED_AT, new Date()) - ); - } - - public List findBetaTesters(String search, int skip, int limit) { - Query query = buildBetaTesterQuery(search); - query.with(Sort.by(Sort.Direction.DESC, ServerFields.BETA_TESTER_CREATED_AT)); - query.skip(skip).limit(limit); - return find(query); - } - - public long countBetaTesters(String search) { - return count(buildBetaTesterQuery(search)); - } - - public List findAllBetaTesters() { - return find(Query.query(Criteria.where(ServerFields.BETA_TESTER).is(true))); - } - - private Query buildBetaTesterQuery(String search) { - Criteria betaCriteria = Criteria.where(ServerFields.BETA_TESTER_CREATED_AT).exists(true); - if (search == null || search.trim().isEmpty()) { - return new Query(betaCriteria); - } - String escapedSearch = Pattern.quote(search.trim()); - return new Query(new Criteria().andOperator( - betaCriteria, - new Criteria().orOperator( - Criteria.where(ServerFields.SERVER_NAME).regex(escapedSearch, "i"), - Criteria.where(ServerFields.CUSTOM_DOMAIN).regex(escapedSearch, "i"), - Criteria.where(ServerFields.ADMIN_EMAIL).regex(escapedSearch, "i") - ) - )); - } - - public Optional updateBetaState(String serverId, ServerPlan plan, SubscriptionStatus subscriptionStatus, boolean betaTester) { - Update update = new Update() - .set(ServerFields.PLAN, plan) - .set(ServerFields.SUBSCRIPTION_STATUS, subscriptionStatus) - .set(ServerFields.BETA_TESTER, betaTester) - .set(ServerFields.UPDATED_AT, new Date()); - return Optional.ofNullable(findAndModify( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - update, - FindAndModifyOptions.options().returnNew(true) - )); - } - - public void updateAdminEmail(String serverId, String adminEmail) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.ADMIN_EMAIL, adminEmail) - .set(ServerFields.UPDATED_AT, new Date()) - ); - } - - public void updateApiKey(String serverId, String apiKey) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update().set(ServerFields.API_KEY, apiKey) - ); - } - - public void clearApiKey(String serverId) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update().unset(ServerFields.API_KEY) - ); - } - - public Optional updateAllowedFields(String serverId, Map updateData) { - Update update = new Update(); - boolean hasChanges = false; - - for (Map.Entry entry : updateData.entrySet()) { - String key = entry.getKey(); - Object value = entry.getValue(); - if (value == null) { - continue; - } - - switch (key) { - case ServerFields.ADMIN_EMAIL -> { - update.set(ServerFields.ADMIN_EMAIL, value); - hasChanges = true; - } - case ServerFields.EMAIL_VERIFIED -> { - update.set(ServerFields.EMAIL_VERIFIED, value); - hasChanges = true; - } - case ServerFields.PROVISIONING_STATUS -> { - update.set(ServerFields.PROVISIONING_STATUS, normalizeProvisioningStatus(value)); - hasChanges = true; - } - case ServerFields.PROVISIONING_NOTES -> { - update.set(ServerFields.PROVISIONING_NOTES, value); - hasChanges = true; - } - case ServerFields.PLAN -> { - update.set(ServerFields.PLAN, normalizePlan(value)); - hasChanges = true; - } - case ServerFields.SUBSCRIPTION_STATUS -> { - update.set(ServerFields.SUBSCRIPTION_STATUS, normalizeSubscriptionStatus(value)); - hasChanges = true; - } - case ServerFields.LAST_ACTIVITY_AT -> { - update.set(ServerFields.LAST_ACTIVITY_AT, normalizeDate(value)); - hasChanges = true; - } - case ServerFields.UPDATED_AT -> { - update.set(ServerFields.UPDATED_AT, normalizeDate(value)); - hasChanges = true; - } - default -> { - } - } - } - - if (!hasChanges) { - return findById(serverId); - } - - Server updated = findAndModify( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - update, - FindAndModifyOptions.options().returnNew(true) - ); - return Optional.ofNullable(updated); - } - - private ServerPlan normalizePlan(Object value) { - if (value instanceof ServerPlan plan) { - return plan; - } - return ServerPlan.valueOf(String.valueOf(value).trim().toUpperCase(Locale.ROOT)); - } - - private ProvisioningStatus normalizeProvisioningStatus(Object value) { - if (value instanceof ProvisioningStatus provisioningStatus) { - return provisioningStatus; - } - return ProvisioningStatus.valueOf(String.valueOf(value).trim().toUpperCase(Locale.ROOT)); - } - - private SubscriptionStatus normalizeSubscriptionStatus(Object value) { - if (value instanceof SubscriptionStatus subscriptionStatus) { - return subscriptionStatus; - } - return SubscriptionStatus.valueOf(String.valueOf(value).trim().toUpperCase(Locale.ROOT)); - } - - private Date normalizeDate(Object value) { - if (value instanceof Date d) { - return d; - } - if (value instanceof Instant i) { - return Date.from(i); - } - if (value instanceof Number n) { - return new Date(n.longValue()); - } - if (value instanceof String s) { - return Date.from(Instant.parse(s.trim())); - } - throw new IllegalArgumentException("Unsupported value type for date field: " - + (value == null ? "null" : value.getClass())); - } - - public boolean deleteByServerId(String serverId) { - return remove(Query.query(Criteria.where(ServerFields.ID).is(serverId))).getDeletedCount() > 0; - } - - public long deleteByServerIds(List serverIds) { - return remove(Query.query(Criteria.where(ServerFields.ID).in(serverIds))).getDeletedCount(); - } - - public long bulkSuspend(List serverIds, Date updatedAt) { - Update update = new Update() - .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED) - .set(ServerFields.UPDATED_AT, updatedAt); - return updateMulti(Query.query(Criteria.where(ServerFields.ID).in(serverIds)), update).getModifiedCount(); - } - - public long bulkActivate(List serverIds, Date updatedAt) { - Update update = new Update() - .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.IN_PROGRESS) - .set(ServerFields.EMAIL_VERIFIED, true) - .set(ServerFields.UPDATED_AT, updatedAt); - return updateMulti(Query.query(Criteria.where(ServerFields.ID).in(serverIds)), update).getModifiedCount(); - } - - public boolean markProvisioningCompleted(String serverId) { - Query query = Query.query(Criteria.where(ServerFields.ID).is(serverId) - .and(ServerFields.PROVISIONING_STATUS).in( - ProvisioningStatus.IN_PROGRESS, ProvisioningStatus.PENDING, ProvisioningStatus.FAILED)); - Update update = new Update() - .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED) - .unset(ServerFields.PROVISIONING_NOTES) - .set(ServerFields.UPDATED_AT, new Date()); - return updateFirst(query, update).getModifiedCount() > 0; - } - - public boolean markProvisioningFailed(String serverId, String notes) { - String safeNotes = notes != null && notes.length() > 500 ? notes.substring(0, 500) : notes; - Update update = new Update() - .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED) - .set(ServerFields.PROVISIONING_NOTES, safeNotes) - .set(ServerFields.UPDATED_AT, new Date()); - return updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update) - .getModifiedCount() > 0; - } - - public Optional applyFieldUpdate(String serverId, Update update) { - if (update.getUpdateObject().isEmpty()) { - return findById(serverId); - } - return Optional.ofNullable(findAndModify( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - update, - FindAndModifyOptions.options().returnNew(true) - )); - } - - public long bulkUpdatePlan(List serverIds, ServerPlan plan, Date updatedAt) { - Update update = new Update() - .set(ServerFields.PLAN, plan) - .set(ServerFields.UPDATED_AT, updatedAt); - return updateMulti(Query.query(Criteria.where(ServerFields.ID).in(serverIds)), update).getModifiedCount(); - } - - public void updateCustomDomain(String serverId, String customDomain, String status, - String cloudflareHostnameId, String error) { - CustomDomainStatus domainStatus = switch (status) { - case "active" -> CustomDomainStatus.ACTIVE; - case "error" -> CustomDomainStatus.ERROR; - case "verifying" -> CustomDomainStatus.VERIFYING; - default -> CustomDomainStatus.PENDING; - }; - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.CUSTOM_DOMAIN_OVERRIDE, customDomain) - .set(ServerFields.CUSTOM_DOMAIN_STATUS, domainStatus.name()) - .set(ServerFields.CUSTOM_DOMAIN_CLOUDFLARE_ID, cloudflareHostnameId) - .set(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED, new Date()) - .set(ServerFields.CUSTOM_DOMAIN_ERROR, error) - .set(ServerFields.UPDATED_AT, new Date()) - ); - } - - public void clearCustomDomain(String serverId) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .unset(ServerFields.CUSTOM_DOMAIN_OVERRIDE) - .unset(ServerFields.CUSTOM_DOMAIN_STATUS) - .unset(ServerFields.CUSTOM_DOMAIN_CLOUDFLARE_ID) - .unset(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED) - .unset(ServerFields.CUSTOM_DOMAIN_ERROR) - .set(ServerFields.UPDATED_AT, new Date()) - ); - } - - public void updateStaffPermissionsTimestamp(String serverId, Date timestamp) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.STAFF_PERMISSIONS_UPDATED_AT, timestamp) - .set(ServerFields.UPDATED_AT, new Date()) - ); - } - - public void updatePunishmentTypesTimestamp(String serverId, Date timestamp) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.PUNISHMENT_TYPES_UPDATED_AT, timestamp) - .set(ServerFields.UPDATED_AT, new Date()) - ); - } - - public void updateLastActivity(String serverId, Date lastActivityAt, long onlinePlayerCount) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.LAST_ACTIVITY_AT, lastActivityAt) - .set(ServerFields.ONLINE_PLAYER_COUNT, onlinePlayerCount) - ); - } - - public void updateUsageStats(String serverId, long userCount, long ticketCount, Date updatedAt) { - Update update = new Update() - .set(ServerFields.USER_COUNT, userCount) - .set(ServerFields.TICKET_COUNT, ticketCount) - .set(ServerFields.LAST_STATS_UPDATED_AT, updatedAt); - updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update); - } - - public void resetAfterDatabaseDrop(String serverId, Date updatedAt) { - Update update = new Update() - .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.PENDING) - .set(ServerFields.PROVISIONING_NOTES, RESET_MESSAGE) - .unset(ServerFields.LAST_ACTIVITY_AT) - .unset(ServerFields.CUSTOM_DOMAIN_STATUS) - .unset(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED) - .unset(ServerFields.CUSTOM_DOMAIN_ERROR) - .set(ServerFields.UPDATED_AT, updatedAt); - updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update); - } - - public List aggregateHistoricalMetric(String metric, Date startDate) { - ProjectionOperation projectDateStage = Aggregation.project() - .and(DateOperators.DateToString.dateOf(ServerFields.CREATED_AT).toString("%Y-%m-%d")).as(ALIAS_DATE); - - if (METRIC_USERS.equals(metric) || METRIC_TICKETS.equals(metric)) { - String sumField = METRIC_USERS.equals(metric) ? ServerFields.USER_COUNT : ServerFields.TICKET_COUNT; - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.match(Criteria.where(ServerFields.CREATED_AT).gte(startDate)), - projectDateStage.and(sumField).as(ALIAS_VALUE_SOURCE), - Aggregation.group(ALIAS_DATE).sum(ALIAS_VALUE_SOURCE).as(ALIAS_VALUE), - Aggregation.sort(Sort.Direction.ASC, "_id"), - Aggregation.project().and("_id").as(ALIAS_DATE).and(ALIAS_VALUE).as(ALIAS_VALUE) - ); - return aggregate(aggregation, DateValueResult.class).getMappedResults(); - } - - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.match(Criteria.where(ServerFields.CREATED_AT).gte(startDate)), - projectDateStage, - Aggregation.group(ALIAS_DATE).count().as(ALIAS_VALUE), - Aggregation.sort(Sort.Direction.ASC, "_id"), - Aggregation.project().and("_id").as(ALIAS_DATE).and(ALIAS_VALUE).as(ALIAS_VALUE) - ); - return aggregate(aggregation, DateValueResult.class).getMappedResults(); - } - - public DashboardStats aggregateDashboardStats(Date startDate, Date previousStartDate) { - Document facet = new Document() - .append("total", List.of(new Document("$count", "n"))) - .append("active", List.of( - new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED.name()) - .append(ServerFields.EMAIL_VERIFIED, true)), - new Document("$count", "n") - )) - .append("withUsers", List.of( - new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED.name()) - .append(ServerFields.USER_COUNT, new Document("$gt", 0))), - new Document("$count", "n") - )) - .append("currentPeriod", List.of( - new Document("$match", new Document(ServerFields.CREATED_AT, new Document("$gte", startDate))), - new Document("$count", "n") - )) - .append("previousPeriod", List.of( - new Document("$match", new Document(ServerFields.CREATED_AT, - new Document("$gte", previousStartDate).append("$lt", startDate))), - new Document("$count", "n") - )) - .append("usage", List.of( - new Document("$group", new Document("_id", null) - .append("totalUsers", new Document("$sum", "$" + ServerFields.USER_COUNT)) - .append("totalTickets", new Document("$sum", "$" + ServerFields.TICKET_COUNT))) - )); - - List pipeline = List.of(new Document("$facet", facet)); - List results = globalTemplate().getCollection(collectionName()) - .aggregate(pipeline) - .into(new ArrayList<>()); - - if (results.isEmpty()) { - return new DashboardStats(0, 0, 0, 0, 0, 0, 0); - } - - Document doc = results.get(0); - long total = extractFacetCount(doc, "total"); - long active = extractFacetCount(doc, "active"); - long withUsers = extractFacetCount(doc, "withUsers"); - long currentPeriod = extractFacetCount(doc, "currentPeriod"); - long previousPeriod = extractFacetCount(doc, "previousPeriod"); - - long totalUsers = 0; - long totalTickets = 0; - List usageList = doc.getList("usage", Document.class, List.of()); - if (!usageList.isEmpty() && usageList.getFirst() instanceof Document usageDoc) { - totalUsers = extractLong(usageDoc, "totalUsers"); - totalTickets = extractLong(usageDoc, "totalTickets"); - } - - return new DashboardStats(total, active, withUsers, currentPeriod, previousPeriod, totalUsers, totalTickets); - } - - public MonitoringServerStats aggregateMonitoringServerStats(Date fiveMinutesAgo, Date oneWeekAgo) { - Document facet = new Document() - .append("total", List.of(new Document("$count", "n"))) - .append("active", List.of( - new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED.name()) - .append(ServerFields.EMAIL_VERIFIED, true)), - new Document("$count", "n") - )) - .append("concurrent", List.of( - new Document("$match", new Document(ServerFields.LAST_ACTIVITY_AT, new Document("$gte", fiveMinutesAgo))), - new Document("$count", "n") - )) - .append("concurrentPlayers", List.of( - new Document("$match", new Document(ServerFields.LAST_ACTIVITY_AT, new Document("$gte", fiveMinutesAgo))), - new Document("$group", new Document("_id", null) - .append("sum", new Document("$sum", "$" + ServerFields.ONLINE_PLAYER_COUNT))) - )) - .append("pending", List.of( - new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, - new Document("$in", List.of(ProvisioningStatus.PENDING.name(), ProvisioningStatus.IN_PROGRESS.name())))), - new Document("$count", "n") - )) - .append("failed", List.of( - new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED.name())), - new Document("$count", "n") - )) - .append("recentRegistrations", List.of( - new Document("$match", new Document(ServerFields.CREATED_AT, new Document("$gte", oneWeekAgo))), - new Document("$count", "n") - )); - - List pipeline = List.of(new Document("$facet", facet)); - List results = globalTemplate().getCollection(collectionName()) - .aggregate(pipeline) - .into(new ArrayList<>()); - - if (results.isEmpty()) { - return new MonitoringServerStats(0, 0, 0, 0, 0, 0, 0); - } - - Document doc = results.get(0); - long concurrentPlayers = 0; - List cpList = doc.getList("concurrentPlayers", Document.class, List.of()); - if (!cpList.isEmpty() && cpList.getFirst() instanceof Document cpDoc) { - concurrentPlayers = extractLong(cpDoc, "sum"); - } - - return new MonitoringServerStats( - extractFacetCount(doc, "total"), - extractFacetCount(doc, "active"), - extractFacetCount(doc, "concurrent"), - concurrentPlayers, - extractFacetCount(doc, "pending"), - extractFacetCount(doc, "failed"), - extractFacetCount(doc, "recentRegistrations") - ); - } - - public record MonitoringServerStats(long total, long active, long concurrent, long concurrentPlayers, - long pending, long failed, long recentRegistrations) {} - - public record DashboardStats(long totalServers, long activeServers, long serversWithData, - long currentPeriodServers, long previousPeriodServers, - long totalUsers, long totalTickets) {} - - public record AIUsageSnapshot(long aiRequestsCurrentPeriod, long maxAiOverageRequests) {} - - public record UsageTotals(long totalUsers, long totalTickets) {} - - public record NameValueResult(String name, int value) {} - - public record DateServersResult(String date, int servers) {} - - public record DateValueResult(String date, long value) {} -} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerProvisioningRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerProvisioningRepository.java new file mode 100644 index 0000000..6f5296a --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerProvisioningRepository.java @@ -0,0 +1,107 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerProvisioningRepository extends AbstractGlobalMongoRepository { + private static final String RESET_MESSAGE = "Database reset - awaiting reprovisioning"; + + public ServerProvisioningRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public Optional verifyEmailTokenAtomically(String token) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.EMAIL_VERIFICATION_TOKEN).is(token), + Criteria.where(ServerFields.EMAIL_VERIFIED).is(false), + Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.PENDING), + noCleanupClaimCriteria() + )); + + Update update = new Update() + .set(ServerFields.EMAIL_VERIFIED, true) + .unset(ServerFields.EMAIL_VERIFICATION_TOKEN) + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.IN_PROGRESS) + .set(ServerFields.UPDATED_AT, new Date()); + + return Optional.ofNullable(findAndModify( + query, + update, + FindAndModifyOptions.options().returnNew(true) + )); + } + + public Optional consumeProvisioningSignInToken(String token, Date now) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN).is(token), + Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN_EXPIRES_AT).gt(now), + Criteria.where(ServerFields.EMAIL_VERIFIED).is(true), + Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED) + )); + Update update = new Update() + .unset(ServerFields.PROVISIONING_SIGN_IN_TOKEN) + .unset(ServerFields.PROVISIONING_SIGN_IN_TOKEN_EXPIRES_AT) + .set(ServerFields.UPDATED_AT, now); + return Optional.ofNullable(findAndModify(query, update, FindAndModifyOptions.options().returnNew(true))); + } + + public List findProvisioningCandidatesByIds(List serverIds) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).in(serverIds), + Criteria.where(ServerFields.DATABASE_NAME).exists(true).ne(null) + )); + return find(query); + } + + public boolean markProvisioningCompleted(String serverId) { + Query query = Query.query(Criteria.where(ServerFields.ID).is(serverId) + .and(ServerFields.PROVISIONING_STATUS).in( + ProvisioningStatus.IN_PROGRESS, ProvisioningStatus.PENDING, ProvisioningStatus.FAILED)); + Update update = new Update() + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED) + .unset(ServerFields.PROVISIONING_NOTES) + .set(ServerFields.UPDATED_AT, new Date()); + return updateFirst(query, update).getModifiedCount() > 0; + } + + public boolean markProvisioningFailed(String serverId, String notes) { + Update update = new Update() + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED) + .set(ServerFields.PROVISIONING_NOTES, notes) + .set(ServerFields.UPDATED_AT, new Date()); + return updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update) + .getModifiedCount() > 0; + } + + public void resetAfterDatabaseDrop(String serverId, Date updatedAt) { + Update update = new Update() + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.PENDING) + .set(ServerFields.PROVISIONING_NOTES, RESET_MESSAGE) + .unset(ServerFields.LAST_ACTIVITY_AT) + .unset(ServerFields.CUSTOM_DOMAIN_STATUS) + .unset(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED) + .unset(ServerFields.CUSTOM_DOMAIN_ERROR) + .set(ServerFields.UPDATED_AT, updatedAt); + updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update); + } + + private Criteria noCleanupClaimCriteria() { + return new Criteria().orOperator( + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).exists(false), + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(null) + ); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerRegistrationCleanupRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerRegistrationCleanupRepository.java new file mode 100644 index 0000000..b38fd0c --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerRegistrationCleanupRepository.java @@ -0,0 +1,167 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerRegistrationCleanupRepository extends AbstractGlobalMongoRepository { + + public ServerRegistrationCleanupRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public List findExpiredRegistrationCleanupCandidates(Date cutoff, int limit) { + return findExpiredRegistrationCleanupCandidates(cutoff, new Date(0), limit); + } + + public List findExpiredRegistrationCleanupCandidates(Date cutoff, Date claimCutoff, int limit) { + Query query = Query.query(expiredRegistrationCriteria(cutoff, claimCutoff)); + query.with(Sort.by(Sort.Direction.ASC, ServerFields.CREATED_AT)); + query.limit(limit); + query.fields() + .include(ServerFields.SERVER_NAME) + .include(ServerFields.CUSTOM_DOMAIN) + .include(ServerFields.DATABASE_NAME) + .include(ServerFields.ADMIN_EMAIL) + .include(ServerFields.EMAIL_VERIFIED) + .include(ServerFields.EMAIL_VERIFICATION_TOKEN) + .include(ServerFields.PROVISIONING_STATUS) + .include(ServerFields.API_KEY) + .include(ServerFields.ONLINE_PLAYER_COUNT) + .include(ServerFields.USER_COUNT) + .include(ServerFields.TICKET_COUNT) + .include(ServerFields.LAST_ACTIVITY_AT) + .include(ServerFields.CREATED_AT) + .include(ServerFields.UPDATED_AT) + .include(ServerFields.CLEANUP_CLAIM_ID) + .include(ServerFields.CLEANUP_CLAIMED_AT); + return find(query); + } + + public Optional claimExpiredRegistrationForCleanup(String serverId, Date cutoff, Instant claimedAt) { + return claimExpiredRegistrationForCleanup(serverId, cutoff, new Date(0), claimedAt); + } + + public Optional claimExpiredRegistrationForCleanup(String serverId, Date cutoff, Date claimCutoff, Instant claimedAt) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + expiredRegistrationCriteria(cutoff, claimCutoff) + )); + Update update = new Update() + .set(ServerFields.CLEANUP_CLAIM_ID, UUID.randomUUID().toString()) + .set(ServerFields.CLEANUP_CLAIMED_AT, Date.from(claimedAt)) + .set(ServerFields.UPDATED_AT, Date.from(claimedAt)); + + return Optional.ofNullable(findAndModify( + query, + update, + FindAndModifyOptions.options().returnNew(true) + )); + } + + public boolean deleteClaimedExpiredRegistration(String serverId, String cleanupClaimId, Date cutoff) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(cleanupClaimId), + explicitExpiredRegistrationCriteria(cutoff) + )); + return remove(query).getDeletedCount() > 0; + } + + public Optional confirmRegistrationCleanupClaim(String serverId, String cleanupClaimId, Date cutoff, Instant confirmedAt) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(cleanupClaimId), + explicitExpiredRegistrationCriteria(cutoff) + )); + Update update = new Update() + .set(ServerFields.CLEANUP_CLAIMED_AT, Date.from(confirmedAt)) + .set(ServerFields.UPDATED_AT, Date.from(confirmedAt)); + + return Optional.ofNullable(findAndModify( + query, + update, + FindAndModifyOptions.options().returnNew(true) + )); + } + + public boolean releaseRegistrationCleanupClaim(String serverId, String cleanupClaimId) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(cleanupClaimId) + )); + Update update = new Update() + .unset(ServerFields.CLEANUP_CLAIM_ID) + .unset(ServerFields.CLEANUP_CLAIMED_AT) + .set(ServerFields.UPDATED_AT, new Date()); + return updateFirst(query, update).getModifiedCount() > 0; + } + + private Criteria expiredRegistrationCriteria(Date cutoff, Date claimCutoff) { + return new Criteria().andOperator( + explicitExpiredRegistrationCriteria(cutoff), + cleanupClaimEligibleCriteria(claimCutoff) + ); + } + + private Criteria explicitExpiredRegistrationCriteria(Date cutoff) { + return new Criteria().andOperator( + Criteria.where(ServerFields.EMAIL_VERIFIED).is(false), + Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.PENDING), + Criteria.where(ServerFields.EMAIL_VERIFICATION_TOKEN).exists(true).nin(null, ""), + Criteria.where(ServerFields.CREATED_AT).exists(true).lt(cutoff), + Criteria.where(ServerFields.DATABASE_NAME).regex("^server_.+"), + notPresentOrBlank(ServerFields.API_KEY), + notPresent(ServerFields.LAST_ACTIVITY_AT), + notPositive(ServerFields.USER_COUNT), + notPositive(ServerFields.TICKET_COUNT), + notPositive(ServerFields.ONLINE_PLAYER_COUNT) + ); + } + + private Criteria cleanupClaimEligibleCriteria(Date claimCutoff) { + return new Criteria().orOperator( + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).exists(false), + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(null), + Criteria.where(ServerFields.CLEANUP_CLAIMED_AT).lt(claimCutoff) + ); + } + + private Criteria notPresent(String field) { + return new Criteria().orOperator( + Criteria.where(field).exists(false), + Criteria.where(field).is(null) + ); + } + + private Criteria notPresentOrBlank(String field) { + return new Criteria().orOperator( + Criteria.where(field).exists(false), + Criteria.where(field).is(null), + Criteria.where(field).is("") + ); + } + + private Criteria notPositive(String field) { + return new Criteria().orOperator( + Criteria.where(field).exists(false), + Criteria.where(field).is(null), + Criteria.where(field).lte(0) + ); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerSettingsTimestampRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerSettingsTimestampRepository.java new file mode 100644 index 0000000..95145e2 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerSettingsTimestampRepository.java @@ -0,0 +1,38 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerSettingsTimestampRepository extends AbstractGlobalMongoRepository { + + public ServerSettingsTimestampRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public void updateStaffPermissionsTimestamp(String serverId, Date timestamp) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.STAFF_PERMISSIONS_UPDATED_AT, timestamp) + .set(ServerFields.UPDATED_AT, new Date()) + ); + } + + public void updatePunishmentTypesTimestamp(String serverId, Date timestamp) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.PUNISHMENT_TYPES_UPDATED_AT, timestamp) + .set(ServerFields.UPDATED_AT, new Date()) + ); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerUsageRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerUsageRepository.java new file mode 100644 index 0000000..bfa8476 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerUsageRepository.java @@ -0,0 +1,191 @@ +package gg.modl.backend.database.mongo.repository; + +import static gg.modl.backend.database.mongo.MongoAggregationResults.extractLong; + +import com.mongodb.client.result.UpdateResult; +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import org.bson.Document; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.aggregation.AggregationExpression; +import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; +import org.springframework.data.mongodb.core.aggregation.SetOperation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerUsageRepository extends AbstractGlobalMongoRepository { + + public ServerUsageRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public List findUsageRefreshCandidates(Date staleCutoff, int limit) { + Criteria staleCriteria = new Criteria().orOperator( + Criteria.where(ServerFields.LAST_STATS_UPDATED_AT).exists(false), + Criteria.where(ServerFields.LAST_STATS_UPDATED_AT).lt(staleCutoff) + ); + + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.DATABASE_NAME).ne(null), + Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), + Criteria.where(ServerFields.EMAIL_VERIFIED).is(true), + staleCriteria + )); + query.with(Sort.by(Sort.Direction.ASC, ServerFields.LAST_STATS_UPDATED_AT)); + query.limit(limit); + query.fields() + .include(ServerFields.SERVER_NAME) + .include(ServerFields.CUSTOM_DOMAIN) + .include(ServerFields.DATABASE_NAME) + .include(ServerFields.ADMIN_EMAIL) + .include(ServerFields.EMAIL_VERIFIED) + .include(ServerFields.PLAN) + .include(ServerFields.USER_COUNT) + .include(ServerFields.TICKET_COUNT) + .include(ServerFields.LAST_STATS_UPDATED_AT) + .include(ServerFields.LAST_ACTIVITY_AT) + .include(ServerFields.UPDATED_AT); + return find(query); + } + + public List findUsageTargetsByIds(List serverIds) { + Query query = Query.query(Criteria.where(ServerFields.ID).in(serverIds)); + query.fields() + .include(ServerFields.SERVER_NAME) + .include(ServerFields.CUSTOM_DOMAIN) + .include(ServerFields.DATABASE_NAME) + .include(ServerFields.ADMIN_EMAIL) + .include(ServerFields.EMAIL_VERIFIED) + .include(ServerFields.PLAN) + .include(ServerFields.USER_COUNT) + .include(ServerFields.TICKET_COUNT) + .include(ServerFields.LAST_STATS_UPDATED_AT) + .include(ServerFields.LAST_ACTIVITY_AT) + .include(ServerFields.UPDATED_AT); + return find(query); + } + + public void incrementAiRequests(String serverId, long additionalRequests) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update().inc(ServerFields.AI_REQUESTS_CURRENT_PERIOD, additionalRequests) + ); + } + + public void incrementStorageUsed(String serverId, long bytes) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update().inc(ServerFields.STORAGE_USED_BYTES, bytes) + ); + } + + public boolean tryIncrementStorageUsedWithinLimit(String serverId, long bytes, long maxBytes) { + long maxCurrentBytes = maxBytes - bytes; + if (bytes < 0 || maxCurrentBytes < 0) { + return false; + } + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + new Criteria().orOperator( + Criteria.where(ServerFields.STORAGE_USED_BYTES).lte(maxCurrentBytes), + Criteria.where(ServerFields.STORAGE_USED_BYTES).exists(false), + Criteria.where(ServerFields.STORAGE_USED_BYTES).is(null) + ) + )); + UpdateResult result = updateFirst(query, new Update().inc(ServerFields.STORAGE_USED_BYTES, bytes)); + return result.getMatchedCount() == 1; + } + + public void decrementStorageUsed(String serverId, long bytes) { + AggregationUpdate update = AggregationUpdate.update().set( + SetOperation.set(ServerFields.STORAGE_USED_BYTES).toValueOf(flooredStorageAfterDecrement(bytes)) + ); + globalTemplate().updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + update, + collectionName() + ); + } + + private AggregationExpression flooredStorageAfterDecrement(long bytes) { + return context -> new Document("$max", List.of(0L, new Document("$subtract", + List.of("$" + ServerFields.STORAGE_USED_BYTES, bytes)))); + } + + public void setStorageUsed(String serverId, long bytes) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update().set(ServerFields.STORAGE_USED_BYTES, bytes) + ); + } + + public boolean setStorageUsedIfBelow(String serverId, long bytes) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + new Criteria().orOperator( + Criteria.where(ServerFields.STORAGE_USED_BYTES).lt(bytes), + Criteria.where(ServerFields.STORAGE_USED_BYTES).exists(false) + ) + )); + UpdateResult result = updateFirst(query, new Update().set(ServerFields.STORAGE_USED_BYTES, bytes)); + return result.getModifiedCount() == 1; + } + + public Optional findAIUsageSnapshotById(String serverId) { + Query query = Query.query(Criteria.where(ServerFields.ID).is(serverId)); + query.fields() + .include(ServerFields.AI_REQUESTS_CURRENT_PERIOD) + .include(ServerFields.MAX_AI_OVERAGE_REQUESTS); + + Document document = globalTemplate().findOne(query, Document.class, collectionName()); + if (document == null) { + return Optional.empty(); + } + + return Optional.of(new AIUsageSnapshot( + extractLong(document, ServerFields.AI_REQUESTS_CURRENT_PERIOD), + extractLong(document, ServerFields.MAX_AI_OVERAGE_REQUESTS) + )); + } + + public void resetUsageCounters(String serverId) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.AI_REQUESTS_CURRENT_PERIOD, 0L) + ); + } + + public void resetUsageAndStatsCounters(String serverId) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.STORAGE_USED_BYTES, 0L) + .set(ServerFields.USER_COUNT, 0L) + .set(ServerFields.TICKET_COUNT, 0L) + .set(ServerFields.ONLINE_PLAYER_COUNT, 0L) + .set(ServerFields.AI_REQUESTS_CURRENT_PERIOD, 0L) + .set(ServerFields.UPDATED_AT, new Date()) + ); + } + + public void updateUsageStats(String serverId, long userCount, long ticketCount, Date updatedAt) { + Update update = new Update() + .set(ServerFields.USER_COUNT, userCount) + .set(ServerFields.TICKET_COUNT, ticketCount) + .set(ServerFields.LAST_STATS_UPDATED_AT, updatedAt); + updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update); + } + + public record AIUsageSnapshot(long aiRequestsCurrentPeriod, long maxAiOverageRequests) {} +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/SettingsMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/SettingsMongoRepository.java index b8d5baf..012f685 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/SettingsMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/SettingsMongoRepository.java @@ -1,96 +1,82 @@ -package gg.modl.backend.database.mongo.repository; - -import com.mongodb.client.result.UpdateResult; -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.SettingsFields; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.Settings; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class SettingsMongoRepository extends AbstractServerMongoRepository { - public SettingsMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(Settings.class, CollectionName.SETTINGS, tenantMongoAccess); - } - - public boolean existsByType(Server server, String type) { - return exists(server, Query.query(Criteria.where(SettingsFields.TYPE).is(type))); - } - - public Optional findByType(Server server, String type) { - return findOne(server, Query.query(Criteria.where(SettingsFields.TYPE).is(type))); - } - - public void upsertData(Server server, String type, Map data) { - upsertRawData(server, type, data); - } - - private void upsertRawData(Server server, String type, Object data) { - Query query = Query.query(Criteria.where(SettingsFields.TYPE).is(type)); - Update update = new Update() - .set(SettingsFields.TYPE, type) - .set(SettingsFields.DATA, data); - upsert(server, query, update); - } - - public void upsertListData(Server server, String type, Object data) { - upsertRawData(server, type, data); - } - - public void updateDataByType(Server server, String type, Map data) { - Query query = Query.query(Criteria.where(SettingsFields.TYPE).is(type)); - Update update = new Update().set(SettingsFields.DATA, data); - updateFirst(server, query, update); - } - - public void removeByType(Server server, String type) { - remove(server, Query.query(Criteria.where(SettingsFields.TYPE).is(type))); - } - - public List findLatestByType(Server server, String type, int limit) { - Query query = Query.query(Criteria.where(SettingsFields.TYPE).is(type)) - .with(Sort.by( - Sort.Order.desc(SettingsFields.VERSION), - Sort.Order.desc(SettingsFields.UPDATED_AT), - Sort.Order.desc(SettingsFields.ID) - )) - .limit(limit); - return find(server, query); - } - - public boolean updateWithVersionCheck(Server server, String settingsId, long expectedVersion, - String type, Map data, long newVersion, Date updatedAt) { - Criteria versionCriteria = buildVersionCriteria(expectedVersion); - Query updateQuery = Query.query(Criteria.where(SettingsFields.ID).is(settingsId) - .andOperator(versionCriteria)); - Update update = new Update() - .set(SettingsFields.TYPE, type) - .set(SettingsFields.DATA, data) - .set(SettingsFields.VERSION, newVersion) - .set(SettingsFields.UPDATED_AT, updatedAt); - UpdateResult result = updateFirst(server, updateQuery, update); - return result.getModifiedCount() > 0; - } - - private Criteria buildVersionCriteria(long expectedVersion) { - if (expectedVersion == 0L) { - return new Criteria().orOperator( - Criteria.where(SettingsFields.VERSION).is(0L), - Criteria.where(SettingsFields.VERSION).exists(false), - Criteria.where(SettingsFields.VERSION).is(null) - ); - } - return Criteria.where(SettingsFields.VERSION).is(expectedVersion); - } -} - +package gg.modl.backend.database.mongo.repository; + +import com.mongodb.client.result.UpdateResult; +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.SettingsFields; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.Settings; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class SettingsMongoRepository extends AbstractServerMongoRepository { + public SettingsMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(Settings.class, CollectionName.SETTINGS, tenantMongoAccess); + } + + public boolean existsByType(Server server, String type) { + return exists(server, Query.query(Criteria.where(SettingsFields.TYPE).is(type))); + } + + public Optional findByType(Server server, String type) { + return findOne(server, Query.query(Criteria.where(SettingsFields.TYPE).is(type))); + } + + public void upsertListData(Server server, String type, Object data) { + Query query = Query.query(Criteria.where(SettingsFields.TYPE).is(type)); + Update update = new Update() + .set(SettingsFields.TYPE, type) + .set(SettingsFields.DATA, data); + upsert(server, query, update); + } + + public void removeByType(Server server, String type) { + remove(server, Query.query(Criteria.where(SettingsFields.TYPE).is(type))); + } + + public List findLatestByType(Server server, String type, int limit) { + Query query = Query.query(Criteria.where(SettingsFields.TYPE).is(type)) + .with(Sort.by( + Sort.Order.desc(SettingsFields.VERSION), + Sort.Order.desc(SettingsFields.UPDATED_AT), + Sort.Order.desc(SettingsFields.ID) + )) + .limit(limit); + return find(server, query); + } + + public boolean updateWithVersionCheck(Server server, String settingsId, long expectedVersion, + String type, Map data, long newVersion, Date updatedAt) { + Criteria versionCriteria = buildVersionCriteria(expectedVersion); + Query updateQuery = Query.query(Criteria.where(SettingsFields.ID).is(settingsId) + .andOperator(versionCriteria)); + Update update = new Update() + .set(SettingsFields.TYPE, type) + .set(SettingsFields.DATA, data) + .set(SettingsFields.VERSION, newVersion) + .set(SettingsFields.UPDATED_AT, updatedAt); + UpdateResult result = updateFirst(server, updateQuery, update); + return result.getModifiedCount() > 0; + } + + private Criteria buildVersionCriteria(long expectedVersion) { + if (expectedVersion == 0L) { + return new Criteria().orOperator( + Criteria.where(SettingsFields.VERSION).is(0L), + Criteria.where(SettingsFields.VERSION).exists(false), + Criteria.where(SettingsFields.VERSION).is(null) + ); + } + return Criteria.where(SettingsFields.VERSION).is(expectedVersion); + } +} + diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/AuditMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/StaffActivityAnalyticsRepository.java similarity index 57% rename from src/main/java/gg/modl/backend/database/mongo/repository/AuditMongoRepository.java rename to src/main/java/gg/modl/backend/database/mongo/repository/StaffActivityAnalyticsRepository.java index 641d990..0ed8ba0 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/AuditMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/StaffActivityAnalyticsRepository.java @@ -1,46 +1,35 @@ package gg.modl.backend.database.mongo.repository; -import gg.modl.backend.audit.data.AuditLog; import gg.modl.backend.database.CollectionName; - import gg.modl.backend.database.mongo.TenantMongoAccess; import gg.modl.backend.database.mongo.fields.AuditLogFields; import gg.modl.backend.database.mongo.fields.PlayerFields; import gg.modl.backend.database.mongo.fields.PunishmentFields; -import gg.modl.backend.database.mongo.fields.StaffFields; import gg.modl.backend.database.mongo.fields.TicketFields; -import gg.modl.backend.player.data.punishment.PunishmentStatus; import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.data.Staff; import java.util.ArrayList; import java.util.Date; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.Set; import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; import org.bson.Document; import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.aggregation.AggregationOperation; -import org.springframework.data.mongodb.core.aggregation.AggregationOptions; import org.springframework.data.mongodb.core.aggregation.ConditionalOperators; +import org.springframework.data.mongodb.core.aggregation.StringOperators; import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor -public class AuditMongoRepository { +public class StaffActivityAnalyticsRepository { private final TenantMongoAccess tenantMongoAccess; + private static final String PUNISHMENTS_PREFIX = PlayerFields.PUNISHMENTS + "."; private static final String TOTAL_ACTIONS = "totalActions"; private static final String COUNT = "count"; private static final String LEVEL_MODERATION = "moderation"; - private static final String LEVEL_INFO = "info"; private static final String SOURCE_SYSTEM = "system"; - private static final String STATUS_UNSTARTED = PunishmentStatus.UNSTARTED; private static final String DATE = "date"; private static final String ALIAS_TICKET_ACTIONS = "ticketActions"; private static final String ALIAS_MODERATION_ACTIONS = "moderationActions"; @@ -49,14 +38,9 @@ public class AuditMongoRepository { private static final String ALIAS_PUNISHMENT_ID = "punishmentId"; private static final String ALIAS_PLAYER_ID = "playerId"; private static final String ALIAS_TYPE_ORDINAL = "typeOrdinal"; - private static final String ALIAS_ISSUER_NAME = "issuerName"; - private static final String ALIAS_ISSUER_ID = "issuerId"; private static final String ALIAS_ISSUED = "issued"; private static final String ALIAS_STARTED = "started"; - private static final String ALIAS_DATA = "data"; private static final String ALIAS_MODIFICATIONS = "modifications"; - private static final String ALIAS_EVIDENCE = "evidence"; - private static final String ALIAS_ATTACHED_TICKET_IDS = "attachedTicketIds"; private static final String ALIAS_USERNAMES = "usernames"; private static final String ALIAS_REASON = "reason"; private static final String ALIAS_DURATION = "duration"; @@ -66,12 +50,10 @@ public class AuditMongoRepository { private static final String ALIAS_TICKET_CREATED = "ticketCreated"; private static final String ALIAS_LAST_ACTIVITY = "lastActivity"; private static final String ALIAS_FIRST_REPLY = "firstReply"; - - public List findAllStaff(Server server) { - Query query = new Query(); - query.fields().include(StaffFields.ID, StaffFields.USERNAME, StaffFields.EMAIL, StaffFields.ROLE_ID, StaffFields.ASSIGNED_MINECRAFT_USERNAME, StaffFields.UPDATED_AT); - return tenantMongoAccess.forServer(server).find(query, Staff.class, CollectionName.STAFF); - } + private static final String ALIAS_STAFF = "staff"; + private static final String ALIAS_STAFF_KEY = "staffKey"; + private static final String ALIAS_TICKET_ID = "ticketId"; + private static final String ALIAS_REPLY_CREATED = "replyCreated"; public List aggregateLogActivityBySource(Server server, Date startDate) { Criteria logCriteria = Criteria.where(AuditLogFields.SOURCE).ne(SOURCE_SYSTEM); @@ -131,130 +113,6 @@ public List aggregatePunishmentCountsByIssuer(Server server, Date .getMappedResults(); } - public Map mapStaffUsernamesByIds(Server server, Set staffIds) { - if (staffIds == null || staffIds.isEmpty()) { - return Map.of(); - } - - Query query = Query.query(Criteria.where(StaffFields.ID).in(staffIds)); - query.fields().include(StaffFields.ID, StaffFields.USERNAME); - List staffMembers = tenantMongoAccess.forServer(server).find(query, Staff.class, CollectionName.STAFF); - Map usernamesById = new HashMap<>(); - for (Staff staff : staffMembers) { - if (staff.getId() != null && staff.getUsername() != null) { - usernamesById.put(staff.getId(), staff.getUsername()); - } - } - return usernamesById; - } - - public List findPunishmentLogs(Server server, Date startDate, int limit, boolean canRollbackOnly) { - Criteria criteria = Criteria.where(AuditLogFields.CREATED).gte(startDate) - .orOperator( - Criteria.where(AuditLogFields.LEVEL).is(LEVEL_MODERATION), - Criteria.where(AuditLogFields.DESCRIPTION).regex(Pattern.compile("ban|mute|kick|warn", Pattern.CASE_INSENSITIVE)) - ); - - if (canRollbackOnly) { - criteria = criteria.and(AuditLogFields.METADATA_CAN_ROLLBACK).ne(false); - } - - Query query = Query.query(criteria) - .with(Sort.by(Sort.Direction.DESC, AuditLogFields.CREATED)) - .limit(limit); - - return tenantMongoAccess.forServer(server).find(query, AuditLog.class, CollectionName.LOGS); - } - - public List aggregatePunishmentRows(Server server) { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.match(Criteria.where(PlayerFields.PUNISHMENTS).exists(true).ne(List.of())), - Aggregation.unwind(PlayerFields.PUNISHMENTS), - Aggregation.match(Criteria.where(PlayerFields.PUNISHMENT_TYPE_ORDINAL).ne(0) - .and(PlayerFields.PUNISHMENT_DATA_STATUS).ne(STATUS_UNSTARTED)), - Aggregation.sort(Sort.Direction.DESC, PlayerFields.PUNISHMENT_ISSUED), - Aggregation.project() - .and(PlayerFields.PUNISHMENT_ID).as(ALIAS_PUNISHMENT_ID) - .and(PlayerFields.MINECRAFT_UUID).as(ALIAS_PLAYER_ID) - .and(PlayerFields.PUNISHMENT_TYPE_ORDINAL).as(ALIAS_TYPE_ORDINAL) - .and(PlayerFields.PUNISHMENT_ISSUER_NAME).as(ALIAS_ISSUER_NAME) - .and(PlayerFields.PUNISHMENT_ISSUER_ID).as(ALIAS_ISSUER_ID) - .and(PlayerFields.PUNISHMENT_ISSUED).as(ALIAS_ISSUED) - .and("punishments." + PunishmentFields.STARTED).as(ALIAS_STARTED) - .and("punishments." + PunishmentFields.DATA).as(ALIAS_DATA) - .and("punishments." + PunishmentFields.MODIFICATIONS).as(ALIAS_MODIFICATIONS) - .and("punishments." + PunishmentFields.EVIDENCE).as(ALIAS_EVIDENCE) - .and("punishments." + PunishmentFields.ATTACHED_TICKET_IDS).as(ALIAS_ATTACHED_TICKET_IDS) - .and(PlayerFields.USERNAMES).as(ALIAS_USERNAMES) - ).withOptions(AggregationOptions.builder().allowDiskUse(true).build()); - - return tenantMongoAccess.forServer(server) - .aggregate(aggregation, CollectionName.PLAYERS, Document.class) - .getMappedResults(); - } - - public void saveAuditLog(Server server, AuditLog auditLog) { - tenantMongoAccess.forServer(server).save(auditLog, CollectionName.LOGS); - } - - public List readTable(Server server, String collectionName, int limit, int skip) { - Query query = new Query() - .with(Sort.by(Sort.Direction.DESC, "_id")) - .skip(skip) - .limit(limit); - return tenantMongoAccess.forServer(server).find(query, Document.class, collectionName); - } - - public long countCollection(Server server, String collectionName) { - return tenantMongoAccess.forServer(server).count(new Query(), collectionName); - } - - public List findPlayersForRollback(Server server, String staffUsername, String staffId) { - List issuerMatch = new ArrayList<>(); - issuerMatch.add(Criteria.where(PunishmentFields.ISSUER_NAME).regex("^" + Pattern.quote(staffUsername) + "$", "i")); - if (staffId != null) { - issuerMatch.add(Criteria.where(PunishmentFields.ISSUER_ID).is(staffId)); - } - - Query query = Query.query(Criteria.where(PlayerFields.PUNISHMENTS).elemMatch( - new Criteria().orOperator(issuerMatch.toArray(new Criteria[0])) - )); - query.fields().include(PlayerFields.ID, PlayerFields.MINECRAFT_UUID, PlayerFields.PUNISHMENTS); - - return tenantMongoAccess.forServer(server).find(query, Document.class, CollectionName.PLAYERS); - } - - public Document findPlayerByPunishmentId(Server server, String punishmentId) { - Query query = Query.query(Criteria.where(PlayerFields.PUNISHMENT_ID).is(punishmentId)); - query.fields().include(PlayerFields.ID, PlayerFields.MINECRAFT_UUID, - PlayerFields.USERNAMES, PlayerFields.PUNISHMENTS); - return tenantMongoAccess.forServer(server).findOne(query, Document.class, CollectionName.PLAYERS); - } - - public List findPlayersForBulkAction(Server server, List typeOrdinals) { - Query query = Query.query(Criteria.where(PlayerFields.PUNISHMENTS).elemMatch( - Criteria.where(PunishmentFields.TYPE_ORDINAL).in(typeOrdinals) - )); - query.fields().include(PlayerFields.ID, PlayerFields.MINECRAFT_UUID, - PlayerFields.USERNAMES, PlayerFields.PUNISHMENTS); - return tenantMongoAccess.forServer(server).find(query, Document.class, CollectionName.PLAYERS); - } - - public void appendPunishmentModification(Server server, String playerId, String punishmentId, - Map modification) { - appendPunishmentModificationWithData(server, playerId, punishmentId, modification, Map.of()); - } - - public void appendPunishmentModificationWithData(Server server, String playerId, String punishmentId, - Map modification, Map dataUpdates) { - Update update = new Update().push(PlayerFields.PUNISHMENT_MODIFICATIONS, modification); - for (Map.Entry entry : dataUpdates.entrySet()) { - update.set("punishments.$.data." + entry.getKey(), entry.getValue()); - } - Query query = Query.query(Criteria.where(PlayerFields.ID).is(playerId).and(PlayerFields.PUNISHMENT_ID).is(punishmentId)); - tenantMongoAccess.forServer(server).updateFirst(query, update, CollectionName.PLAYERS); - } - public List aggregatePunishmentDetails(Server server, List usernames, String staffId, Date startDate) { Criteria criteria = buildIssuerCriteria(usernames, staffId); if (startDate != null) { @@ -271,10 +129,10 @@ public List aggregatePunishmentDetails(Server server, List use .and(PlayerFields.MINECRAFT_UUID).as(ALIAS_PLAYER_ID) .and(PlayerFields.PUNISHMENT_TYPE_ORDINAL).as(ALIAS_TYPE_ORDINAL) .and(PlayerFields.PUNISHMENT_ISSUED).as(ALIAS_ISSUED) - .and("punishments." + PunishmentFields.STARTED).as(ALIAS_STARTED) + .and(PUNISHMENTS_PREFIX + PunishmentFields.STARTED).as(ALIAS_STARTED) .and(PlayerFields.PUNISHMENT_DATA_REASON).as(ALIAS_REASON) .and(PlayerFields.PUNISHMENT_DATA_DURATION).as(ALIAS_DURATION) - .and("punishments." + PunishmentFields.MODIFICATIONS).as(ALIAS_MODIFICATIONS) + .and(PUNISHMENTS_PREFIX + PunishmentFields.MODIFICATIONS).as(ALIAS_MODIFICATIONS) .and(PlayerFields.USERNAMES).as(ALIAS_USERNAMES) ); @@ -320,6 +178,34 @@ public List aggregateTicketDetails(Server server, String username, Dat .getMappedResults(); } + public List aggregateTicketResponseTimesByStaff(Server server, Date startDate) { + Criteria replyCriteria = Criteria.where(TicketFields.REPLY_STAFF).is(true); + if (startDate != null) { + replyCriteria = replyCriteria.and(TicketFields.REPLY_CREATED).gte(startDate); + } + + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.unwind(TicketFields.REPLIES), + Aggregation.match(replyCriteria), + Aggregation.project() + .and(StringOperators.ToLower.lowerValueOf(TicketFields.REPLY_NAME)).as(ALIAS_STAFF_KEY) + .and(TicketFields.ID).as(ALIAS_TICKET_ID) + .and(TicketFields.CREATED).as(ALIAS_TICKET_CREATED) + .and(TicketFields.REPLY_CREATED).as(ALIAS_REPLY_CREATED), + Aggregation.group(ALIAS_STAFF_KEY, ALIAS_TICKET_ID) + .first(ALIAS_TICKET_CREATED).as(ALIAS_TICKET_CREATED) + .min(ALIAS_REPLY_CREATED).as(ALIAS_FIRST_REPLY), + Aggregation.project() + .and("_id." + ALIAS_STAFF_KEY).as(ALIAS_STAFF) + .and(ALIAS_TICKET_CREATED).as(ALIAS_TICKET_CREATED) + .and(ALIAS_FIRST_REPLY).as(ALIAS_FIRST_REPLY) + ); + + return tenantMongoAccess.forServer(server) + .aggregate(aggregation, CollectionName.TICKETS, StaffTicketResponseTime.class) + .getMappedResults(); + } + public List aggregateDailyPunishmentCounts(Server server, List usernames, String staffId, Date startDate) { Criteria criteria = buildIssuerCriteria(usernames, staffId); if (startDate != null) { @@ -375,28 +261,11 @@ public List aggregatePunishmentTypeBreakdown(Server server, .getMappedResults(); } - public long countEvidenceUploads(Server server, String username, Date startDate) { - Criteria baseCriteria = Criteria.where(AuditLogFields.SOURCE).is(username); - if (startDate != null) { - baseCriteria = baseCriteria.and(AuditLogFields.CREATED).gte(startDate); - } - - Query query = Query.query( - baseCriteria.orOperator( - Criteria.where(AuditLogFields.DESCRIPTION).regex(Pattern.compile("evidence|upload|file", Pattern.CASE_INSENSITIVE)), - Criteria.where(AuditLogFields.LEVEL) - .is(LEVEL_INFO) - .and(AuditLogFields.DESCRIPTION) - .regex(Pattern.compile("uploaded|attachment", Pattern.CASE_INSENSITIVE)) - ) - ); - - return tenantMongoAccess.forServer(server).count(query, AuditLog.class, CollectionName.LOGS); - } - public record IdCountResult(String id, int count) {} public record OrdinalCountResult(Integer id, int count) {} public record StaffActivityResult(String id, int totalActions, int ticketActions, int moderationActions, Date lastActive) {} + + public record StaffTicketResponseTime(String staff, Date ticketCreated, Date firstReply) {} } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/StaffMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/StaffMongoRepository.java index 74128e2..7a6e8e2 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/StaffMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/StaffMongoRepository.java @@ -1,244 +1,252 @@ -package gg.modl.backend.database.mongo.repository; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.StaffFields; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.email.EmailAddressUtil; -import java.util.Collection; -import java.util.Date; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.regex.Pattern; -import org.bson.Document; -import org.springframework.data.mongodb.core.aggregation.Aggregation; -import org.springframework.data.mongodb.core.aggregation.AggregationResults; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class StaffMongoRepository extends AbstractServerMongoRepository { - public StaffMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(Staff.class, CollectionName.STAFF, tenantMongoAccess); - } - - public long countAll(Server server) { - return count(server, new Query()); - } - - public Optional findByUsername(Server server, String username) { - return findOne(server, Query.query(Criteria.where(StaffFields.USERNAME).is(username))); - } - - public Optional findByEmailExact(Server server, String email) { - return findOne(server, Query.query(Criteria.where(StaffFields.EMAIL).is(email))); - } - - public boolean existsByUsername(Server server, String username) { - return exists(server, Query.query(Criteria.where(StaffFields.USERNAME).is(username))); - } - - public boolean existsByEmailOrUsername(Server server, String email, String username) { - Query query = new Query(new Criteria().orOperator( - Criteria.where(StaffFields.EMAIL).is(email), - Criteria.where(StaffFields.USERNAME).is(username) - )); - return exists(server, query); - } - - public boolean existsByEmailExact(Server server, String email) { - return exists(server, Query.query(Criteria.where(StaffFields.EMAIL).is(email))); - } - - public boolean existsByEmailEqualsIgnoreCase(Server server, String email) { - return exists(server, Query.query(Criteria.where(StaffFields.EMAIL) - .regex("^" + Pattern.quote(email) + "$", "i"))); - } - - public boolean existsByEmailIgnoreCaseOrUsername(Server server, String normalizedEmail, String username) { - Query query = new Query(new Criteria().orOperator( - Criteria.where(StaffFields.EMAIL).is(normalizedEmail), - Criteria.where(StaffFields.USERNAME).is(username) - )); - return exists(server, query); - } - - public boolean existsByEmailIgnoreCaseExcluding(Server server, String email, String currentEmail) { - Staff existing = findByEmailIgnoreCase(server, email).orElse(null); - return existing != null && !existing.getEmail().equalsIgnoreCase(currentEmail); - } - - public Optional findByEmailIgnoreCase(Server server, String email) { - return findOne(server, Query.query(Criteria.where(StaffFields.EMAIL) - .is(EmailAddressUtil.normalize(email)))); - } - - public boolean existsByUsernameExcludingId(Server server, String username, String excludedStaffId) { - Query query = Query.query(Criteria.where(StaffFields.USERNAME).is(username) - .and(StaffFields.ID).ne(excludedStaffId)); - return exists(server, query); - } - - public boolean deleteById(Server server, String staffId) { - return remove(server, Query.query(Criteria.where(StaffFields.ID).is(staffId))).getDeletedCount() > 0; - } - - public List findAssignedMinecraftStaff(Server server) { - Query query = Query.query( - Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("") - ); - return find(server, query); - } - - public Optional findByAssignedMinecraftUuidExcludingId(Server server, String minecraftUuid, String excludedStaffId) { - Query query = Query.query( - Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).is(minecraftUuid) - .and(StaffFields.ID).ne(excludedStaffId) - ); - return findOne(server, query); - } - - public boolean updateLastSeenByAssignedMinecraftUuid(Server server, String minecraftUuid) { - Query query = Query.query(Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).is(minecraftUuid)); - Update update = new Update().set(StaffFields.LAST_SEEN, new Date()); - return updateFirst(server, query, update).getModifiedCount() > 0; - } - - public int countByRoleId(Server server, String roleId) { - return (int) count(server, Query.query(Criteria.where(StaffFields.ROLE_ID).is(roleId))); - } - - public Map countByRoleId(Server server) { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group(StaffFields.ROLE_ID).count().as("count") - ); - AggregationResults results = aggregate(server, aggregation, Document.class); - Map counts = new LinkedHashMap<>(); - for (Document document : results.getMappedResults()) { - String roleId = document.getString("_id"); - if (roleId != null) { - counts.put(roleId, document.getInteger("count", 0)); - } - } - return counts; - } - - public Map findUsernamesByIds(Server server, Set ids) { - if (ids == null || ids.isEmpty()) { - return Map.of(); - } - Query query = Query.query(Criteria.where(StaffFields.ID).in(ids)); - query.fields().include(StaffFields.USERNAME); - Map result = new HashMap<>(); - for (Staff staff : find(server, query)) { - if (staff.getId() != null && staff.getUsername() != null) { - result.put(staff.getId(), staff.getUsername()); - } - } - return result; - } - - public boolean createTwoFactorToken(Server server, String minecraftUuid, String token, String ip, long createdAt) { - Query query = Query.query(Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).is(minecraftUuid)); - Update update = new Update(); - update.set(StaffFields.TWO_FACTOR_TOKEN, token); - update.set(StaffFields.TWO_FACTOR_TOKEN_IP, ip); - update.set(StaffFields.TWO_FACTOR_TOKEN_CREATED_AT, createdAt); - return updateFirst(server, query, update).getMatchedCount() > 0; - } - - public Optional findByTwoFactorToken(Server server, String token) { - return findOne(server, Query.query(Criteria.where(StaffFields.TWO_FACTOR_TOKEN).is(token))); - } - - public boolean activateTwoFactorSession(Server server, String staffId, String token, String sessionIp, long sessionExpiresAt) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(StaffFields.ID).is(staffId), - Criteria.where(StaffFields.TWO_FACTOR_TOKEN).is(token) - )); - Update update = new Update(); - update.unset(StaffFields.TWO_FACTOR_TOKEN); - update.unset(StaffFields.TWO_FACTOR_TOKEN_CREATED_AT); - update.set(StaffFields.TWO_FACTOR_PENDING_DELIVERY, true); - update.set(StaffFields.TWO_FACTOR_SESSION_IP, sessionIp); - update.unset(StaffFields.TWO_FACTOR_TOKEN_IP); - update.set(StaffFields.TWO_FACTOR_SESSION_EXPIRES_AT, sessionExpiresAt); - return updateFirst(server, query, update).getModifiedCount() > 0; - } - - public boolean deactivateSubscription(Server server, String email, String ticketId) { - Query query = Query.query( - Criteria.where(StaffFields.EMAIL).is(email) - .and(StaffFields.SUBSCRIBED_TICKET_TICKET_ID).is(ticketId) - .and(StaffFields.SUBSCRIBED_TICKET_ACTIVE).is(true) - ); - Update update = new Update().set(StaffFields.SUBSCRIBED_TICKET_POS_ACTIVE, false); - return updateFirst(server, query, update).getModifiedCount() > 0; - } - - public boolean markSubscriptionRead(Server server, String email, String ticketId, java.util.Date lastReadAt) { - Query query = Query.query( - Criteria.where(StaffFields.EMAIL).is(email) - .and(StaffFields.SUBSCRIBED_TICKET_TICKET_ID).is(ticketId) - .and(StaffFields.SUBSCRIBED_TICKET_ACTIVE).is(true) - ); - Update update = new Update().set(StaffFields.SUBSCRIBED_TICKET_POS_LAST_READ_AT, lastReadAt); - return updateFirst(server, query, update).getModifiedCount() > 0; - } - - public void addTicketSubscription(Server server, String email, Staff.TicketSubscription subscription) { - Query query = Query.query(Criteria.where(StaffFields.EMAIL).is(email)); - Update update = new Update().addToSet(StaffFields.SUBSCRIBED_TICKETS, subscription); - updateFirst(server, query, update); - } - - public List findAssignedMinecraftUuids(Server server) { - Query query = Query.query(Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("")); - query.fields().include(StaffFields.ASSIGNED_MINECRAFT_UUID); - return find(server, query) - .stream() - .map(Staff::getAssignedMinecraftUuid) - .filter(uuid -> uuid != null && !uuid.isBlank()) - .distinct() - .toList(); - } - - public Optional findUsernameByEmail(Server server, String email) { - Query query = Query.query(Criteria.where(StaffFields.EMAIL).is(email)); - query.fields().include(StaffFields.USERNAME); - return findOne(server, query) - .map(Staff::getUsername) - .filter(username -> !username.isBlank()); - } - - public List findWithPendingTwoFactorDelivery(Server server) { - Query query = Query.query( - Criteria.where(StaffFields.TWO_FACTOR_PENDING_DELIVERY).is(true) - .and(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("") - ); - return find(server, query); - } - - public void clearPendingTwoFactorDelivery(Server server) { - Query query = Query.query( - Criteria.where(StaffFields.TWO_FACTOR_PENDING_DELIVERY).is(true) - .and(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("") - ); - updateMulti(server, query, new Update().set(StaffFields.TWO_FACTOR_PENDING_DELIVERY, false)); - } - - public List findByUsernames(Server server, Collection usernames) { - if (usernames == null || usernames.isEmpty()) return List.of(); - Query query = Query.query(Criteria.where(StaffFields.USERNAME).in(usernames)); - return find(server, query); - } -} - +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.StaffFields; +import gg.modl.backend.email.EmailAddressUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.staff.data.Staff; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import org.bson.Document; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class StaffMongoRepository extends AbstractServerMongoRepository { + public StaffMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(Staff.class, CollectionName.STAFF, tenantMongoAccess); + } + + public long countAll(Server server) { + return count(server, new Query()); + } + + public Optional findByUsername(Server server, String username) { + return findOne(server, Query.query(Criteria.where(StaffFields.USERNAME).is(username))); + } + + public Optional findByEmailExact(Server server, String email) { + return findOne(server, Query.query(Criteria.where(StaffFields.EMAIL).is(email))); + } + + public boolean existsByUsername(Server server, String username) { + return exists(server, Query.query(Criteria.where(StaffFields.USERNAME).is(username))); + } + + public boolean existsByEmailOrUsername(Server server, String email, String username) { + Query query = new Query(new Criteria().orOperator( + Criteria.where(StaffFields.EMAIL).is(email), + Criteria.where(StaffFields.USERNAME).is(username) + )); + return exists(server, query); + } + + public boolean existsByEmailExact(Server server, String email) { + return exists(server, Query.query(Criteria.where(StaffFields.EMAIL).is(email))); + } + + public boolean existsByEmailEqualsIgnoreCase(Server server, String email) { + return exists(server, Query.query(Criteria.where(StaffFields.EMAIL) + .regex("^" + Pattern.quote(email) + "$", "i"))); + } + + public boolean existsByEmailIgnoreCaseOrUsername(Server server, String normalizedEmail, String username) { + Query query = new Query(new Criteria().orOperator( + Criteria.where(StaffFields.EMAIL).is(normalizedEmail), + Criteria.where(StaffFields.USERNAME).is(username) + )); + return exists(server, query); + } + + public boolean existsByEmailIgnoreCaseExcluding(Server server, String email, String currentEmail) { + Staff existing = findByEmailIgnoreCase(server, email).orElse(null); + return existing != null && !existing.getEmail().equalsIgnoreCase(currentEmail); + } + + public Optional findByEmailIgnoreCase(Server server, String email) { + return findOne(server, Query.query(Criteria.where(StaffFields.EMAIL) + .is(EmailAddressUtil.normalize(email)))); + } + + public boolean existsByUsernameExcludingId(Server server, String username, String excludedStaffId) { + Query query = Query.query(Criteria.where(StaffFields.USERNAME).is(username) + .and(StaffFields.ID).ne(excludedStaffId)); + return exists(server, query); + } + + public boolean deleteById(Server server, String staffId) { + return remove(server, Query.query(Criteria.where(StaffFields.ID).is(staffId))).getDeletedCount() > 0; + } + + public List findAssignedMinecraftStaff(Server server) { + Query query = Query.query( + Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("") + ); + return find(server, query); + } + + public Optional findByAssignedMinecraftUuidExcludingId(Server server, String minecraftUuid, String excludedStaffId) { + Query query = Query.query( + Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).is(minecraftUuid) + .and(StaffFields.ID).ne(excludedStaffId) + ); + return findOne(server, query); + } + + public boolean updateLastSeenByAssignedMinecraftUuid(Server server, String minecraftUuid) { + Query query = Query.query(Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).is(minecraftUuid)); + Update update = new Update().set(StaffFields.LAST_SEEN, new Date()); + return updateFirst(server, query, update).getModifiedCount() > 0; + } + + public int countByRoleId(Server server, String roleId) { + return (int) count(server, Query.query(Criteria.where(StaffFields.ROLE_ID).is(roleId))); + } + + public Map countByRoleId(Server server) { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.group(StaffFields.ROLE_ID).count().as("count") + ); + AggregationResults results = aggregate(server, aggregation, Document.class); + Map counts = new LinkedHashMap<>(); + for (Document document : results.getMappedResults()) { + String roleId = document.getString("_id"); + if (roleId != null) { + counts.put(roleId, document.getInteger("count", 0)); + } + } + return counts; + } + + public List findAllStaff(Server server) { + Query query = new Query(); + query.fields().include(StaffFields.ID, StaffFields.USERNAME, StaffFields.EMAIL, StaffFields.ROLE_ID, StaffFields.ASSIGNED_MINECRAFT_USERNAME, StaffFields.UPDATED_AT); + return find(server, query); + } + + public Map findUsernamesByIds(Server server, Set ids) { + if (ids == null || ids.isEmpty()) { + return Map.of(); + } + Query query = Query.query(Criteria.where(StaffFields.ID).in(ids)); + query.fields().include(StaffFields.USERNAME); + Map result = new HashMap<>(); + for (Staff staff : find(server, query)) { + if (staff.getId() != null && staff.getUsername() != null) { + result.put(staff.getId(), staff.getUsername()); + } + } + return result; + } + + public boolean createTwoFactorToken(Server server, String minecraftUuid, String token, String ip, long createdAt) { + Query query = Query.query(Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).is(minecraftUuid)); + Update update = new Update(); + update.set(StaffFields.TWO_FACTOR_TOKEN, token); + update.set(StaffFields.TWO_FACTOR_TOKEN_IP, ip); + update.set(StaffFields.TWO_FACTOR_TOKEN_CREATED_AT, createdAt); + return updateFirst(server, query, update).getMatchedCount() > 0; + } + + public Optional findByTwoFactorToken(Server server, String token) { + return findOne(server, Query.query(Criteria.where(StaffFields.TWO_FACTOR_TOKEN).is(token))); + } + + public boolean activateTwoFactorSession(Server server, String staffId, String token, String sessionIp, long sessionExpiresAt) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(StaffFields.ID).is(staffId), + Criteria.where(StaffFields.TWO_FACTOR_TOKEN).is(token) + )); + Update update = new Update(); + update.unset(StaffFields.TWO_FACTOR_TOKEN); + update.unset(StaffFields.TWO_FACTOR_TOKEN_CREATED_AT); + update.set(StaffFields.TWO_FACTOR_PENDING_DELIVERY, true); + update.set(StaffFields.TWO_FACTOR_SESSION_IP, sessionIp); + update.unset(StaffFields.TWO_FACTOR_TOKEN_IP); + update.set(StaffFields.TWO_FACTOR_SESSION_EXPIRES_AT, sessionExpiresAt); + return updateFirst(server, query, update).getModifiedCount() > 0; + } + + public boolean deactivateSubscription(Server server, String email, String ticketId) { + Query query = Query.query( + Criteria.where(StaffFields.EMAIL).is(email) + .and(StaffFields.SUBSCRIBED_TICKET_TICKET_ID).is(ticketId) + .and(StaffFields.SUBSCRIBED_TICKET_ACTIVE).is(true) + ); + Update update = new Update().set(StaffFields.SUBSCRIBED_TICKET_POS_ACTIVE, false); + return updateFirst(server, query, update).getModifiedCount() > 0; + } + + public boolean markSubscriptionRead(Server server, String email, String ticketId, Date lastReadAt) { + Query query = Query.query( + Criteria.where(StaffFields.EMAIL).is(email) + .and(StaffFields.SUBSCRIBED_TICKET_TICKET_ID).is(ticketId) + .and(StaffFields.SUBSCRIBED_TICKET_ACTIVE).is(true) + ); + Update update = new Update().set(StaffFields.SUBSCRIBED_TICKET_POS_LAST_READ_AT, lastReadAt); + return updateFirst(server, query, update).getModifiedCount() > 0; + } + + public void addTicketSubscription(Server server, String email, Staff.TicketSubscription subscription) { + Query query = Query.query(Criteria.where(StaffFields.EMAIL).is(email)); + Update update = new Update().addToSet(StaffFields.SUBSCRIBED_TICKETS, subscription); + updateFirst(server, query, update); + } + + public List findAssignedMinecraftUuids(Server server) { + Query query = Query.query(Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("")); + query.fields().include(StaffFields.ASSIGNED_MINECRAFT_UUID); + return find(server, query) + .stream() + .map(Staff::getAssignedMinecraftUuid) + .filter(uuid -> uuid != null && !uuid.isBlank()) + .distinct() + .toList(); + } + + public Optional findUsernameByEmail(Server server, String email) { + Query query = Query.query(Criteria.where(StaffFields.EMAIL).is(email)); + query.fields().include(StaffFields.USERNAME); + return findOne(server, query) + .map(Staff::getUsername) + .filter(username -> !username.isBlank()); + } + + public List findWithPendingTwoFactorDelivery(Server server) { + Query query = Query.query( + Criteria.where(StaffFields.TWO_FACTOR_PENDING_DELIVERY).is(true) + .and(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("") + ); + return find(server, query); + } + + public void clearPendingTwoFactorDelivery(Server server) { + Query query = Query.query( + Criteria.where(StaffFields.TWO_FACTOR_PENDING_DELIVERY).is(true) + .and(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("") + ); + updateMulti(server, query, new Update().set(StaffFields.TWO_FACTOR_PENDING_DELIVERY, false)); + } + + public List findByUsernames(Server server, Collection usernames) { + if (usernames == null || usernames.isEmpty()) { + return List.of(); + } + Query query = Query.query(Criteria.where(StaffFields.USERNAME).in(usernames)); + return find(server, query); + } +} + diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/StaffRoleMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/StaffRoleMongoRepository.java index 74472c6..88103db 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/StaffRoleMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/StaffRoleMongoRepository.java @@ -1,142 +1,135 @@ -package gg.modl.backend.database.mongo.repository; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; - -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.StaffRoleFields; -import gg.modl.backend.role.data.StaffRole; -import gg.modl.backend.server.data.Server; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Pattern; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.BulkOperations; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class StaffRoleMongoRepository extends AbstractServerMongoRepository { - public StaffRoleMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(StaffRole.class, CollectionName.STAFF_ROLES, tenantMongoAccess); - } - - public List findAllOrdered(Server server) { - Query query = new Query().with(Sort.by( - Sort.Direction.ASC, - StaffRoleFields.ORDER, - StaffRoleFields.CREATED_AT - )); - return find(server, query); - } - - public Optional findHighestOrdered(Server server) { - Query query = new Query().with(Sort.by(Sort.Direction.DESC, StaffRoleFields.ORDER)).limit(1); - return findOne(server, query); - } - - public boolean existsByNameIgnoreCase(Server server, String roleName) { - return exists(server, Query.query( - Criteria.where(StaffRoleFields.NAME).regex("^" + Pattern.quote(roleName) + "$", "i") - )); - } - - public boolean existsByNameIgnoreCaseExcludingId(Server server, String roleName, String excludedRoleId) { - Criteria criteria = Criteria.where(StaffRoleFields.NAME) - .regex("^" + Pattern.quote(roleName) + "$", "i") - .and(StaffRoleFields.ID).ne(excludedRoleId); - return exists(server, Query.query(criteria)); - } - - - public void updateOrder(Server server, String roleId, int order) { - Update update = new Update(); - update.set(StaffRoleFields.ORDER, order); - updateFirst(server, Query.query(Criteria.where(StaffRoleFields.ID).is(roleId)), update); - } - - public void upsertRole(Server server, StaffRole role) { - Query query = Query.query(Criteria.where(StaffRoleFields.ID).is(role.getId())); - Update update = new Update(); - update.set(StaffRoleFields.NAME, role.getName()); - update.set(StaffRoleFields.DESCRIPTION, role.getDescription()); - update.set(StaffRoleFields.PERMISSIONS, role.getPermissions()); - update.set(StaffRoleFields.IS_DEFAULT, role.isDefault()); - update.set(StaffRoleFields.ORDER, role.getOrder()); - update.setOnInsert(StaffRoleFields.CREATED_AT, role.getCreatedAt()); - update.set(StaffRoleFields.UPDATED_AT, role.getUpdatedAt()); - upsert(server, query, update); - } - - public void insertRoleIfAbsent(Server server, StaffRole role) { - Query query = Query.query(Criteria.where(StaffRoleFields.ID).is(role.getId())); - Update update = new Update(); - update.setOnInsert(StaffRoleFields.NAME, role.getName()); - update.setOnInsert(StaffRoleFields.DESCRIPTION, role.getDescription()); - update.setOnInsert(StaffRoleFields.PERMISSIONS, role.getPermissions()); - update.setOnInsert(StaffRoleFields.IS_DEFAULT, role.isDefault()); - update.setOnInsert(StaffRoleFields.ORDER, role.getOrder()); - update.setOnInsert(StaffRoleFields.CREATED_AT, role.getCreatedAt()); - update.setOnInsert(StaffRoleFields.UPDATED_AT, role.getUpdatedAt()); - upsert(server, query, update); - } - - public List findCustomRolesWithOrderZero(Server server) { - Query query = Query.query(Criteria.where(StaffRoleFields.IS_DEFAULT).is(false) - .and(StaffRoleFields.ORDER).is(0)); - return find(server, query); - } - - public boolean deleteById(Server server, String roleId) { - return remove(server, Query.query(Criteria.where(StaffRoleFields.ID).is(roleId))).getDeletedCount() > 0; - } - - public Optional findByName(Server server, String roleName) { - return findOne(server, Query.query(Criteria.where(StaffRoleFields.NAME).is(roleName))); - } - - public List findAllByName(Server server, String roleName) { - return find(server, Query.query(Criteria.where(StaffRoleFields.NAME).is(roleName))); - } - - public List findByIds(Server server, Collection ids) { - if (ids == null || ids.isEmpty()) { - return List.of(); - } - return find(server, Query.query(Criteria.where(StaffRoleFields.ID).in(ids))); - } - - public void bulkUpdateOrder(Server server, Map orderById) { - if (orderById.isEmpty()) return; - - MongoTemplate template = serverTemplate(server); - BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); - for (Map.Entry entry : orderById.entrySet()) { - Query query = Query.query(Criteria.where("_id").is(entry.getKey())); - Update update = new Update().set(StaffRoleFields.ORDER, entry.getValue()); - bulk.updateOne(query, update); - } - bulk.execute(); - } - - public void bulkRepairOrderFromZero(Server server, Map orderById) { - if (orderById.isEmpty()) return; - - MongoTemplate template = serverTemplate(server); - BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); - for (Map.Entry entry : orderById.entrySet()) { - Query query = Query.query(Criteria.where("_id").is(entry.getKey()) - .and(StaffRoleFields.ORDER).is(0) - .and(StaffRoleFields.IS_DEFAULT).is(false)); - Update update = new Update().set(StaffRoleFields.ORDER, entry.getValue()); - bulk.updateOne(query, update); - } - bulk.execute(); - } -} - +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.StaffRoleFields; +import gg.modl.backend.role.data.StaffRole; +import gg.modl.backend.server.data.Server; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.BulkOperations; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class StaffRoleMongoRepository extends AbstractServerMongoRepository { + public StaffRoleMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(StaffRole.class, CollectionName.STAFF_ROLES, tenantMongoAccess); + } + + public List findAllOrdered(Server server) { + Query query = new Query().with(Sort.by( + Sort.Direction.ASC, + StaffRoleFields.ORDER, + StaffRoleFields.CREATED_AT + )); + return find(server, query); + } + + public Optional findHighestOrdered(Server server) { + Query query = new Query().with(Sort.by(Sort.Direction.DESC, StaffRoleFields.ORDER)).limit(1); + return findOne(server, query); + } + + public boolean existsByNameIgnoreCase(Server server, String roleName) { + return exists(server, Query.query( + Criteria.where(StaffRoleFields.NAME).regex("^" + Pattern.quote(roleName) + "$", "i") + )); + } + + public boolean existsByNameIgnoreCaseExcludingId(Server server, String roleName, String excludedRoleId) { + Criteria criteria = Criteria.where(StaffRoleFields.NAME) + .regex("^" + Pattern.quote(roleName) + "$", "i") + .and(StaffRoleFields.ID).ne(excludedRoleId); + return exists(server, Query.query(criteria)); + } + + public void updateOrder(Server server, String roleId, int order) { + Update update = new Update(); + update.set(StaffRoleFields.ORDER, order); + updateFirst(server, Query.query(Criteria.where(StaffRoleFields.ID).is(roleId)), update); + } + + public void upsertRole(Server server, StaffRole role) { + Query query = Query.query(Criteria.where(StaffRoleFields.ID).is(role.getId())); + Update update = new Update(); + update.set(StaffRoleFields.NAME, role.getName()); + update.set(StaffRoleFields.DESCRIPTION, role.getDescription()); + update.set(StaffRoleFields.PERMISSIONS, role.getPermissions()); + update.set(StaffRoleFields.IS_DEFAULT, role.isDefault()); + update.set(StaffRoleFields.ORDER, role.getOrder()); + update.setOnInsert(StaffRoleFields.CREATED_AT, role.getCreatedAt()); + update.set(StaffRoleFields.UPDATED_AT, role.getUpdatedAt()); + upsert(server, query, update); + } + + public void insertRoleIfAbsent(Server server, StaffRole role) { + Query query = Query.query(Criteria.where(StaffRoleFields.ID).is(role.getId())); + Update update = new Update(); + update.setOnInsert(StaffRoleFields.NAME, role.getName()); + update.setOnInsert(StaffRoleFields.DESCRIPTION, role.getDescription()); + update.setOnInsert(StaffRoleFields.PERMISSIONS, role.getPermissions()); + update.setOnInsert(StaffRoleFields.IS_DEFAULT, role.isDefault()); + update.setOnInsert(StaffRoleFields.ORDER, role.getOrder()); + update.setOnInsert(StaffRoleFields.CREATED_AT, role.getCreatedAt()); + update.setOnInsert(StaffRoleFields.UPDATED_AT, role.getUpdatedAt()); + upsert(server, query, update); + } + + public List findCustomRolesWithOrderZero(Server server) { + Query query = Query.query(Criteria.where(StaffRoleFields.IS_DEFAULT).is(false) + .and(StaffRoleFields.ORDER).is(0)); + return find(server, query); + } + + public boolean deleteById(Server server, String roleId) { + return remove(server, Query.query(Criteria.where(StaffRoleFields.ID).is(roleId))).getDeletedCount() > 0; + } + + public Optional findByName(Server server, String roleName) { + return findOne(server, Query.query(Criteria.where(StaffRoleFields.NAME).is(roleName))); + } + + public List findAllByName(Server server, String roleName) { + return find(server, Query.query(Criteria.where(StaffRoleFields.NAME).is(roleName))); + } + + public List findByIds(Server server, Collection ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return find(server, Query.query(Criteria.where(StaffRoleFields.ID).in(ids))); + } + + public void bulkUpdateOrder(Server server, Map orderById) { + bulkUpdateByIds(server, new ArrayList<>(orderById.keySet()), + (id, index) -> new Update().set(StaffRoleFields.ORDER, orderById.get(id))); + } + + public void bulkRepairOrderFromZero(Server server, Map orderById) { + if (orderById.isEmpty()) { + return; + } + + MongoTemplate template = serverTemplate(server); + BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); + for (Map.Entry entry : orderById.entrySet()) { + Query query = Query.query(Criteria.where(StaffRoleFields.ID).is(entry.getKey()) + .and(StaffRoleFields.ORDER).is(0) + .and(StaffRoleFields.IS_DEFAULT).is(false)); + Update update = new Update().set(StaffRoleFields.ORDER, entry.getValue()); + bulk.updateOne(query, update); + } + bulk.execute(); + } +} + diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/StorageFileMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/StorageFileMongoRepository.java index 862dc67..11591c7 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/StorageFileMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/StorageFileMongoRepository.java @@ -8,9 +8,9 @@ import gg.modl.backend.storage.data.StorageFileDocument; import java.util.ArrayList; import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.HashMap; import java.util.Optional; import org.bson.Document; import org.springframework.data.domain.Sort; @@ -22,6 +22,7 @@ @Repository public class StorageFileMongoRepository extends AbstractServerMongoRepository { + private static final String ALIAS_TOTAL_SIZE = "totalSize"; public StorageFileMongoRepository(TenantMongoAccess tenantMongoAccess) { super(StorageFileDocument.class, CollectionName.STORAGE_FILES, tenantMongoAccess); @@ -83,19 +84,19 @@ public long sumSizeByKeyPrefixesSince(Server server, List prefixes, Date Aggregation aggregation = Aggregation.newAggregation( Aggregation.match(match), - Aggregation.group().sum(StorageFileDocumentFields.SIZE).as("totalSize") + Aggregation.group().sum(StorageFileDocumentFields.SIZE).as(ALIAS_TOTAL_SIZE) ); Document result = aggregate(server, aggregation, Document.class).getUniqueMappedResult(); if (result == null) { return 0L; } - return result.get("totalSize", Number.class).longValue(); + return result.get(ALIAS_TOTAL_SIZE, Number.class).longValue(); } public Map aggregateStorageByCategory(Server server) { Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group(StorageFileDocumentFields.CATEGORY).sum(StorageFileDocumentFields.SIZE).as("totalSize") + Aggregation.group(StorageFileDocumentFields.CATEGORY).sum(StorageFileDocumentFields.SIZE).as(ALIAS_TOTAL_SIZE) ); AggregationResults results = aggregate(server, aggregation, Document.class); @@ -110,7 +111,7 @@ public Map aggregateStorageByCategory(Server server) { for (Document doc : results.getMappedResults()) { String category = doc.getString("_id"); - long totalSize = doc.get("totalSize", Number.class).longValue(); + long totalSize = doc.get(ALIAS_TOTAL_SIZE, Number.class).longValue(); byType.put(category, totalSize); } @@ -119,7 +120,7 @@ public Map aggregateStorageByCategory(Server server) { public long sumTotalSize(Server server) { Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group().sum(StorageFileDocumentFields.SIZE).as("totalSize") + Aggregation.group().sum(StorageFileDocumentFields.SIZE).as(ALIAS_TOTAL_SIZE) ); AggregationResults results = aggregate(server, aggregation, Document.class); @@ -128,7 +129,7 @@ public long sumTotalSize(Server server) { if (result == null) { return 0L; } - return result.get("totalSize", Number.class).longValue(); + return result.get(ALIAS_TOTAL_SIZE, Number.class).longValue(); } private String escapeRegex(String input) { diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/StripeWebhookEventMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/StripeWebhookEventMongoRepository.java index 9cbe8e7..c65fb95 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/StripeWebhookEventMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/StripeWebhookEventMongoRepository.java @@ -15,15 +15,15 @@ @Repository public class StripeWebhookEventMongoRepository extends AbstractGlobalMongoRepository { - public StripeWebhookEventMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(StripeWebhookEvent.class, CollectionName.STRIPE_WEBHOOK_EVENTS, tenantMongoAccess); - } - private static final long PROCESSING_STALE_MS = 15L * 60L * 1000L; private static final String STATUS_PROCESSING = "processing"; private static final String STATUS_PROCESSED = "processed"; private static final String STATUS_FAILED = "failed"; + public StripeWebhookEventMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(StripeWebhookEvent.class, CollectionName.STRIPE_WEBHOOK_EVENTS, tenantMongoAccess); + } + public boolean markProcessing(String eventId, String eventType, Date processingAt) { try { Date staleBefore = new Date(processingAt.getTime() - PROCESSING_STALE_MS); diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/SystemConfigMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/SystemConfigMongoRepository.java index 883db4c..874c85d 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/SystemConfigMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/SystemConfigMongoRepository.java @@ -1,6 +1,7 @@ package gg.modl.backend.database.mongo.repository; import gg.modl.backend.admin.data.SystemConfig; +import gg.modl.backend.database.CollectionName; import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; import gg.modl.backend.database.mongo.TenantMongoAccess; import gg.modl.backend.database.mongo.fields.SystemConfigFields; @@ -12,10 +13,9 @@ @Repository public class SystemConfigMongoRepository extends AbstractGlobalMongoRepository { public static final String MAIN_CONFIG_ID = "main_config"; - private static final String COLLECTION_NAME = "system_config"; public SystemConfigMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(SystemConfig.class, COLLECTION_NAME, tenantMongoAccess); + super(SystemConfig.class, CollectionName.SYSTEM_CONFIG, tenantMongoAccess); } public Optional findMainConfig() { diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/SystemLogMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/SystemLogMongoRepository.java index dab0133..c78312a 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/SystemLogMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/SystemLogMongoRepository.java @@ -3,8 +3,8 @@ import static gg.modl.backend.database.mongo.MongoAggregationResults.extractFacetCount; import gg.modl.backend.admin.data.SystemLog; +import gg.modl.backend.database.CollectionName; import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; - import gg.modl.backend.database.mongo.TenantMongoAccess; import gg.modl.backend.database.mongo.fields.SystemLogFields; import java.util.ArrayList; @@ -20,11 +20,10 @@ import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; @Repository public class SystemLogMongoRepository extends AbstractGlobalMongoRepository { - public static final String COLLECTION_NAME = "system_logs"; - private static final String ALIAS_DATE = "date"; private static final String ALIAS_LEVEL = "level"; private static final String ALIAS_COUNT = "count"; @@ -40,7 +39,7 @@ public class SystemLogMongoRepository extends AbstractGlobalMongoRepository criteriaList = new ArrayList<>(); - if (hasText(level)) { + if (StringUtils.hasText(level)) { criteriaList.add(Criteria.where(SystemLogFields.LEVEL).is(level)); } - if (hasText(source)) { + if (StringUtils.hasText(source)) { criteriaList.add(Criteria.where(SystemLogFields.SOURCE).is(source)); } - if (hasText(serverId)) { + if (StringUtils.hasText(serverId)) { criteriaList.add(Criteria.where(SystemLogFields.SERVER_ID).is(serverId)); } - if (hasText(category)) { + if (StringUtils.hasText(category)) { criteriaList.add(Criteria.where(SystemLogFields.CATEGORY).is(category)); } if (resolved != null) { criteriaList.add(Criteria.where(SystemLogFields.RESOLVED).is(RESOLVED_TRUE.equals(resolved))); } - if (hasText(search)) { + if (StringUtils.hasText(search)) { criteriaList.add(Criteria.where(SystemLogFields.MESSAGE).regex(Pattern.quote(search), "i")); } if (startDate != null || endDate != null) { @@ -140,10 +139,6 @@ private Query buildLogsQuery( return query; } - private boolean hasText(String value) { - return value != null && !value.isBlank(); - } - private Sort.Direction resolveDirection(String order) { return ORDER_DESC.equalsIgnoreCase(order) ? Sort.Direction.DESC : Sort.Direction.ASC; } @@ -176,11 +171,11 @@ public long countLogs( } public List findDistinctSources() { - return globalTemplate().findDistinct(new Query(), SystemLogFields.SOURCE, COLLECTION_NAME, String.class); + return globalTemplate().findDistinct(new Query(), SystemLogFields.SOURCE, CollectionName.SYSTEM_LOGS, String.class); } public List findDistinctCategories() { - return globalTemplate().findDistinct(new Query(), SystemLogFields.CATEGORY, COLLECTION_NAME, String.class); + return globalTemplate().findDistinct(new Query(), SystemLogFields.CATEGORY, CollectionName.SYSTEM_LOGS, String.class); } public SystemLog resolveById(String id, String resolvedBy, Date resolvedAt) { @@ -250,7 +245,7 @@ public MonitoringLogStats aggregateMonitoringLogStats(Date oneDayAgo) { )); List pipeline = List.of(new Document("$facet", facet)); - List results = globalTemplate().getCollection(COLLECTION_NAME) + List results = globalTemplate().getCollection(CollectionName.SYSTEM_LOGS) .aggregate(pipeline) .into(new ArrayList<>()); diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/SystemPromptMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/SystemPromptMongoRepository.java index d8739ba..6379a39 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/SystemPromptMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/SystemPromptMongoRepository.java @@ -1,6 +1,7 @@ package gg.modl.backend.database.mongo.repository; import gg.modl.backend.admin.data.SystemPrompt; +import gg.modl.backend.database.CollectionName; import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; import gg.modl.backend.database.mongo.TenantMongoAccess; import gg.modl.backend.database.mongo.fields.SystemPromptFields; @@ -14,10 +15,9 @@ @Repository public class SystemPromptMongoRepository extends AbstractGlobalMongoRepository { - private static final String COLLECTION_NAME = "systemprompts"; public SystemPromptMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(SystemPrompt.class, COLLECTION_NAME, tenantMongoAccess); + super(SystemPrompt.class, CollectionName.SYSTEM_PROMPTS, tenantMongoAccess); } public Optional findActive() { diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/TicketMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/TicketMongoRepository.java index 8ea845d..f532b13 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/TicketMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/TicketMongoRepository.java @@ -1,592 +1,594 @@ -package gg.modl.backend.database.mongo.repository; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.TicketFields; -import gg.modl.backend.infrastructure.util.CanonicalAliasIndex; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.AppealWorkflowStatus; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketCategory; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.data.TicketStatus; -import gg.modl.backend.ticket.util.TicketAssigneeUtil; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class TicketMongoRepository extends AbstractServerMongoRepository { - private static final String FILTER_ALL = "all"; - private static final String FILTER_OPEN = "open"; - private static final String FILTER_CLOSED = "closed"; - private static final String ASSIGNEE_NONE = "none"; - - private static final String SORT_OLDEST = "oldest"; - private static final String SORT_RECENTLY_UPDATED = "recently-updated"; - private static final String SORT_LEAST_RECENTLY_UPDATED = "least-recently-updated"; - - public TicketMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(Ticket.class, CollectionName.TICKETS, tenantMongoAccess); - } - - public void updateState(Server server, Ticket ticket) { - Query query = Query.query(Criteria.where(TicketFields.ID).is(ticket.getId())); - Update update = new Update() - .set(TicketFields.REPLIES, ticket.getReplies()) - .set(TicketFields.LOCKED, ticket.isLocked()) - .set(TicketFields.STATUS, ticket.getStatus() != null ? ticket.getStatus().getId() : null) - .set(TicketFields.UPDATED_AT, ticket.getUpdatedAt()); - updateFirst(server, query, update); - } - - public List findByIds(Server server, List ticketIds) { - return find(server, Query.query(Criteria.where(TicketFields.ID).in(ticketIds))); - } - - public Optional findByTicketId(Server server, String ticketId) { - return findOne(server, Query.query(Criteria.where(TicketFields.ID).is(ticketId))); - } - - public List findReportedPlayerTickets(Server server, String reportedPlayerUuid, int limit) { - Query query = Query.query(Criteria.where(TicketFields.REPORTED_PLAYER_UUID).is(reportedPlayerUuid)); - query.limit(limit); - return find(server, query); - } - - public List findMinecraftTickets(Server server, String status, String type, int limit) { - List conditions = new ArrayList<>(); - - if (status != null && !status.isBlank() && !FILTER_ALL.equalsIgnoreCase(status)) { - conditions.add(Criteria.where(TicketFields.STATUS).is(TicketStatus.fromCanonicalId(status).getId())); - } - - if (type != null && !type.isBlank()) { - conditions.add(buildTypeCriteria(type)); - } else { - conditions.add(Criteria.where(TicketFields.TYPE).in( - TicketCategory.SUPPORT.getId(), - TicketCategory.BUG.getId(), - TicketCategory.APPEAL.getId() - )); - } - - Query query = conditions.isEmpty() - ? new Query() - : Query.query(new Criteria().andOperator(conditions.toArray(new Criteria[0]))); - query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); - query.limit(Math.min(limit, 100)); - return find(server, query); - } - - private Criteria buildTypeCriteria(String type) { - String normalizedType = normalizeTypeValue(type); - - TicketCategory category = tryResolveCategory(type); - if (category != null && !TicketCategory.isCanonicalBucket(normalizedType)) { - return Criteria.where(TicketFields.TYPE).is(category.getId()); - } - - List bucketCategoryIds = TicketCategory.categoryIdsForBucket(normalizedType); - if (!bucketCategoryIds.isEmpty()) { - return Criteria.where(TicketFields.TYPE).in(bucketCategoryIds); - } - - String escapedType = Pattern.quote(normalizedType); - return Criteria.where(TicketFields.TYPE).regex("^" + escapedType + "$", "i"); - } - - private TicketCategory tryResolveCategory(String rawType) { - if (rawType == null || rawType.isBlank() || FILTER_ALL.equalsIgnoreCase(rawType)) { - return null; - } - try { - return TicketCategory.fromCanonicalId(rawType); - } catch (IllegalArgumentException ignored) { - return null; - } - } - - private String normalizeTypeValue(String type) { - return CanonicalAliasIndex.normalize(type); - } - - public List findRecentByCreator(Server server, String creatorUuid, int limit) { - Query query = Query.query(Criteria.where(TicketFields.CREATOR_UUID).is(creatorUuid)); - query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); - query.limit(Math.min(limit, 50)); - return find(server, query); - } - - public List findPlayerTicketsWithReplayUrl(Server server, String playerUuid, int limit) { - String lower = playerUuid == null ? null : playerUuid.toLowerCase(Locale.ROOT); - String upper = playerUuid == null ? null : playerUuid.toUpperCase(Locale.ROOT); - List uuidCandidates = lower != null && lower.equals(upper) - ? List.of(lower) - : Arrays.asList(lower, upper); - - Query query = Query.query(new Criteria().andOperator( - new Criteria().orOperator( - Criteria.where(TicketFields.CREATOR_UUID).in(uuidCandidates), - Criteria.where(TicketFields.REPORTED_PLAYER_UUID).in(uuidCandidates) - ), - Criteria.where(TicketFields.REPLAY_URL).exists(true).nin(Arrays.asList(null, "")) - )); - query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); - query.limit(Math.min(limit, 100)); - return find(server, query); - } - - public long clearReplayReferences(Server server, Collection replayIds) { - List ids = sanitizeReplayIds(replayIds); - if (ids.isEmpty()) { - return 0L; - } - Query query = Query.query(Criteria.where(TicketFields.REPLAY_ID).in(ids)); - Update update = new Update().unset(TicketFields.REPLAY_URL).unset(TicketFields.REPLAY_ID); - return updateMulti(server, query, update).getModifiedCount(); - } - - public Set findReplayIdsReferencedByUnresolvedTicket(Server server, Collection replayIds) { - List ids = sanitizeReplayIds(replayIds); - if (ids.isEmpty()) { - return Set.of(); - } - Query query = Query.query(new Criteria().andOperator( - Criteria.where(TicketFields.REPLAY_ID).in(ids), - Criteria.where(TicketFields.STATUS).ne(TicketStatus.CLOSED.getId()) - )); - query.fields().include(TicketFields.REPLAY_ID); - return find(server, query).stream() - .map(Ticket::getReplayId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - } - - private List sanitizeReplayIds(Collection replayIds) { - if (replayIds == null || replayIds.isEmpty()) { - return List.of(); - } - List ids = new ArrayList<>(replayIds.size()); - for (String replayId : replayIds) { - if (replayId != null && !replayId.isBlank()) { - ids.add(replayId); - } - } - return ids; - } - - public List findReports(Server server, String status, String playerUuid, int limit, boolean sortByCreatedDesc) { - Query query = Query.query(buildReportCriteria(status, playerUuid)); - if (sortByCreatedDesc) { - query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); - } - query.limit(Math.min(limit, 100)); - return find(server, query); - } - - private Criteria buildReportCriteria(String status, String playerUuid) { - List conditions = new ArrayList<>(); - conditions.add(Criteria.where(TicketFields.TYPE).in(TicketCategory.reportCategoryIds())); - - if (playerUuid != null && !playerUuid.isBlank()) { - conditions.add(Criteria.where(TicketFields.REPORTED_PLAYER_UUID).is(playerUuid)); - } - - if (status != null && !status.isBlank() && !FILTER_ALL.equalsIgnoreCase(status)) { - conditions.add(Criteria.where(TicketFields.STATUS).is(TicketStatus.fromCanonicalId(status).getId())); - } - - return conditions.size() == 1 - ? conditions.get(0) - : new Criteria().andOperator(conditions.toArray(new Criteria[0])); - } - - public TicketSearchPage searchTickets(Server server, TicketSearchFilter filter, TicketSortOption sort, int page, int limit) { - Query countQuery = buildSearchQuery(filter, true); - long total = count(server, countQuery); - - Query pagedQuery = Query.of(countQuery); - pagedQuery.with(sort.toMongoSort()); - pagedQuery.skip(Math.max(page - 1, 0L) * limit).limit(limit); - List tickets = find(server, pagedQuery); - return new TicketSearchPage(tickets, total); - } - - private Query buildSearchQuery(TicketSearchFilter filter, boolean includeReplySearch) { - TicketStatus requestedStatus = tryResolveTicketStatus(filter.status()); - Query query = new Query(); - if (requestedStatus != TicketStatus.UNFINISHED) { - query.addCriteria(Criteria.where(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId())); - } - - if (filter.search() != null && !filter.search().isBlank()) { - String escapedSearch = Pattern.quote(filter.search()); - List searchCriteria = new ArrayList<>(); - searchCriteria.add(Criteria.where(TicketFields.ID).regex(escapedSearch, "i")); - searchCriteria.add(Criteria.where(TicketFields.SUBJECT).regex(escapedSearch, "i")); - searchCriteria.add(Criteria.where(TicketFields.CREATOR_NAME).regex(escapedSearch, "i")); - if (includeReplySearch) { - searchCriteria.add(Criteria.where(TicketFields.REPLY_NAME).regex(escapedSearch, "i")); - searchCriteria.add(Criteria.where(TicketFields.REPLY_CONTENT).regex(escapedSearch, "i")); - } - query.addCriteria(new Criteria().orOperator(searchCriteria.toArray(new Criteria[0]))); - } - - if (filter.status() != null && !filter.status().isBlank() && !filter.status().equalsIgnoreCase(FILTER_ALL)) { - if (requestedStatus == TicketStatus.UNFINISHED) { - query.addCriteria(Criteria.where(TicketFields.STATUS).is(TicketStatus.UNFINISHED.getId())); - } else if (requestedStatus != null) { - query.addCriteria(requestedStatus.isTerminal() - ? Criteria.where(TicketFields.LOCKED).is(true) - : Criteria.where(TicketFields.LOCKED).ne(true)); - } else if (filter.status().equalsIgnoreCase(FILTER_OPEN)) { - query.addCriteria(Criteria.where(TicketFields.LOCKED).ne(true)); - } else if (filter.status().equalsIgnoreCase(FILTER_CLOSED)) { - query.addCriteria(Criteria.where(TicketFields.LOCKED).is(true)); - } - } - - if (filter.types() != null && !filter.types().isEmpty()) { - List validTypes = filter.types() - .stream() - .filter(type -> type != null && !type.isBlank() && !type.equals(FILTER_ALL)) - .toList(); - if (!validTypes.isEmpty()) { - List typeCriteria = validTypes.stream() - .map(this::buildTypeCriteria) - .toList(); - query.addCriteria(new Criteria().orOperator(typeCriteria.toArray(new Criteria[0]))); - } - } - - if (filter.author() != null && !filter.author().isBlank()) { - String escapedAuthor = Pattern.quote(filter.author()); - query.addCriteria(Criteria.where(TicketFields.CREATOR_NAME).regex(escapedAuthor, "i")); - } - - if (filter.labels() != null && !filter.labels().isEmpty()) { - query.addCriteria(Criteria.where(TicketFields.TAGS).all(filter.labels())); - } - - Criteria assigneeCriteria = buildAssigneeCriteria(filter.assignees()); - if (assigneeCriteria != null) { - query.addCriteria(assigneeCriteria); - } - - return query; - } - - private Criteria buildAssigneeCriteria(List assignees) { - if (assignees == null || assignees.isEmpty()) { - return null; - } - - List assigneeCriteriaList = new ArrayList<>(); - for (String assignee : assignees) { - if (assignee == null || assignee.isBlank()) { - continue; - } - - if (ASSIGNEE_NONE.equalsIgnoreCase(assignee)) { - assigneeCriteriaList.add(buildUnassignedCriteria()); - continue; - } - - String normalizedAssignee = TicketAssigneeUtil.normalizeSingle(assignee); - if (normalizedAssignee != null) { - assigneeCriteriaList.add(Criteria.where(TicketFields.ASSIGNED_TO).is(normalizedAssignee)); - } - } - - if (assigneeCriteriaList.isEmpty()) { - return null; - } - - return new Criteria().orOperator(assigneeCriteriaList.toArray(new Criteria[0])); - } - - private Criteria buildUnassignedCriteria() { - return new Criteria().orOperator( - Criteria.where(TicketFields.ASSIGNED_TO).exists(false), - Criteria.where(TicketFields.ASSIGNED_TO).is(null), - Criteria.where(TicketFields.ASSIGNED_TO).size(0) - ); - } - - private TicketStatus tryResolveTicketStatus(String rawStatus) { - if (rawStatus == null || rawStatus.isBlank() || FILTER_ALL.equalsIgnoreCase(rawStatus)) { - return null; - } - try { - return TicketStatus.fromCanonicalId(rawStatus); - } catch (IllegalArgumentException ignored) { - return null; - } - } - - public TicketCounts countTickets(Server server, TicketSearchFilter filter) { - Query openQuery = buildSearchQuery(filter, false); - openQuery.addCriteria(Criteria.where(TicketFields.LOCKED).ne(true)); - - Query closedQuery = buildSearchQuery(filter, false); - closedQuery.addCriteria(Criteria.where(TicketFields.LOCKED).is(true)); - - return new TicketCounts(count(server, openQuery), count(server, closedQuery)); - } - - public List findByPlayer(Server server, String playerUuid) { - Criteria criteria = new Criteria().andOperator( - new Criteria().orOperator( - Criteria.where(TicketFields.CREATOR_UUID).is(playerUuid), - Criteria.where(TicketFields.REPORTED_PLAYER_UUID).is(playerUuid) - ), - Criteria.where(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) - ); - Query query = Query.query(criteria).with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); - return find(server, query); - } - - public List findByTag(Server server, String tag) { - return find(server, Query.query(Criteria.where(TicketFields.TAGS).is(tag))); - } - - public List findRecentActiveTicketsWithRepliesByIds(Server server, List ticketIds, int limit) { - if (ticketIds == null || ticketIds.isEmpty()) { - return List.of(); - } - - Query query = Query.query( - Criteria.where(TicketFields.ID).in(ticketIds) - .and(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) - .and(TicketFields.REPLIES + ".0").exists(true) - ); - query.with(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)); - query.limit(limit); - return find(server, query); - } - - public List findRecentAssignedTicketsWithReplies(Server server, String assignee, int limit) { - Query query = Query.query( - Criteria.where(TicketFields.ASSIGNED_TO).is(assignee) - .and(TicketFields.REPLIES + ".0").exists(true) - .and(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) - ); - query.with(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)); - query.limit(limit); - return find(server, query); - } - - public boolean existsByTicketId(Server server, String ticketId) { - return exists(server, Query.query(Criteria.where(TicketFields.ID).is(ticketId))); - } - - public List findAppealsByPunishmentId(Server server, String punishmentId) { - Query query = Query.query( - Criteria.where(TicketFields.TYPE).is(TicketCategory.APPEAL.getId()) - .and(TicketFields.DATA + ".punishmentId").is(punishmentId) - ); - return find(server, query); - } - - public Ticket saveAppeal(Server server, Ticket appeal) { - return saveEntity(server, appeal); - } - - public Ticket insertTicket(Server server, Ticket ticket) { - return insert(serverTemplate(server), ticket); - } - - public void pushReply(Server server, String ticketId, TicketReply reply) { - Query query = Query.query(Criteria.where(TicketFields.ID).is(ticketId)); - Update update = new Update() - .push(TicketFields.REPLIES, reply) - .set(TicketFields.UPDATED_AT, new Date()); - updateFirst(server, query, update); - } - - public void updateAppealState(Server server, String ticketId, - AppealWorkflowStatus appealWorkflowStatus, - TicketStatus status, Boolean locked, - Map data, - List systemReplies) { - Query query = Query.query(Criteria.where(TicketFields.ID).is(ticketId)); - Update update = new Update().set(TicketFields.UPDATED_AT, new Date()); - - if (appealWorkflowStatus != null) { - update.set(TicketFields.APPEAL_WORKFLOW_STATUS, appealWorkflowStatus.getId()); - } - if (status != null) { - update.set(TicketFields.STATUS, status.getId()); - } - if (locked != null) { - update.set(TicketFields.LOCKED, locked); - } - if (data != null) { - update.set(TicketFields.DATA, data); - } - if (systemReplies != null) { - for (TicketReply reply : systemReplies) { - update.push(TicketFields.REPLIES, reply); - } - } - updateFirst(server, query, update); - } - - public List findCreatedAfterExcludingUnfinished(Server server, Date after, int limit) { - Query query = Query.query( - Criteria.where(TicketFields.CREATED).gte(after) - .and(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) - ); - query.limit(limit); - return find(server, query); - } - - public long countUnresolvedReports(Server server) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(TicketFields.TYPE).in(TicketCategory.reportCategoryIds()), - Criteria.where(TicketFields.STATUS).in(TicketStatus.OPEN.getId(), TicketStatus.UNFINISHED.getId()) - )); - return count(server, query); - } - - public long countUnresolvedTickets(Server server) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(TicketFields.TYPE).in( - TicketCategory.SUPPORT.getId(), - TicketCategory.BUG.getId(), - TicketCategory.APPEAL.getId() - ), - Criteria.where(TicketFields.STATUS).in(TicketStatus.OPEN.getId(), TicketStatus.UNFINISHED.getId()) - )); - return count(server, query); - } - - public long countAll(Server server) { - return count(server, new Query()); - } - - public long countByStatus(Server server, TicketStatus status) { - return count(server, Query.query(Criteria.where(TicketFields.STATUS).is(status.getId()))); - } - - public long countCreatedAfter(Server server, Date after) { - return count(server, Query.query(Criteria.where(TicketFields.CREATED).gte(after))); - } - - public long countCreatedBetween(Server server, Date from, Date to) { - return count(server, Query.query(Criteria.where(TicketFields.CREATED).gte(from).lt(to))); - } - - public List findRecentWithProjection(Server server, int limit) { - Query query = Query.query(Criteria.where(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId())) - .with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)) - .limit(limit); - - query.fields() - .include(TicketFields.SUBJECT) - .include(TicketFields.STATUS) - .include(TicketFields.PRIORITY) - .include(TicketFields.CREATED) - .include(TicketFields.CREATOR_NAME) - .include(TicketFields.TYPE) - .include(TicketFields.REPLIES); - query.fields().slice(TicketFields.REPLIES, 1); - - return find(server, query); - } - - public List findStaffActivityTickets(Server server, String staffUsername, String normalizedStaffUsername, Date cutoffDate, int limit) { - List staffMatchCriteria = new ArrayList<>(); - staffMatchCriteria.add(Criteria.where(TicketFields.CREATOR_NAME).is(staffUsername)); - if (normalizedStaffUsername != null) { - staffMatchCriteria.add(Criteria.where(TicketFields.ASSIGNED_TO).is(normalizedStaffUsername)); - } - staffMatchCriteria.add(Criteria.where(TicketFields.REPLY_NAME).is(staffUsername)); - - Query query = Query.query(new Criteria().andOperator( - Criteria.where(TicketFields.UPDATED_AT).gte(cutoffDate), - new Criteria().orOperator(staffMatchCriteria.toArray(new Criteria[0])) - )).with(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)).limit(limit); - - query.fields() - .include(TicketFields.SUBJECT) - .include(TicketFields.TYPE) - .include(TicketFields.CREATED) - .include(TicketFields.CREATOR_NAME) - .include(TicketFields.REPLY_NAME) - .include(TicketFields.REPLY_CREATED); - - return find(server, query); - } - - public enum TicketSortOption { - NEWEST(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)), - OLDEST(Sort.by(Sort.Direction.ASC, TicketFields.CREATED)), - RECENTLY_UPDATED(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)), - LEAST_RECENTLY_UPDATED(Sort.by(Sort.Direction.ASC, TicketFields.UPDATED_AT)); - - private final Sort mongoSort; - - TicketSortOption(Sort mongoSort) { - this.mongoSort = mongoSort; - } - - public static TicketSortOption from(String rawSort) { - if (rawSort == null || rawSort.isBlank()) { - return NEWEST; - } - return switch (rawSort) { - case SORT_OLDEST -> OLDEST; - case SORT_RECENTLY_UPDATED -> RECENTLY_UPDATED; - case SORT_LEAST_RECENTLY_UPDATED -> LEAST_RECENTLY_UPDATED; - default -> NEWEST; - }; - } - - public Sort toMongoSort() { - return mongoSort; - } - } - - public record TicketSearchFilter( - String search, - String status, - List types, - String author, - List labels, - List assignees - ) {} - - public record TicketSearchPage(List tickets, long total) {} - - public record TicketCounts(long open, long closed) {} - - public void bulkCloseForPunishment(Server server, List ticketIds) { - if (ticketIds == null || ticketIds.isEmpty()) return; - Query query = Query.query( - Criteria.where(TicketFields.ID).in(ticketIds) - .and(TicketFields.LOCKED).ne(true) - ); - Update update = new Update() - .set(TicketFields.STATUS, TicketStatus.CLOSED.getId()) - .set(TicketFields.LOCKED, true) - .set(TicketFields.UPDATED_AT, new Date()); - updateMulti(server, query, update); - } -} +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.TicketFields; +import gg.modl.backend.infrastructure.util.CanonicalAliasIndex; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.AppealWorkflowStatus; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketCategory; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.data.TicketStatus; +import gg.modl.backend.ticket.util.TicketAssigneeUtil; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class TicketMongoRepository extends AbstractServerMongoRepository { + private static final String FILTER_ALL = "all"; + private static final String FILTER_OPEN = "open"; + private static final String FILTER_CLOSED = "closed"; + private static final String ASSIGNEE_NONE = "none"; + + private static final String SORT_OLDEST = "oldest"; + private static final String SORT_RECENTLY_UPDATED = "recently-updated"; + private static final String SORT_LEAST_RECENTLY_UPDATED = "least-recently-updated"; + + public TicketMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(Ticket.class, CollectionName.TICKETS, tenantMongoAccess); + } + + public void updateState(Server server, Ticket ticket) { + Query query = Query.query(Criteria.where(TicketFields.ID).is(ticket.getId())); + Update update = new Update() + .set(TicketFields.REPLIES, ticket.getReplies()) + .set(TicketFields.LOCKED, ticket.isLocked()) + .set(TicketFields.STATUS, ticket.getStatus() != null ? ticket.getStatus().getId() : null) + .set(TicketFields.UPDATED_AT, ticket.getUpdatedAt()); + updateFirst(server, query, update); + } + + public List findByIds(Server server, List ticketIds) { + return find(server, Query.query(Criteria.where(TicketFields.ID).in(ticketIds))); + } + + public Optional findByTicketId(Server server, String ticketId) { + return findOne(server, Query.query(Criteria.where(TicketFields.ID).is(ticketId))); + } + + public List findReportedPlayerTickets(Server server, String reportedPlayerUuid, int limit) { + Query query = Query.query(Criteria.where(TicketFields.REPORTED_PLAYER_UUID).is(reportedPlayerUuid)); + query.limit(limit); + return find(server, query); + } + + public List findMinecraftTickets(Server server, String status, String type, int limit) { + List conditions = new ArrayList<>(); + + if (status != null && !status.isBlank() && !FILTER_ALL.equalsIgnoreCase(status)) { + conditions.add(Criteria.where(TicketFields.STATUS).is(TicketStatus.fromCanonicalId(status).getId())); + } + + if (type != null && !type.isBlank()) { + conditions.add(buildTypeCriteria(type)); + } else { + conditions.add(Criteria.where(TicketFields.TYPE).in( + TicketCategory.SUPPORT.getId(), + TicketCategory.BUG.getId(), + TicketCategory.APPEAL.getId() + )); + } + + Query query = conditions.isEmpty() + ? new Query() + : Query.query(new Criteria().andOperator(conditions.toArray(new Criteria[0]))); + query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); + query.limit(Math.min(limit, 100)); + return find(server, query); + } + + private Criteria buildTypeCriteria(String type) { + String normalizedType = normalizeTypeValue(type); + + TicketCategory category = tryResolveCategory(type); + if (category != null && !TicketCategory.isCanonicalBucket(normalizedType)) { + return Criteria.where(TicketFields.TYPE).is(category.getId()); + } + + List bucketCategoryIds = TicketCategory.categoryIdsForBucket(normalizedType); + if (!bucketCategoryIds.isEmpty()) { + return Criteria.where(TicketFields.TYPE).in(bucketCategoryIds); + } + + String escapedType = Pattern.quote(normalizedType); + return Criteria.where(TicketFields.TYPE).regex("^" + escapedType + "$", "i"); + } + + private TicketCategory tryResolveCategory(String rawType) { + if (rawType == null || rawType.isBlank() || FILTER_ALL.equalsIgnoreCase(rawType)) { + return null; + } + try { + return TicketCategory.fromCanonicalId(rawType); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private String normalizeTypeValue(String type) { + return CanonicalAliasIndex.normalize(type); + } + + public List findRecentByCreator(Server server, String creatorUuid, int limit) { + Query query = Query.query(Criteria.where(TicketFields.CREATOR_UUID).is(creatorUuid)); + query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); + query.limit(Math.min(limit, 50)); + return find(server, query); + } + + public List findPlayerTicketsWithReplayUrl(Server server, String playerUuid, int limit) { + String lower = playerUuid == null ? null : playerUuid.toLowerCase(Locale.ROOT); + String upper = playerUuid == null ? null : playerUuid.toUpperCase(Locale.ROOT); + List uuidCandidates = lower != null && lower.equals(upper) + ? List.of(lower) + : Arrays.asList(lower, upper); + + Query query = Query.query(new Criteria().andOperator( + new Criteria().orOperator( + Criteria.where(TicketFields.CREATOR_UUID).in(uuidCandidates), + Criteria.where(TicketFields.REPORTED_PLAYER_UUID).in(uuidCandidates) + ), + Criteria.where(TicketFields.REPLAY_URL).exists(true).nin(Arrays.asList(null, "")) + )); + query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); + query.limit(Math.min(limit, 100)); + return find(server, query); + } + + public long clearReplayReferences(Server server, Collection replayIds) { + List ids = sanitizeReplayIds(replayIds); + if (ids.isEmpty()) { + return 0L; + } + Query query = Query.query(Criteria.where(TicketFields.REPLAY_ID).in(ids)); + Update update = new Update().unset(TicketFields.REPLAY_URL).unset(TicketFields.REPLAY_ID); + return updateMulti(server, query, update).getModifiedCount(); + } + + public Set findReplayIdsReferencedByUnresolvedTicket(Server server, Collection replayIds) { + List ids = sanitizeReplayIds(replayIds); + if (ids.isEmpty()) { + return Set.of(); + } + Query query = Query.query(new Criteria().andOperator( + Criteria.where(TicketFields.REPLAY_ID).in(ids), + Criteria.where(TicketFields.STATUS).ne(TicketStatus.CLOSED.getId()) + )); + query.fields().include(TicketFields.REPLAY_ID); + return find(server, query).stream() + .map(Ticket::getReplayId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + private List sanitizeReplayIds(Collection replayIds) { + if (replayIds == null || replayIds.isEmpty()) { + return List.of(); + } + List ids = new ArrayList<>(replayIds.size()); + for (String replayId : replayIds) { + if (replayId != null && !replayId.isBlank()) { + ids.add(replayId); + } + } + return ids; + } + + public List findReports(Server server, String status, String playerUuid, int limit, boolean sortByCreatedDesc) { + Query query = Query.query(buildReportCriteria(status, playerUuid)); + if (sortByCreatedDesc) { + query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); + } + query.limit(Math.min(limit, 100)); + return find(server, query); + } + + private Criteria buildReportCriteria(String status, String playerUuid) { + List conditions = new ArrayList<>(); + conditions.add(Criteria.where(TicketFields.TYPE).in(TicketCategory.reportCategoryIds())); + + if (playerUuid != null && !playerUuid.isBlank()) { + conditions.add(Criteria.where(TicketFields.REPORTED_PLAYER_UUID).is(playerUuid)); + } + + if (status != null && !status.isBlank() && !FILTER_ALL.equalsIgnoreCase(status)) { + conditions.add(Criteria.where(TicketFields.STATUS).is(TicketStatus.fromCanonicalId(status).getId())); + } + + return conditions.size() == 1 + ? conditions.get(0) + : new Criteria().andOperator(conditions.toArray(new Criteria[0])); + } + + public TicketSearchPage searchTickets(Server server, TicketSearchFilter filter, TicketSortOption sort, int page, int limit) { + Query countQuery = buildSearchQuery(filter, true); + long total = count(server, countQuery); + + Query pagedQuery = Query.of(countQuery); + pagedQuery.with(sort.toMongoSort()); + pagedQuery.skip(Math.max(page - 1, 0L) * limit).limit(limit); + List tickets = find(server, pagedQuery); + return new TicketSearchPage(tickets, total); + } + + private Query buildSearchQuery(TicketSearchFilter filter, boolean includeReplySearch) { + TicketStatus requestedStatus = tryResolveTicketStatus(filter.status()); + Query query = new Query(); + if (requestedStatus != TicketStatus.UNFINISHED) { + query.addCriteria(Criteria.where(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId())); + } + + if (filter.search() != null && !filter.search().isBlank()) { + String escapedSearch = Pattern.quote(filter.search()); + List searchCriteria = new ArrayList<>(); + searchCriteria.add(Criteria.where(TicketFields.ID).regex(escapedSearch, "i")); + searchCriteria.add(Criteria.where(TicketFields.SUBJECT).regex(escapedSearch, "i")); + searchCriteria.add(Criteria.where(TicketFields.CREATOR_NAME).regex(escapedSearch, "i")); + if (includeReplySearch) { + searchCriteria.add(Criteria.where(TicketFields.REPLY_NAME).regex(escapedSearch, "i")); + searchCriteria.add(Criteria.where(TicketFields.REPLY_CONTENT).regex(escapedSearch, "i")); + } + query.addCriteria(new Criteria().orOperator(searchCriteria.toArray(new Criteria[0]))); + } + + if (filter.status() != null && !filter.status().isBlank() && !filter.status().equalsIgnoreCase(FILTER_ALL)) { + if (requestedStatus == TicketStatus.UNFINISHED) { + query.addCriteria(Criteria.where(TicketFields.STATUS).is(TicketStatus.UNFINISHED.getId())); + } else if (requestedStatus != null) { + query.addCriteria(requestedStatus.isTerminal() + ? Criteria.where(TicketFields.LOCKED).is(true) + : Criteria.where(TicketFields.LOCKED).ne(true)); + } else if (filter.status().equalsIgnoreCase(FILTER_OPEN)) { + query.addCriteria(Criteria.where(TicketFields.LOCKED).ne(true)); + } else if (filter.status().equalsIgnoreCase(FILTER_CLOSED)) { + query.addCriteria(Criteria.where(TicketFields.LOCKED).is(true)); + } + } + + if (filter.types() != null && !filter.types().isEmpty()) { + List validTypes = filter.types() + .stream() + .filter(type -> type != null && !type.isBlank() && !type.equals(FILTER_ALL)) + .toList(); + if (!validTypes.isEmpty()) { + List typeCriteria = validTypes.stream() + .map(this::buildTypeCriteria) + .toList(); + query.addCriteria(new Criteria().orOperator(typeCriteria.toArray(new Criteria[0]))); + } + } + + if (filter.author() != null && !filter.author().isBlank()) { + String escapedAuthor = Pattern.quote(filter.author()); + query.addCriteria(Criteria.where(TicketFields.CREATOR_NAME).regex(escapedAuthor, "i")); + } + + if (filter.labels() != null && !filter.labels().isEmpty()) { + query.addCriteria(Criteria.where(TicketFields.TAGS).all(filter.labels())); + } + + Criteria assigneeCriteria = buildAssigneeCriteria(filter.assignees()); + if (assigneeCriteria != null) { + query.addCriteria(assigneeCriteria); + } + + return query; + } + + private Criteria buildAssigneeCriteria(List assignees) { + if (assignees == null || assignees.isEmpty()) { + return null; + } + + List assigneeCriteriaList = new ArrayList<>(); + for (String assignee : assignees) { + if (assignee == null || assignee.isBlank()) { + continue; + } + + if (ASSIGNEE_NONE.equalsIgnoreCase(assignee)) { + assigneeCriteriaList.add(buildUnassignedCriteria()); + continue; + } + + String normalizedAssignee = TicketAssigneeUtil.normalizeSingle(assignee); + if (normalizedAssignee != null) { + assigneeCriteriaList.add(Criteria.where(TicketFields.ASSIGNED_TO).is(normalizedAssignee)); + } + } + + if (assigneeCriteriaList.isEmpty()) { + return null; + } + + return new Criteria().orOperator(assigneeCriteriaList.toArray(new Criteria[0])); + } + + private Criteria buildUnassignedCriteria() { + return new Criteria().orOperator( + Criteria.where(TicketFields.ASSIGNED_TO).exists(false), + Criteria.where(TicketFields.ASSIGNED_TO).is(null), + Criteria.where(TicketFields.ASSIGNED_TO).size(0) + ); + } + + private TicketStatus tryResolveTicketStatus(String rawStatus) { + if (rawStatus == null || rawStatus.isBlank() || FILTER_ALL.equalsIgnoreCase(rawStatus)) { + return null; + } + try { + return TicketStatus.fromCanonicalId(rawStatus); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + public TicketCounts countTickets(Server server, TicketSearchFilter filter) { + Query openQuery = buildSearchQuery(filter, false); + openQuery.addCriteria(Criteria.where(TicketFields.LOCKED).ne(true)); + + Query closedQuery = buildSearchQuery(filter, false); + closedQuery.addCriteria(Criteria.where(TicketFields.LOCKED).is(true)); + + return new TicketCounts(count(server, openQuery), count(server, closedQuery)); + } + + public List findByPlayer(Server server, String playerUuid) { + Criteria criteria = new Criteria().andOperator( + new Criteria().orOperator( + Criteria.where(TicketFields.CREATOR_UUID).is(playerUuid), + Criteria.where(TicketFields.REPORTED_PLAYER_UUID).is(playerUuid) + ), + Criteria.where(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) + ); + Query query = Query.query(criteria).with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); + return find(server, query); + } + + public List findByTag(Server server, String tag) { + return find(server, Query.query(Criteria.where(TicketFields.TAGS).is(tag))); + } + + public List findRecentActiveTicketsWithRepliesByIds(Server server, List ticketIds, int limit) { + if (ticketIds == null || ticketIds.isEmpty()) { + return List.of(); + } + + Query query = Query.query( + Criteria.where(TicketFields.ID).in(ticketIds) + .and(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) + .and(TicketFields.REPLIES + ".0").exists(true) + ); + query.with(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)); + query.limit(limit); + return find(server, query); + } + + public List findRecentAssignedTicketsWithReplies(Server server, String assignee, int limit) { + Query query = Query.query( + Criteria.where(TicketFields.ASSIGNED_TO).is(assignee) + .and(TicketFields.REPLIES + ".0").exists(true) + .and(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) + ); + query.with(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)); + query.limit(limit); + return find(server, query); + } + + public boolean existsByTicketId(Server server, String ticketId) { + return exists(server, Query.query(Criteria.where(TicketFields.ID).is(ticketId))); + } + + public List findAppealsByPunishmentId(Server server, String punishmentId) { + Query query = Query.query( + Criteria.where(TicketFields.TYPE).is(TicketCategory.APPEAL.getId()) + .and(TicketFields.DATA + ".punishmentId").is(punishmentId) + ); + return find(server, query); + } + + public Ticket saveAppeal(Server server, Ticket appeal) { + return saveEntity(server, appeal); + } + + public Ticket insertTicket(Server server, Ticket ticket) { + return insertEntity(server, ticket); + } + + public void pushReply(Server server, String ticketId, TicketReply reply) { + Query query = Query.query(Criteria.where(TicketFields.ID).is(ticketId)); + Update update = new Update() + .push(TicketFields.REPLIES, reply) + .set(TicketFields.UPDATED_AT, new Date()); + updateFirst(server, query, update); + } + + public void updateAppealState(Server server, String ticketId, + AppealWorkflowStatus appealWorkflowStatus, + TicketStatus status, Boolean locked, + Map data, + List systemReplies) { + Query query = Query.query(Criteria.where(TicketFields.ID).is(ticketId)); + Update update = new Update().set(TicketFields.UPDATED_AT, new Date()); + + if (appealWorkflowStatus != null) { + update.set(TicketFields.APPEAL_WORKFLOW_STATUS, appealWorkflowStatus.getId()); + } + if (status != null) { + update.set(TicketFields.STATUS, status.getId()); + } + if (locked != null) { + update.set(TicketFields.LOCKED, locked); + } + if (data != null) { + update.set(TicketFields.DATA, data); + } + if (systemReplies != null) { + for (TicketReply reply : systemReplies) { + update.push(TicketFields.REPLIES, reply); + } + } + updateFirst(server, query, update); + } + + public List findCreatedAfterExcludingUnfinished(Server server, Date after, int limit) { + Query query = Query.query( + Criteria.where(TicketFields.CREATED).gte(after) + .and(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) + ); + query.limit(limit); + return find(server, query); + } + + public long countUnresolvedReports(Server server) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(TicketFields.TYPE).in(TicketCategory.reportCategoryIds()), + Criteria.where(TicketFields.STATUS).in(TicketStatus.OPEN.getId(), TicketStatus.UNFINISHED.getId()) + )); + return count(server, query); + } + + public long countUnresolvedTickets(Server server) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(TicketFields.TYPE).in( + TicketCategory.SUPPORT.getId(), + TicketCategory.BUG.getId(), + TicketCategory.APPEAL.getId() + ), + Criteria.where(TicketFields.STATUS).in(TicketStatus.OPEN.getId(), TicketStatus.UNFINISHED.getId()) + )); + return count(server, query); + } + + public long countAll(Server server) { + return count(server, new Query()); + } + + public long countByStatus(Server server, TicketStatus status) { + return count(server, Query.query(Criteria.where(TicketFields.STATUS).is(status.getId()))); + } + + public long countCreatedAfter(Server server, Date after) { + return count(server, Query.query(Criteria.where(TicketFields.CREATED).gte(after))); + } + + public long countCreatedBetween(Server server, Date from, Date to) { + return count(server, Query.query(Criteria.where(TicketFields.CREATED).gte(from).lt(to))); + } + + public List findRecentWithProjection(Server server, int limit) { + Query query = Query.query(Criteria.where(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId())) + .with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)) + .limit(limit); + + query.fields() + .include(TicketFields.SUBJECT) + .include(TicketFields.STATUS) + .include(TicketFields.PRIORITY) + .include(TicketFields.CREATED) + .include(TicketFields.CREATOR_NAME) + .include(TicketFields.TYPE) + .include(TicketFields.REPLIES); + query.fields().slice(TicketFields.REPLIES, 1); + + return find(server, query); + } + + public List findStaffActivityTickets(Server server, String staffUsername, String normalizedStaffUsername, Date cutoffDate, int limit) { + List staffMatchCriteria = new ArrayList<>(); + staffMatchCriteria.add(Criteria.where(TicketFields.CREATOR_NAME).is(staffUsername)); + if (normalizedStaffUsername != null) { + staffMatchCriteria.add(Criteria.where(TicketFields.ASSIGNED_TO).is(normalizedStaffUsername)); + } + staffMatchCriteria.add(Criteria.where(TicketFields.REPLY_NAME).is(staffUsername)); + + Query query = Query.query(new Criteria().andOperator( + Criteria.where(TicketFields.UPDATED_AT).gte(cutoffDate), + new Criteria().orOperator(staffMatchCriteria.toArray(new Criteria[0])) + )).with(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)).limit(limit); + + query.fields() + .include(TicketFields.SUBJECT) + .include(TicketFields.TYPE) + .include(TicketFields.CREATED) + .include(TicketFields.CREATOR_NAME) + .include(TicketFields.REPLY_NAME) + .include(TicketFields.REPLY_CREATED); + + return find(server, query); + } + + public enum TicketSortOption { + NEWEST(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)), + OLDEST(Sort.by(Sort.Direction.ASC, TicketFields.CREATED)), + RECENTLY_UPDATED(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)), + LEAST_RECENTLY_UPDATED(Sort.by(Sort.Direction.ASC, TicketFields.UPDATED_AT)); + + private final Sort mongoSort; + + TicketSortOption(Sort mongoSort) { + this.mongoSort = mongoSort; + } + + public static TicketSortOption from(String rawSort) { + if (rawSort == null || rawSort.isBlank()) { + return NEWEST; + } + return switch (rawSort) { + case SORT_OLDEST -> OLDEST; + case SORT_RECENTLY_UPDATED -> RECENTLY_UPDATED; + case SORT_LEAST_RECENTLY_UPDATED -> LEAST_RECENTLY_UPDATED; + default -> NEWEST; + }; + } + + public Sort toMongoSort() { + return mongoSort; + } + } + + public record TicketSearchFilter( + String search, + String status, + List types, + String author, + List labels, + List assignees + ) {} + + public record TicketSearchPage(List tickets, long total) {} + + public record TicketCounts(long open, long closed) {} + + public void bulkCloseForPunishment(Server server, List ticketIds) { + if (ticketIds == null || ticketIds.isEmpty()) { + return; + } + Query query = Query.query( + Criteria.where(TicketFields.ID).in(ticketIds) + .and(TicketFields.LOCKED).ne(true) + ); + Update update = new Update() + .set(TicketFields.STATUS, TicketStatus.CLOSED.getId()) + .set(TicketFields.LOCKED, true) + .set(TicketFields.UPDATED_AT, new Date()); + updateMulti(server, query, update); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/TrainingSegmentRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/TrainingSegmentRepository.java index d8ad96a..19f5536 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/TrainingSegmentRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/TrainingSegmentRepository.java @@ -6,21 +6,19 @@ import gg.modl.backend.replay.data.TrainingSegmentDocument; import java.util.Collection; import java.util.List; +import lombok.RequiredArgsConstructor; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.stereotype.Repository; @Repository +@RequiredArgsConstructor public class TrainingSegmentRepository { private static final String COLLECTION = CollectionName.TRAINING_SEGMENTS; private final TenantMongoAccess tenantMongoAccess; - public TrainingSegmentRepository(TenantMongoAccess tenantMongoAccess) { - this.tenantMongoAccess = tenantMongoAccess; - } - public void save(TrainingSegmentDocument doc) { nonTenantTrainingStore().save(doc, COLLECTION); } diff --git a/src/main/java/gg/modl/backend/dev/DevTenantSeeder.java b/src/main/java/gg/modl/backend/dev/DevTenantSeeder.java index a8ed501..17cab9e 100644 --- a/src/main/java/gg/modl/backend/dev/DevTenantSeeder.java +++ b/src/main/java/gg/modl/backend/dev/DevTenantSeeder.java @@ -1,6 +1,6 @@ package gg.modl.backend.dev; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerLookupRepository; import gg.modl.backend.email.EmailAddressUtil; import gg.modl.backend.infrastructure.config.ModlDevProperties; import gg.modl.backend.infrastructure.config.ModlProperties; @@ -25,7 +25,7 @@ public class DevTenantSeeder implements ApplicationRunner { private final ModlProperties modlProperties; private final ModlDevProperties devProperties; private final ServerService serverService; - private final ServerMongoRepository serverRepository; + private final ServerLookupRepository serverLookupRepository; private final ServerProvisioningService provisioningService; @Override @@ -44,7 +44,7 @@ public void run(ApplicationArguments args) { String customDomain = resolveCustomDomain(serverDomain); String adminEmail = normalizeEmail(devProperties.getSeedAdminEmail()); - Server server = serverRepository.findByCustomDomain(customDomain).orElse(null); + Server server = serverLookupRepository.findByCustomDomain(customDomain).orElse(null); if (server == null) { server = serverService.createServer(devProperties.getServerName(), customDomain, adminEmail, null, ServerPlan.PREMIUM); log.warn("[DevSeed] Created test tenant server '{}' (customDomain={})", devProperties.getServerName(), customDomain); @@ -62,7 +62,7 @@ public void run(ApplicationArguments args) { } server.setUpdatedAt(new Date()); - serverRepository.saveEntity(server); + serverLookupRepository.saveEntity(server); serverService.evictAllServerCaches(); log.warn("======================================================================"); diff --git a/src/main/java/gg/modl/backend/email/EmailConfiguration.java b/src/main/java/gg/modl/backend/email/EmailConfiguration.java index 1e91275..82efbe7 100644 --- a/src/main/java/gg/modl/backend/email/EmailConfiguration.java +++ b/src/main/java/gg/modl/backend/email/EmailConfiguration.java @@ -1,17 +1,17 @@ -package gg.modl.backend.email; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; -import org.springframework.validation.annotation.Validated; - -@Component -@ConfigurationProperties(prefix = "modl.email") -@Validated -@Getter -@Setter -public class EmailConfiguration { - private String fromName; - private String fromEmailAddress; -} +package gg.modl.backend.email; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Component +@ConfigurationProperties(prefix = "modl.email") +@Validated +@Getter +@Setter +public class EmailConfiguration { + private String fromName; + private String fromEmailAddress; +} diff --git a/src/main/java/gg/modl/backend/email/EmailHTMLTemplate.java b/src/main/java/gg/modl/backend/email/EmailHTMLTemplate.java index 6b43405..9579ab0 100644 --- a/src/main/java/gg/modl/backend/email/EmailHTMLTemplate.java +++ b/src/main/java/gg/modl/backend/email/EmailHTMLTemplate.java @@ -1,344 +1,344 @@ -package gg.modl.backend.email; - -import static gg.modl.backend.Constants.BRAND_NAME; - -public interface EmailHTMLTemplate { - CodeTemplate USER_CODE = (serverName, code) -> new HTMLEmail( - "%s | Login Code".formatted(serverName), - """ -
-
-

Login Verification Code

- -

- Your login verification code for %s is: -

- -
-

%s

-
- -

- This code will expire in 15 minutes. -

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(serverName, code) - ); - - CodeTemplate ADMIN_CODE = (serverName, code) -> new HTMLEmail( - BRAND_NAME + " | Admin Login Code", - """ -
-
-

Admin Verification Code

- -

- Your admin panel login code is: -

- -
-

%s

-
- -

- This code will expire in 15 minutes. -

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(code)); - - CodeTemplate EMAIL_CHANGE_CODE = (serverName, code) -> new HTMLEmail( - "%s | Confirm your new email".formatted(serverName), - """ -
-
-

Confirm Your New Email

- -

- Use this code to confirm your new email address for %s: -

- -
-

%s

-
- -

- This code will expire shortly. If you did not request an email change, you can ignore this message and your address will stay the same. -

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(serverName, code) - ); - - EmailChangedNoticeTemplate EMAIL_CHANGED_NOTICE = (serverName, newEmail) -> new HTMLEmail( - "%s | Your email address was changed".formatted(serverName), - """ -
-
-

Email Address Changed

- -

- The email address for your %s staff account was just changed to %s. -

- -

- If you made this change, no action is needed. If you did not, contact your server administrator immediately. Your account security may be at risk. -

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(serverName, newEmail) - ); - - VerifyLinkTemplate REGISTRATION_VERIFY_LINK = link -> new HTMLEmail( - BRAND_NAME + " | Verify your email", - """ -
-
-

Verify Your Email

- -

- Thank you for registering with %s! -

- -

- Please verify your email address by clicking the button below: -

- - - -

- Or copy and paste this link into your browser: -

-

%s

- -
-

- If you didn't create an account, you can safely ignore this email. -

-
-
-
- """.formatted(BRAND_NAME, link, link)); - - BetaReadyTemplate BETA_PANEL_READY = (serverName, panelLink) -> new HTMLEmail( - "%s | Your beta panel is ready".formatted(serverName), - """ -
-
-

Welcome to the %s Beta

- -

- Your beta tester panel for %s is provisioned and ready to use. -

- -
-

Premium unlocked

-

You've been granted Premium access for free while you help us test. Some usage limits apply during the beta.

-
- - - -

- Or copy and paste this link into your browser: -

-

%s

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(BRAND_NAME, serverName, panelLink, panelLink)); - - StaffInviteTemplate STAFF_INVITE_TEMPLATE = (serverName, staffRole, link) -> new HTMLEmail( - "%s | Staff Invitation".formatted(serverName), - """ -
-
-

Team Invitation

- -

- You have been invited to join the %s team as a %s! -

- -
-

Welcome to the Team!

-

Click the button below to accept your invitation and get started.

-
- - - -

- This invitation will expire in 24 hours. -

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(serverName, staffRole, link) - ); - - TicketReplyTemplate TICKET_REPLY_TEMPLATE = (serverName, playerName, isStaffReply, ticketType, ticketId, ticketSubject, replyAuthor, replyContent, ticketUrl) -> new HTMLEmail( - "%s | Someone replied to Ticket #%s".formatted(serverName, ticketId), - """ -
-
-

Ticket Reply Notification

- -

Hello %s,

- -

- %s has replied to your %s ticket: -

- -
-

Ticket #%s: %s

-
- -
-
Reply from %s:
-

%s

-
- - - -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(playerName, isStaffReply ? "A staff member" : "Someone", ticketType, ticketId, ticketSubject, replyAuthor, replyContent, ticketUrl)); - - TicketTranscriptTemplate TICKET_TRANSCRIPT_TEMPLATE = (serverName, playerName, ticketType, ticketId, ticketSubject, messagesHtml, ticketUrl) -> new HTMLEmail( - "%s | Ticket #%s Transcript".formatted(serverName, ticketId), - """ -
-
-

Ticket Closed - Transcript

- -

Hello %s,

- -

- Your %s ticket has been closed. Here is the full transcript: -

- -
-

Ticket #%s: %s

-
- - %s - - - -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(playerName, ticketType, ticketId, ticketSubject, messagesHtml, ticketUrl)); - - CodeTemplate TICKET_VERIFICATION_CODE = (serverName, code) -> new HTMLEmail( - "%s | Ticket Verification Code".formatted(serverName), - """ -
-
-

Ticket Verification Code

- -

- Your ticket verification code for %s is: -

- -
-

%s

-
- -

- This code will expire in 15 minutes. -

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(serverName, code) - ); - - interface CodeTemplate { - HTMLEmail build(String serverName, String code); - } - - interface VerifyLinkTemplate { - HTMLEmail build(String link); - } - - interface EmailChangedNoticeTemplate { - HTMLEmail build(String serverName, String newEmail); - } - - interface BetaReadyTemplate { - HTMLEmail build(String serverName, String panelLink); - } - - interface StaffInviteTemplate { - HTMLEmail build(String serverName, String staffRole, String link); - } - - interface TicketReplyTemplate { - HTMLEmail build(String serverName, String playerName, boolean isStaffReply, String ticketType, String ticketId, String ticketSubject, String replyAuthor, String replyContent, String ticketUrl); - } - - interface TicketTranscriptTemplate { - HTMLEmail build(String serverName, String playerName, String ticketType, String ticketId, String ticketSubject, String messagesHtml, String ticketUrl); - } - - record HTMLEmail(String subject, String body) { - } -} +package gg.modl.backend.email; + +import static gg.modl.backend.Constants.BRAND_NAME; + +public interface EmailHTMLTemplate { + CodeTemplate USER_CODE = (serverName, code) -> new HTMLEmail( + "%s | Login Code".formatted(serverName), + """ +
+
+

Login Verification Code

+ +

+ Your login verification code for %s is: +

+ +
+

%s

+
+ +

+ This code will expire in 15 minutes. +

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(serverName, code) + ); + + CodeTemplate ADMIN_CODE = (serverName, code) -> new HTMLEmail( + BRAND_NAME + " | Admin Login Code", + """ +
+
+

Admin Verification Code

+ +

+ Your admin panel login code is: +

+ +
+

%s

+
+ +

+ This code will expire in 15 minutes. +

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(code)); + + CodeTemplate EMAIL_CHANGE_CODE = (serverName, code) -> new HTMLEmail( + "%s | Confirm your new email".formatted(serverName), + """ +
+
+

Confirm Your New Email

+ +

+ Use this code to confirm your new email address for %s: +

+ +
+

%s

+
+ +

+ This code will expire shortly. If you did not request an email change, you can ignore this message and your address will stay the same. +

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(serverName, code) + ); + + EmailChangedNoticeTemplate EMAIL_CHANGED_NOTICE = (serverName, newEmail) -> new HTMLEmail( + "%s | Your email address was changed".formatted(serverName), + """ +
+
+

Email Address Changed

+ +

+ The email address for your %s staff account was just changed to %s. +

+ +

+ If you made this change, no action is needed. If you did not, contact your server administrator immediately. Your account security may be at risk. +

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(serverName, newEmail) + ); + + VerifyLinkTemplate REGISTRATION_VERIFY_LINK = link -> new HTMLEmail( + BRAND_NAME + " | Verify your email", + """ +
+
+

Verify Your Email

+ +

+ Thank you for registering with %s! +

+ +

+ Please verify your email address by clicking the button below: +

+ + + +

+ Or copy and paste this link into your browser: +

+

%s

+ +
+

+ If you didn't create an account, you can safely ignore this email. +

+
+
+
+ """.formatted(BRAND_NAME, link, link)); + + BetaReadyTemplate BETA_PANEL_READY = (serverName, panelLink) -> new HTMLEmail( + "%s | Your beta panel is ready".formatted(serverName), + """ +
+
+

Welcome to the %s Beta

+ +

+ Your beta tester panel for %s is provisioned and ready to use. +

+ +
+

Premium unlocked

+

You've been granted Premium access for free while you help us test. Some usage limits apply during the beta.

+
+ + + +

+ Or copy and paste this link into your browser: +

+

%s

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(BRAND_NAME, serverName, panelLink, panelLink)); + + StaffInviteTemplate STAFF_INVITE_TEMPLATE = (serverName, staffRole, link) -> new HTMLEmail( + "%s | Staff Invitation".formatted(serverName), + """ +
+
+

Team Invitation

+ +

+ You have been invited to join the %s team as a %s! +

+ +
+

Welcome to the Team!

+

Click the button below to accept your invitation and get started.

+
+ + + +

+ This invitation will expire in 24 hours. +

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(serverName, staffRole, link) + ); + + TicketReplyTemplate TICKET_REPLY_TEMPLATE = (serverName, playerName, isStaffReply, ticketType, ticketId, ticketSubject, replyAuthor, replyContent, ticketUrl) -> new HTMLEmail( + "%s | Someone replied to Ticket #%s".formatted(serverName, ticketId), + """ +
+
+

Ticket Reply Notification

+ +

Hello %s,

+ +

+ %s has replied to your %s ticket: +

+ +
+

Ticket #%s: %s

+
+ +
+
Reply from %s:
+

%s

+
+ + + +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(playerName, isStaffReply ? "A staff member" : "Someone", ticketType, ticketId, ticketSubject, replyAuthor, replyContent, ticketUrl)); + + TicketTranscriptTemplate TICKET_TRANSCRIPT_TEMPLATE = (serverName, playerName, ticketType, ticketId, ticketSubject, messagesHtml, ticketUrl) -> new HTMLEmail( + "%s | Ticket #%s Transcript".formatted(serverName, ticketId), + """ +
+
+

Ticket Closed - Transcript

+ +

Hello %s,

+ +

+ Your %s ticket has been closed. Here is the full transcript: +

+ +
+

Ticket #%s: %s

+
+ + %s + + + +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(playerName, ticketType, ticketId, ticketSubject, messagesHtml, ticketUrl)); + + CodeTemplate TICKET_VERIFICATION_CODE = (serverName, code) -> new HTMLEmail( + "%s | Ticket Verification Code".formatted(serverName), + """ +
+
+

Ticket Verification Code

+ +

+ Your ticket verification code for %s is: +

+ +
+

%s

+
+ +

+ This code will expire in 15 minutes. +

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(serverName, code) + ); + + interface CodeTemplate { + HTMLEmail build(String serverName, String code); + } + + interface VerifyLinkTemplate { + HTMLEmail build(String link); + } + + interface EmailChangedNoticeTemplate { + HTMLEmail build(String serverName, String newEmail); + } + + interface BetaReadyTemplate { + HTMLEmail build(String serverName, String panelLink); + } + + interface StaffInviteTemplate { + HTMLEmail build(String serverName, String staffRole, String link); + } + + interface TicketReplyTemplate { + HTMLEmail build(String serverName, String playerName, boolean isStaffReply, String ticketType, String ticketId, String ticketSubject, String replyAuthor, String replyContent, String ticketUrl); + } + + interface TicketTranscriptTemplate { + HTMLEmail build(String serverName, String playerName, String ticketType, String ticketId, String ticketSubject, String messagesHtml, String ticketUrl); + } + + record HTMLEmail(String subject, String body) { + } +} diff --git a/src/main/java/gg/modl/backend/email/EmailService.java b/src/main/java/gg/modl/backend/email/EmailService.java index 9c94a90..6ed268b 100644 --- a/src/main/java/gg/modl/backend/email/EmailService.java +++ b/src/main/java/gg/modl/backend/email/EmailService.java @@ -1,23 +1,23 @@ -package gg.modl.backend.email; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class EmailService { - private final AsyncEmailDispatcher dispatcher; - - public void sendStaffInviteEmail(String toEmail, String serverName, String role, String invitationLink) { - EmailHTMLTemplate.HTMLEmail email = EmailHTMLTemplate.STAFF_INVITE_TEMPLATE.build(serverName, role, invitationLink); - send(toEmail, email); - } - - public void send(String toEmail, EmailHTMLTemplate.HTMLEmail email) { - send(toEmail, email.subject(), email.body()); - } - - public void send(String toEmail, String subject, String htmlBody) { - dispatcher.dispatch(toEmail, subject, htmlBody); - } -} +package gg.modl.backend.email; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailService { + private final AsyncEmailDispatcher dispatcher; + + public void sendStaffInviteEmail(String toEmail, String serverName, String role, String invitationLink) { + EmailHTMLTemplate.HTMLEmail email = EmailHTMLTemplate.STAFF_INVITE_TEMPLATE.build(serverName, role, invitationLink); + send(toEmail, email); + } + + public void send(String toEmail, EmailHTMLTemplate.HTMLEmail email) { + send(toEmail, email.subject(), email.body()); + } + + public void send(String toEmail, String subject, String htmlBody) { + dispatcher.dispatch(toEmail, subject, htmlBody); + } +} diff --git a/src/main/java/gg/modl/backend/homepage/controller/HomepageProtoMapper.java b/src/main/java/gg/modl/backend/homepage/controller/HomepageProtoMapper.java index 4d8fe9b..55f1548 100644 --- a/src/main/java/gg/modl/backend/homepage/controller/HomepageProtoMapper.java +++ b/src/main/java/gg/modl/backend/homepage/controller/HomepageProtoMapper.java @@ -1,12 +1,15 @@ package gg.modl.backend.homepage.controller; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.addAll; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.nullToEmpty; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toTimestamp; import gg.modl.backend.homepage.data.HomepageCard; -import gg.modl.backend.homepage.dto.response.HomepageCardResponse; import gg.modl.backend.homepage.service.HomepageCardService; +import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; +import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; import gg.modl.proto.modl.v1.HomepageCardMutationResponse; +import gg.modl.proto.modl.v1.HomepageCardResponse; import gg.modl.proto.modl.v1.PanelHomepageCardResponse; import gg.modl.proto.modl.v1.PanelHomepageCardsResponse; import gg.modl.proto.modl.v1.PublicHomepageCardsResponse; @@ -26,7 +29,7 @@ public PanelHomepageCardResponse toPanelCardResponse(HomepageCard card) { return panelCardBuilder(card).build(); } - public PublicHomepageCardsResponse toPublicCardsResponse(List cards) { + public PublicHomepageCardsResponse toPublicCardsResponse(List cards) { PublicHomepageCardsResponse.Builder builder = PublicHomepageCardsResponse.newBuilder(); addAll(cards, this::toPublicCard, builder::addCards); return builder.build(); @@ -73,51 +76,47 @@ private PanelHomepageCardResponse.Builder panelCardBuilder(HomepageCard card) { return builder; } - private gg.modl.proto.modl.v1.HomepageCardResponse toPublicCard(HomepageCardResponse card) { - gg.modl.proto.modl.v1.HomepageCardResponse.Builder builder = - gg.modl.proto.modl.v1.HomepageCardResponse.newBuilder() - .setId(nullToEmpty(card.id())) - .setTitle(nullToEmpty(card.title())) - .setDescription(nullToEmpty(card.description())) - .setIcon(nullToEmpty(card.icon())) - .setIconColor(nullToEmpty(card.iconColor())) - .setActionType(nullToEmpty(card.actionType())) - .setActionUrl(nullToEmpty(card.actionUrl())) - .setActionButtonText(nullToEmpty(card.actionButtonText())) - .setCategoryId(nullToEmpty(card.categoryId())) - .setBackgroundColor(nullToEmpty(card.backgroundColor())) - .setOrdinal(card.ordinal()) - .setIsEnabled(card.isEnabled()); - if (card.createdAt() != null) { - builder.setCreatedAt(toTimestamp(card.createdAt())); + private HomepageCardResponse toPublicCard(HomepageCardService.EnrichedCardWithArticles enriched) { + HomepageCard card = enriched.card(); + HomepageCardResponse.Builder builder = HomepageCardResponse.newBuilder() + .setId(nullToEmpty(card.getId())) + .setTitle(nullToEmpty(card.getTitle())) + .setDescription(nullToEmpty(card.getDescription())) + .setIcon(nullToEmpty(card.getIcon())) + .setIconColor(nullToEmpty(card.getIconColor())) + .setActionType(nullToEmpty(card.getActionType())) + .setActionUrl(nullToEmpty(card.getActionUrl())) + .setActionButtonText(nullToEmpty(card.getActionButtonText())) + .setCategoryId(nullToEmpty(card.getCategoryId())) + .setBackgroundColor(nullToEmpty(card.getBackgroundColor())) + .setOrdinal(card.getOrdinal()) + .setIsEnabled(card.isEnabled()); + if (card.getCreatedAt() != null) { + builder.setCreatedAt(toTimestamp(card.getCreatedAt())); } - if (card.updatedAt() != null) { - builder.setUpdatedAt(toTimestamp(card.updatedAt())); + if (card.getUpdatedAt() != null) { + builder.setUpdatedAt(toTimestamp(card.getUpdatedAt())); } - HomepageCardResponse.EmbeddedCategory category = card.category(); + KnowledgebaseCategory category = enriched.category(); if (category != null) { - gg.modl.proto.modl.v1.HomepageCardResponse.EmbeddedCategory.Builder embedded = - gg.modl.proto.modl.v1.HomepageCardResponse.EmbeddedCategory.newBuilder() - .setId(nullToEmpty(category.id())) - .setName(nullToEmpty(category.name())) - .setSlug(nullToEmpty(category.slug())) - .setDescription(nullToEmpty(category.description())); - addAll(category.articles(), this::toArticleStub, embedded::addArticles); + HomepageCardResponse.EmbeddedCategory.Builder embedded = + HomepageCardResponse.EmbeddedCategory.newBuilder() + .setId(nullToEmpty(category.getId())) + .setName(nullToEmpty(category.getName())) + .setSlug(nullToEmpty(category.getSlug())) + .setDescription(nullToEmpty(category.getDescription())); + addAll(enriched.articles(), this::toArticleStub, embedded::addArticles); builder.setCategory(embedded); } return builder.build(); } - private gg.modl.proto.modl.v1.HomepageCardResponse.ArticleStub toArticleStub(HomepageCardResponse.ArticleStub stub) { - return gg.modl.proto.modl.v1.HomepageCardResponse.ArticleStub.newBuilder() - .setId(nullToEmpty(stub.id())) - .setTitle(nullToEmpty(stub.title())) - .setSlug(nullToEmpty(stub.slug())) - .setOrdinal(stub.ordinal()) + private HomepageCardResponse.ArticleStub toArticleStub(KnowledgebaseArticle article) { + return HomepageCardResponse.ArticleStub.newBuilder() + .setId(nullToEmpty(article.getId())) + .setTitle(nullToEmpty(article.getTitle())) + .setSlug(nullToEmpty(article.getSlug())) + .setOrdinal(article.getOrdinal()) .build(); } - - private static String nullToEmpty(String value) { - return value == null ? "" : value; - } } diff --git a/src/main/java/gg/modl/backend/homepage/controller/PanelHomepageCardController.java b/src/main/java/gg/modl/backend/homepage/controller/PanelHomepageCardController.java index 6a672a2..287edb4 100644 --- a/src/main/java/gg/modl/backend/homepage/controller/PanelHomepageCardController.java +++ b/src/main/java/gg/modl/backend/homepage/controller/PanelHomepageCardController.java @@ -1,93 +1,95 @@ -package gg.modl.backend.homepage.controller; - -import gg.modl.backend.homepage.data.HomepageCard; -import gg.modl.backend.homepage.service.HomepageCardService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.CreateCardRequest; -import gg.modl.proto.modl.v1.HomepageCardMutationResponse; -import gg.modl.proto.modl.v1.PanelHomepageCardResponse; -import gg.modl.proto.modl.v1.PanelHomepageCardsResponse; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.ReorderRequest; -import gg.modl.proto.modl.v1.UpdateCardRequest; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_HOMEPAGE_CARDS) -@RequiredArgsConstructor -public class PanelHomepageCardController { - private final HomepageCardService cardService; - private final HomepageProtoMapper mapper; - private final RealtimeEventPublisher publisher; - - @GetMapping - public ResponseEntity getCards(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(mapper.toPanelCardsResponse(cardService.getAllCardsEnriched(server))); - } - - @PostMapping - public ResponseEntity createCard( - @RequestBody CreateCardRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - HomepageCard card = cardService.createCard(server, createRequest); - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE, card.getId()); - return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toPanelCardResponse(card)); - } - - @PutMapping("/{id}") - public ResponseEntity updateCard( - @PathVariable String id, - @RequestBody UpdateCardRequest updateRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return cardService.updateCard(server, id, updateRequest) - .map(card -> { - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE, card.getId()); - return ResponseEntity.ok(mapper.toPanelCardResponse(card)); - }) - .orElseGet(() -> ResponseEntity.notFound().build()); - } - - @DeleteMapping("/{id}") - public ResponseEntity deleteCard( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - boolean deleted = cardService.deleteCard(server, id); - if (deleted) { - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE, id); - return ResponseEntity.ok(mapper.message("Card deleted")); - } - return ResponseEntity.notFound().build(); - } - - @PutMapping("/reorder") - public ResponseEntity reorderCards( - @RequestBody ReorderRequest reorderRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - cardService.reorderCards(server, reorderRequest.getIdsList()); - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE); - return ResponseEntity.ok(mapper.message("Cards reordered")); - } -} +package gg.modl.backend.homepage.controller; + +import gg.modl.backend.homepage.data.HomepageCard; +import gg.modl.backend.homepage.service.HomepageCardService; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.CreateCardRequest; +import gg.modl.proto.modl.v1.HomepageCardMutationResponse; +import gg.modl.proto.modl.v1.PanelHomepageCardResponse; +import gg.modl.proto.modl.v1.PanelHomepageCardsResponse; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.ReorderRequest; +import gg.modl.proto.modl.v1.UpdateCardRequest; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_HOMEPAGE_CARDS) +@RequiresPanelPermission(view = "admin.settings.view.content", modify = "admin.settings.modify.content") +@RequiredArgsConstructor +public class PanelHomepageCardController { + private final HomepageCardService cardService; + private final HomepageProtoMapper mapper; + private final RealtimeEventPublisher publisher; + + @GetMapping + public ResponseEntity getCards(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(mapper.toPanelCardsResponse(cardService.getAllCardsEnriched(server))); + } + + @PostMapping + public ResponseEntity createCard( + @RequestBody CreateCardRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + HomepageCard card = cardService.createCard(server, createRequest); + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE, card.getId()); + return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toPanelCardResponse(card)); + } + + @PutMapping("/{id}") + public ResponseEntity updateCard( + @PathVariable String id, + @RequestBody UpdateCardRequest updateRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return cardService.updateCard(server, id, updateRequest) + .map(card -> { + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE, card.getId()); + return ResponseEntity.ok(mapper.toPanelCardResponse(card)); + }) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCard( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + boolean deleted = cardService.deleteCard(server, id); + if (deleted) { + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE, id); + return ResponseEntity.ok(mapper.message("Card deleted")); + } + return ResponseEntity.notFound().build(); + } + + @PutMapping("/reorder") + public ResponseEntity reorderCards( + @RequestBody ReorderRequest reorderRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + cardService.reorderCards(server, reorderRequest.getIdsList()); + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE); + return ResponseEntity.ok(mapper.message("Cards reordered")); + } +} diff --git a/src/main/java/gg/modl/backend/homepage/controller/PublicHomepageCardController.java b/src/main/java/gg/modl/backend/homepage/controller/PublicHomepageCardController.java index 06e6662..c19bdf3 100644 --- a/src/main/java/gg/modl/backend/homepage/controller/PublicHomepageCardController.java +++ b/src/main/java/gg/modl/backend/homepage/controller/PublicHomepageCardController.java @@ -1,36 +1,27 @@ -package gg.modl.backend.homepage.controller; - -import gg.modl.backend.homepage.dto.response.HomepageCardResponse; -import gg.modl.backend.homepage.service.HomepageCardService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.PublicHomepageCardsResponse; -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PUBLIC_HOMEPAGE_CARDS) -@RequiredArgsConstructor -public class PublicHomepageCardController { - private final HomepageCardService cardService; - private final HomepageProtoMapper mapper; - - @GetMapping - public ResponseEntity getCards(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List enrichedCards = cardService.getVisibleCardsEnrichedWithArticles(server).stream() - .map(enriched -> enriched.category() == null - ? HomepageCardResponse.from(enriched.card()) - : HomepageCardResponse.from(enriched.card(), - HomepageCardResponse.EmbeddedCategory.from(enriched.category(), enriched.articles()))) - .toList(); - - return ResponseEntity.ok(mapper.toPublicCardsResponse(enrichedCards)); - } -} +package gg.modl.backend.homepage.controller; + +import gg.modl.backend.homepage.service.HomepageCardService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.PublicHomepageCardsResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PUBLIC_HOMEPAGE_CARDS) +@RequiredArgsConstructor +public class PublicHomepageCardController { + private final HomepageCardService cardService; + private final HomepageProtoMapper mapper; + + @GetMapping + public ResponseEntity getCards(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(mapper.toPublicCardsResponse(cardService.getVisibleCardsEnrichedWithArticles(server))); + } +} diff --git a/src/main/java/gg/modl/backend/homepage/dto/response/HomepageCardResponse.java b/src/main/java/gg/modl/backend/homepage/dto/response/HomepageCardResponse.java deleted file mode 100644 index e99992e..0000000 --- a/src/main/java/gg/modl/backend/homepage/dto/response/HomepageCardResponse.java +++ /dev/null @@ -1,77 +0,0 @@ -package gg.modl.backend.homepage.dto.response; - -import gg.modl.backend.homepage.data.HomepageCard; -import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; -import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; -import java.util.Date; -import java.util.List; - -public record HomepageCardResponse( - String id, - String title, - String description, - String icon, - String iconColor, - String actionType, - String actionUrl, - String actionButtonText, - String categoryId, - String backgroundColor, - int ordinal, - boolean isEnabled, - Date createdAt, - Date updatedAt, - EmbeddedCategory category -) { - public static HomepageCardResponse from(HomepageCard card) { - return from(card, null); - } - - public static HomepageCardResponse from(HomepageCard card, EmbeddedCategory category) { - return new HomepageCardResponse( - card.getId(), - card.getTitle(), - card.getDescription(), - card.getIcon(), - card.getIconColor(), - card.getActionType(), - card.getActionUrl(), - card.getActionButtonText(), - card.getCategoryId(), - card.getBackgroundColor(), - card.getOrdinal(), - card.isEnabled(), - card.getCreatedAt(), - card.getUpdatedAt(), - category - ); - } - - public record EmbeddedCategory( - String id, - String name, - String slug, - String description, - List articles - ) { - public static EmbeddedCategory from(KnowledgebaseCategory category, List articles) { - List articleStubs = articles.stream() - .map(a -> new ArticleStub(a.getId(), a.getTitle(), a.getSlug(), a.getOrdinal())) - .toList(); - return new EmbeddedCategory( - category.getId(), - category.getName(), - category.getSlug(), - category.getDescription(), - articleStubs - ); - } - } - - public record ArticleStub( - String id, - String title, - String slug, - int ordinal - ) {} -} diff --git a/src/main/java/gg/modl/backend/homepage/service/HomepageCardService.java b/src/main/java/gg/modl/backend/homepage/service/HomepageCardService.java index 990b4d0..5deae07 100644 --- a/src/main/java/gg/modl/backend/homepage/service/HomepageCardService.java +++ b/src/main/java/gg/modl/backend/homepage/service/HomepageCardService.java @@ -1,164 +1,170 @@ -package gg.modl.backend.homepage.service; - -import gg.modl.backend.database.mongo.repository.HomepageCardMongoRepository; -import gg.modl.backend.homepage.data.HomepageCard; -import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; -import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; -import gg.modl.backend.knowledgebase.service.KnowledgebaseArticleService; -import gg.modl.backend.knowledgebase.service.KnowledgebaseCategoryService; -import gg.modl.backend.infrastructure.validation.SafeUrls; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.CreateCardRequest; -import gg.modl.proto.modl.v1.UpdateCardRequest; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class HomepageCardService { - private final HomepageCardMongoRepository homepageCardRepository; - private final KnowledgebaseCategoryService categoryService; - private final KnowledgebaseArticleService articleService; - - public List getVisibleCards(Server server) { - return homepageCardRepository.findVisibleOrdered(server); - } - - public List getVisibleCardsEnrichedWithArticles(Server server) { - List cards = getVisibleCards(server); - - List categoryIds = cards.stream() - .filter(card -> "category_dropdown".equals(card.getActionType())) - .map(HomepageCard::getCategoryId) - .filter(id -> id != null && !id.isEmpty()) - .distinct() - .toList(); - - Map categoriesById = categoryService.getCategoriesByIds(server, categoryIds) - .stream() - .collect(Collectors.toMap(KnowledgebaseCategory::getId, Function.identity())); - - Map> articlesByCategoryId = - articleService.getVisibleArticlesGroupedByCategoryIds(server, categoryIds); - - return cards.stream() - .map(card -> { - KnowledgebaseCategory category = null; - if ("category_dropdown".equals(card.getActionType()) - && card.getCategoryId() != null && !card.getCategoryId().isEmpty()) { - category = categoriesById.get(card.getCategoryId()); - } - List articles = category != null - ? articlesByCategoryId.getOrDefault(category.getId(), List.of()) - : List.of(); - return new EnrichedCardWithArticles(card, category, articles); - }) - .toList(); - } - - public Optional getCardById(Server server, String id) { - return homepageCardRepository.findByCardId(server, id); - } - - public HomepageCard createCard(Server server, CreateCardRequest request) { - String actionUrl = request.hasActionUrl() ? request.getActionUrl() : null; - SafeUrls.requireSafe(actionUrl, "Invalid card URL"); - HomepageCard card = HomepageCard.builder() - .title(request.getTitle()) - .description(request.hasDescription() ? request.getDescription() : null) - .icon(request.hasIcon() ? request.getIcon() : null) - .iconColor(request.hasIconColor() ? request.getIconColor() : null) - .actionType(request.hasActionType() ? request.getActionType() : null) - .actionUrl(actionUrl) - .actionButtonText(request.hasActionButtonText() ? request.getActionButtonText() : null) - .categoryId(request.hasCategoryId() ? request.getCategoryId() : null) - .backgroundColor(request.hasBackgroundColor() ? request.getBackgroundColor() : null) - .ordinal(homepageCardRepository.findMaxOrdinal(server) + 1) - .isEnabled(!request.hasIsEnabled() || request.getIsEnabled()) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(); - - return homepageCardRepository.saveEntity(server, card); - } - - public Optional updateCard(Server server, String id, UpdateCardRequest request) { - String actionUrl = request.hasActionUrl() ? request.getActionUrl() : null; - SafeUrls.requireSafe(actionUrl, "Invalid card URL"); - return homepageCardRepository.updateCard( - server, - id, - request.hasTitle() ? request.getTitle() : null, - request.hasDescription() ? request.getDescription() : null, - request.hasIcon() ? request.getIcon() : null, - request.hasIconColor() ? request.getIconColor() : null, - request.hasActionType() ? request.getActionType() : null, - actionUrl, - request.hasActionButtonText() ? request.getActionButtonText() : null, - request.hasCategoryId() ? request.getCategoryId() : null, - request.hasBackgroundColor() ? request.getBackgroundColor() : null, - request.hasIsEnabled() ? request.getIsEnabled() : null, - new Date() - ); - } - - public boolean deleteCard(Server server, String id) { - return homepageCardRepository.deleteByCardId(server, id); - } - - public void reorderCards(Server server, List ids) { - homepageCardRepository.reorderCards(server, ids); - } - - public List getAllCardsEnriched(Server server) { - List cards = getAllCards(server); - - List categoryIds = cards.stream() - .map(HomepageCard::getCategoryId) - .filter(id -> id != null && !id.isEmpty()) - .distinct() - .toList(); - - Map categoriesById = categoryService.getCategoriesByIds(server, categoryIds) - .stream() - .collect(Collectors.toMap(KnowledgebaseCategory::getId, Function.identity())); - - return cards.stream() - .map(card -> { - EmbeddedCategory embedded = null; - if (card.getCategoryId() != null && !card.getCategoryId().isEmpty()) { - KnowledgebaseCategory cat = categoriesById.get(card.getCategoryId()); - if (cat != null) { - embedded = new EmbeddedCategory(cat.getId(), cat.getName(), cat.getSlug()); - } - } - return new EnrichedCard(card, embedded); - }) - .toList(); - } - - public List getAllCards(Server server) { - return homepageCardRepository.findAllOrdered(server); - } - - public record EmbeddedCategory(String id, String name, String slug) {} - - public record EnrichedCard( - HomepageCard card, - EmbeddedCategory category - ) {} - - public record EnrichedCardWithArticles( - HomepageCard card, - KnowledgebaseCategory category, - List articles - ) {} -} +package gg.modl.backend.homepage.service; + +import gg.modl.backend.database.mongo.repository.HomepageCardMongoRepository; +import gg.modl.backend.homepage.data.HomepageCard; +import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; +import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; +import gg.modl.backend.knowledgebase.service.KnowledgebaseArticleService; +import gg.modl.backend.knowledgebase.service.KnowledgebaseCategoryService; +import gg.modl.backend.infrastructure.validation.SafeUrls; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.CreateCardRequest; +import gg.modl.proto.modl.v1.UpdateCardRequest; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class HomepageCardService { + private static final String CATEGORY_DROPDOWN_ACTION_TYPE = "category_dropdown"; + + private final HomepageCardMongoRepository homepageCardRepository; + private final KnowledgebaseCategoryService categoryService; + private final KnowledgebaseArticleService articleService; + + public List getVisibleCards(Server server) { + return homepageCardRepository.findVisibleOrdered(server); + } + + public List getVisibleCardsEnrichedWithArticles(Server server) { + List cards = getVisibleCards(server); + + List categoryIds = distinctCategoryIds(cards, + card -> CATEGORY_DROPDOWN_ACTION_TYPE.equals(card.getActionType())); + + Map categoriesById = categoriesByIdMap(server, categoryIds); + + Map> articlesByCategoryId = + articleService.getVisibleArticlesGroupedByCategoryIds(server, categoryIds); + + return cards.stream() + .map(card -> { + KnowledgebaseCategory category = null; + if (CATEGORY_DROPDOWN_ACTION_TYPE.equals(card.getActionType()) + && card.getCategoryId() != null && !card.getCategoryId().isEmpty()) { + category = categoriesById.get(card.getCategoryId()); + } + List articles = category != null + ? articlesByCategoryId.getOrDefault(category.getId(), List.of()) + : List.of(); + return new EnrichedCardWithArticles(card, category, articles); + }) + .toList(); + } + + public Optional getCardById(Server server, String id) { + return homepageCardRepository.findByCardId(server, id); + } + + public HomepageCard createCard(Server server, CreateCardRequest request) { + String actionUrl = request.hasActionUrl() ? request.getActionUrl() : null; + SafeUrls.requireSafe(actionUrl, "Invalid card URL"); + HomepageCard card = HomepageCard.builder() + .title(request.getTitle()) + .description(request.hasDescription() ? request.getDescription() : null) + .icon(request.hasIcon() ? request.getIcon() : null) + .iconColor(request.hasIconColor() ? request.getIconColor() : null) + .actionType(request.hasActionType() ? request.getActionType() : null) + .actionUrl(actionUrl) + .actionButtonText(request.hasActionButtonText() ? request.getActionButtonText() : null) + .categoryId(request.hasCategoryId() ? request.getCategoryId() : null) + .backgroundColor(request.hasBackgroundColor() ? request.getBackgroundColor() : null) + .ordinal(homepageCardRepository.findMaxOrdinal(server) + 1) + .isEnabled(!request.hasIsEnabled() || request.getIsEnabled()) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(); + + return homepageCardRepository.saveEntity(server, card); + } + + public Optional updateCard(Server server, String id, UpdateCardRequest request) { + String actionUrl = request.hasActionUrl() ? request.getActionUrl() : null; + SafeUrls.requireSafe(actionUrl, "Invalid card URL"); + return homepageCardRepository.updateCard( + server, + id, + request.hasTitle() ? request.getTitle() : null, + request.hasDescription() ? request.getDescription() : null, + request.hasIcon() ? request.getIcon() : null, + request.hasIconColor() ? request.getIconColor() : null, + request.hasActionType() ? request.getActionType() : null, + actionUrl, + request.hasActionButtonText() ? request.getActionButtonText() : null, + request.hasCategoryId() ? request.getCategoryId() : null, + request.hasBackgroundColor() ? request.getBackgroundColor() : null, + request.hasIsEnabled() ? request.getIsEnabled() : null, + new Date() + ); + } + + public boolean deleteCard(Server server, String id) { + return homepageCardRepository.deleteByCardId(server, id); + } + + public void reorderCards(Server server, List ids) { + homepageCardRepository.reorderCards(server, ids); + } + + public List getAllCardsEnriched(Server server) { + List cards = getAllCards(server); + + List categoryIds = distinctCategoryIds(cards, card -> true); + + Map categoriesById = categoriesByIdMap(server, categoryIds); + + return cards.stream() + .map(card -> { + EmbeddedCategory embedded = null; + if (card.getCategoryId() != null && !card.getCategoryId().isEmpty()) { + KnowledgebaseCategory cat = categoriesById.get(card.getCategoryId()); + if (cat != null) { + embedded = new EmbeddedCategory(cat.getId(), cat.getName(), cat.getSlug()); + } + } + return new EnrichedCard(card, embedded); + }) + .toList(); + } + + public List getAllCards(Server server) { + return homepageCardRepository.findAllOrdered(server); + } + + private List distinctCategoryIds(List cards, Predicate filter) { + return cards.stream() + .filter(filter) + .map(HomepageCard::getCategoryId) + .filter(id -> id != null && !id.isEmpty()) + .distinct() + .toList(); + } + + private Map categoriesByIdMap(Server server, List categoryIds) { + return categoryService.getCategoriesByIds(server, categoryIds) + .stream() + .collect(Collectors.toMap(KnowledgebaseCategory::getId, Function.identity())); + } + + public record EmbeddedCategory(String id, String name, String slug) {} + + public record EnrichedCard( + HomepageCard card, + EmbeddedCategory category + ) {} + + public record EnrichedCardWithArticles( + HomepageCard card, + KnowledgebaseCategory category, + List articles + ) {} +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/AppealReplyPolicy.java b/src/main/java/gg/modl/backend/infrastructure/authorization/AppealReplyPolicy.java new file mode 100644 index 0000000..bbdb0e6 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/AppealReplyPolicy.java @@ -0,0 +1,21 @@ +package gg.modl.backend.infrastructure.authorization; + +import gg.modl.backend.role.service.PermissionService; + +public enum AppealReplyPolicy implements PanelAccessPolicy { + INSTANCE; + + @Override + public boolean permitsWithoutRole(PanelAccessRequest request) { + return false; + } + + @Override + public boolean permitsWithRole(PanelAccessRequest request, PanelPrincipalPermissions permissions) { + if (request.isReadOnly()) { + return permissions.has(PermissionService.TICKET_VIEW_ALL); + } + return permissions.has(PermissionService.APPEAL_MODIFY) + || permissions.has(PermissionService.TICKET_REPLY_ALL); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicy.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicy.java new file mode 100644 index 0000000..3f10554 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicy.java @@ -0,0 +1,7 @@ +package gg.modl.backend.infrastructure.authorization; + +public interface PanelAccessPolicy { + boolean permitsWithoutRole(PanelAccessRequest request); + + boolean permitsWithRole(PanelAccessRequest request, PanelPrincipalPermissions permissions); +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicyResolver.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicyResolver.java new file mode 100644 index 0000000..e4a1880 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicyResolver.java @@ -0,0 +1,98 @@ +package gg.modl.backend.infrastructure.authorization; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerExecutionChain; +import org.springframework.web.servlet.mvc.condition.PathPatternsRequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.ServletRequestPathUtils; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PanelAccessPolicyResolver { + private final ObjectProvider handlerMappingProvider; + + public List resolvePolicies(HttpServletRequest request) { + RequestMappingHandlerMapping handlerMapping = handlerMappingProvider.getObject(); + boolean parsedHere = ensureParsedRequestPath(handlerMapping, request); + try { + HandlerExecutionChain chain = handlerMapping.getHandler(request); + if (chain != null && chain.getHandler() instanceof HandlerMethod handlerMethod) { + return resolve(handlerMethod).map(List::of).orElseGet(List::of); + } + return List.of(); + } catch (HttpRequestMethodNotSupportedException methodNotSupported) { + return siblingPolicies(handlerMapping, request); + } catch (Exception exception) { + log.debug("Panel handler resolution failed for {} {}", request.getMethod(), request.getRequestURI(), exception); + return List.of(); + } finally { + if (parsedHere) { + ServletRequestPathUtils.clearParsedRequestPath(request); + } + } + } + + public Optional resolve(HandlerMethod handlerMethod) { + return Optional.ofNullable(findAnnotation(handlerMethod)).map(PanelAccessPolicyResolver::toPolicy); + } + + private List siblingPolicies(RequestMappingHandlerMapping handlerMapping, HttpServletRequest request) { + List policies = new ArrayList<>(); + handlerMapping.getHandlerMethods().forEach((mappingInfo, handlerMethod) -> { + if (matchesPath(mappingInfo, request)) { + resolve(handlerMethod).ifPresent(policies::add); + } + }); + return policies; + } + + private static boolean matchesPath(RequestMappingInfo mappingInfo, HttpServletRequest request) { + PathPatternsRequestCondition pathPatterns = mappingInfo.getPathPatternsCondition(); + return pathPatterns != null && pathPatterns.getMatchingCondition(request) != null; + } + + private boolean ensureParsedRequestPath(RequestMappingHandlerMapping handlerMapping, HttpServletRequest request) { + if (handlerMapping.getPatternParser() == null || ServletRequestPathUtils.hasParsedRequestPath(request)) { + return false; + } + ServletRequestPathUtils.parseAndCache(request); + return true; + } + + private static RequiresPanelPermission findAnnotation(HandlerMethod handlerMethod) { + RequiresPanelPermission methodAnnotation = handlerMethod.getMethodAnnotation(RequiresPanelPermission.class); + if (methodAnnotation != null) { + return methodAnnotation; + } + return AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getBeanType(), RequiresPanelPermission.class); + } + + private static PanelAccessPolicy toPolicy(RequiresPanelPermission annotation) { + return switch (annotation.rule()) { + case PERMIT_ALL -> PermitAllPolicy.INSTANCE; + case PLAYER_ACCESS -> PlayerAccessPolicy.INSTANCE; + case APPEAL_REPLY -> AppealReplyPolicy.INSTANCE; + case REQUIRE_PERMISSION -> new ReadWritePermissionPolicy(viewPermission(annotation), modifyPermission(annotation)); + }; + } + + private static String viewPermission(RequiresPanelPermission annotation) { + return annotation.view().isEmpty() ? annotation.value() : annotation.view(); + } + + private static String modifyPermission(RequiresPanelPermission annotation) { + return annotation.modify().isEmpty() ? annotation.value() : annotation.modify(); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRequest.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRequest.java new file mode 100644 index 0000000..74b12ee --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRequest.java @@ -0,0 +1,7 @@ +package gg.modl.backend.infrastructure.authorization; + +public record PanelAccessRequest(String method, String path) { + public boolean isReadOnly() { + return "GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRule.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRule.java new file mode 100644 index 0000000..0c62fee --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRule.java @@ -0,0 +1,8 @@ +package gg.modl.backend.infrastructure.authorization; + +public enum PanelAccessRule { + REQUIRE_PERMISSION, + PERMIT_ALL, + PLAYER_ACCESS, + APPEAL_REPLY +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAuthorizationBootstrapValidator.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAuthorizationBootstrapValidator.java new file mode 100644 index 0000000..499cd56 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAuthorizationBootstrapValidator.java @@ -0,0 +1,52 @@ +package gg.modl.backend.infrastructure.authorization; + +import gg.modl.backend.infrastructure.rest.RouteGroups; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.condition.PathPatternsRequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +@Component +@RequiredArgsConstructor +public class PanelAuthorizationBootstrapValidator implements SmartInitializingSingleton { + private final ObjectProvider handlerMappingProvider; + private final PanelAccessPolicyResolver policyResolver; + + @Override + public void afterSingletonsInstantiated() { + List unguarded = new ArrayList<>(); + handlerMappingProvider.getObject().getHandlerMethods().forEach((mappingInfo, handlerMethod) -> { + if (requiresPanelAuthorization(mappingInfo) && policyResolver.resolve(handlerMethod).isEmpty()) { + unguarded.add(describe(mappingInfo, handlerMethod)); + } + }); + if (!unguarded.isEmpty()) { + throw new IllegalStateException( + "Panel endpoints without a @RequiresPanelPermission policy (fail-closed default would deny them): " + unguarded); + } + } + + private boolean requiresPanelAuthorization(RequestMappingInfo mappingInfo) { + return patternsOf(mappingInfo).stream().anyMatch(this::isGuardedPanelPattern); + } + + private boolean isGuardedPanelPattern(String pattern) { + return RouteGroups.isPanelPrefix(pattern) && !RouteGroups.isPanelAuthArea(pattern); + } + + private Set patternsOf(RequestMappingInfo mappingInfo) { + PathPatternsRequestCondition pathPatterns = mappingInfo.getPathPatternsCondition(); + return pathPatterns != null ? pathPatterns.getPatternValues() : Set.of(); + } + + private String describe(RequestMappingInfo mappingInfo, HandlerMethod handlerMethod) { + return patternsOf(mappingInfo) + " -> " + handlerMethod.getShortLogMessage(); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PanelPrincipalPermissions.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelPrincipalPermissions.java new file mode 100644 index 0000000..75e7bba --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelPrincipalPermissions.java @@ -0,0 +1,15 @@ +package gg.modl.backend.infrastructure.authorization; + +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import org.jetbrains.annotations.Nullable; + +public record PanelPrincipalPermissions(Server server, @Nullable String roleId, PermissionService permissionService) { + public boolean has(String permission) { + return permissionService.hasPermission(server, roleId, permission); + } + + public boolean hasAnyWithPrefix(String prefix) { + return permissionService.hasAnyPermissionWithPrefix(server, roleId, prefix); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PermitAllPolicy.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PermitAllPolicy.java new file mode 100644 index 0000000..953e42e --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PermitAllPolicy.java @@ -0,0 +1,15 @@ +package gg.modl.backend.infrastructure.authorization; + +public enum PermitAllPolicy implements PanelAccessPolicy { + INSTANCE; + + @Override + public boolean permitsWithoutRole(PanelAccessRequest request) { + return true; + } + + @Override + public boolean permitsWithRole(PanelAccessRequest request, PanelPrincipalPermissions permissions) { + return true; + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PlayerAccessPolicy.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PlayerAccessPolicy.java new file mode 100644 index 0000000..3496679 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PlayerAccessPolicy.java @@ -0,0 +1,24 @@ +package gg.modl.backend.infrastructure.authorization; + +import gg.modl.backend.role.service.PermissionService; + +public enum PlayerAccessPolicy implements PanelAccessPolicy { + INSTANCE; + + private static final String PUNISHMENT_APPLY_PREFIX = "punishment.apply."; + + @Override + public boolean permitsWithoutRole(PanelAccessRequest request) { + return false; + } + + @Override + public boolean permitsWithRole(PanelAccessRequest request, PanelPrincipalPermissions permissions) { + if (request.isReadOnly()) { + return permissions.has(PermissionService.PUNISHMENT_VIEW) + || permissions.has(PermissionService.PUNISHMENT_MODIFY) + || permissions.hasAnyWithPrefix(PUNISHMENT_APPLY_PREFIX); + } + return permissions.has(PermissionService.PUNISHMENT_MODIFY); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/ReadWritePermissionPolicy.java b/src/main/java/gg/modl/backend/infrastructure/authorization/ReadWritePermissionPolicy.java new file mode 100644 index 0000000..3b9716e --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/ReadWritePermissionPolicy.java @@ -0,0 +1,13 @@ +package gg.modl.backend.infrastructure.authorization; + +public record ReadWritePermissionPolicy(String viewPermission, String modifyPermission) implements PanelAccessPolicy { + @Override + public boolean permitsWithoutRole(PanelAccessRequest request) { + return false; + } + + @Override + public boolean permitsWithRole(PanelAccessRequest request, PanelPrincipalPermissions permissions) { + return permissions.has(request.isReadOnly() ? viewPermission : modifyPermission); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/RequiresPanelPermission.java b/src/main/java/gg/modl/backend/infrastructure/authorization/RequiresPanelPermission.java new file mode 100644 index 0000000..377394a --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/RequiresPanelPermission.java @@ -0,0 +1,18 @@ +package gg.modl.backend.infrastructure.authorization; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface RequiresPanelPermission { + String value() default ""; + + String view() default ""; + + String modify() default ""; + + PanelAccessRule rule() default PanelAccessRule.REQUIRE_PERMISSION; +} diff --git a/src/main/java/gg/modl/backend/infrastructure/config/AsyncConfig.java b/src/main/java/gg/modl/backend/infrastructure/config/AsyncConfig.java index 48732a1..51854cc 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/AsyncConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/AsyncConfig.java @@ -1,43 +1,43 @@ -package gg.modl.backend.infrastructure.config; - -import java.util.concurrent.Executors; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.task.AsyncTaskExecutor; -import org.springframework.core.task.support.TaskExecutorAdapter; -import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; - -@Configuration -@EnableAsync -@EnableScheduling -@Slf4j -public class AsyncConfig { - - @Bean - public AsyncTaskExecutor applicationTaskExecutor() { - return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); - } - - @Bean(autowireCandidate = false) - public ThreadPoolTaskExecutor emailTaskExecutor( - @Value("${modl.email.executor.core-pool-size:2}") int corePoolSize, - @Value("${modl.email.executor.max-pool-size:8}") int maxPoolSize, - @Value("${modl.email.executor.queue-capacity:500}") int queueCapacity, - @Value("${modl.email.executor.await-termination-seconds:20}") int awaitTerminationSeconds - ) { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(corePoolSize); - executor.setMaxPoolSize(maxPoolSize); - executor.setQueueCapacity(queueCapacity); - executor.setThreadNamePrefix("email-"); - executor.setRejectedExecutionHandler((task, rejectingExecutor) -> - log.warn("Email executor saturated; dropping queued email send")); - executor.setWaitForTasksToCompleteOnShutdown(true); - executor.setAwaitTerminationSeconds(awaitTerminationSeconds); - return executor; - } -} +package gg.modl.backend.infrastructure.config; + +import java.util.concurrent.Executors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.core.task.support.TaskExecutorAdapter; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +@EnableScheduling +@Slf4j +public class AsyncConfig { + + @Bean + public AsyncTaskExecutor applicationTaskExecutor() { + return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); + } + + @Bean(autowireCandidate = false) + public ThreadPoolTaskExecutor emailTaskExecutor( + @Value("${modl.email.executor.core-pool-size:2}") int corePoolSize, + @Value("${modl.email.executor.max-pool-size:8}") int maxPoolSize, + @Value("${modl.email.executor.queue-capacity:500}") int queueCapacity, + @Value("${modl.email.executor.await-termination-seconds:20}") int awaitTerminationSeconds + ) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setThreadNamePrefix("email-"); + executor.setRejectedExecutionHandler((task, rejectingExecutor) -> + log.warn("Email executor saturated; dropping queued email send")); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(awaitTerminationSeconds); + return executor; + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/config/AuthCodeHashSecretGuard.java b/src/main/java/gg/modl/backend/infrastructure/config/AuthCodeHashSecretGuard.java index 263498c..c7618fd 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/AuthCodeHashSecretGuard.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/AuthCodeHashSecretGuard.java @@ -3,12 +3,12 @@ import gg.modl.backend.auth.AuthConfiguration; import gg.modl.backend.ticket.config.TicketEmailVerificationConfiguration; import jakarta.annotation.PostConstruct; -import java.util.Arrays; import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.env.Environment; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; @Component @RequiredArgsConstructor @@ -21,10 +21,9 @@ public class AuthCodeHashSecretGuard { @PostConstruct public void validate() { - boolean isProductionProfile = Arrays.stream(environment.getActiveProfiles()) - .anyMatch(profile -> PRODUCTION_PROFILES.contains(profile.toLowerCase())); - boolean authSecretMissing = isBlank(authConfiguration.getCodeHashSecret()); - boolean ticketSecretMissing = isBlank(ticketEmailVerificationConfiguration.getCodeHashSecret()); + boolean isProductionProfile = ProfileEnvironment.hasAnyActiveProfile(environment, PRODUCTION_PROFILES); + boolean authSecretMissing = !StringUtils.hasText(authConfiguration.getCodeHashSecret()); + boolean ticketSecretMissing = !StringUtils.hasText(ticketEmailVerificationConfiguration.getCodeHashSecret()); if (isProductionProfile && (authSecretMissing || ticketSecretMissing)) { throw new IllegalStateException( @@ -44,8 +43,4 @@ public void validate() { log.warn("======================================================================"); } } - - private static boolean isBlank(String value) { - return value == null || value.isBlank(); - } } diff --git a/src/main/java/gg/modl/backend/infrastructure/config/DevelopmentModeGuard.java b/src/main/java/gg/modl/backend/infrastructure/config/DevelopmentModeGuard.java index 470af10..8ad2cb6 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/DevelopmentModeGuard.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/DevelopmentModeGuard.java @@ -1,45 +1,43 @@ -package gg.modl.backend.infrastructure.config; - -import jakarta.annotation.PostConstruct; -import java.util.Arrays; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.env.Environment; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@Slf4j -public class DevelopmentModeGuard { - private final Environment environment; - private final ModlProperties modlProperties; - private static final Set DEV_PROFILES = Set.of("dev", "local", "test"); - - @PostConstruct - public void validate() { - if (!modlProperties.isDevelopmentMode()) { - return; - } - - boolean isDevProfile = Arrays.stream(environment.getActiveProfiles()) - .anyMatch(profile -> DEV_PROFILES.contains(profile.toLowerCase())); - boolean explicitlyAllowed = environment.getProperty("MODL_ALLOW_DEV_MODE", Boolean.class, false); - - if (!isDevProfile && !explicitlyAllowed) { - throw new IllegalStateException( - "FATAL: modl.development-mode=true is set but no development context was detected. " + - "Development mode disables CSRF protection, captcha validation, and weakens cookie security, " + - "and must never run in production. To run development mode, activate a dev/local/test Spring " + - "profile (e.g. SPRING_PROFILES_ACTIVE=dev) or set MODL_ALLOW_DEV_MODE=true. For production, " + - "remove modl.development-mode or set it to false." - ); - } - - log.warn("======================================================================"); - log.warn(" DEVELOPMENT MODE IS ENABLED"); - log.warn(" CSRF protection, captcha validation, and cookie security are relaxed."); - log.warn(" Do NOT use this setting in production."); - log.warn("======================================================================"); - } -} +package gg.modl.backend.infrastructure.config; + +import jakarta.annotation.PostConstruct; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class DevelopmentModeGuard { + private final Environment environment; + private final ModlProperties modlProperties; + private static final Set DEV_PROFILES = Set.of("dev", "local", "test"); + + @PostConstruct + public void validate() { + if (!modlProperties.isDevelopmentMode()) { + return; + } + + boolean isDevProfile = ProfileEnvironment.hasAnyActiveProfile(environment, DEV_PROFILES); + boolean explicitlyAllowed = environment.getProperty("MODL_ALLOW_DEV_MODE", Boolean.class, false); + + if (!isDevProfile && !explicitlyAllowed) { + throw new IllegalStateException( + "FATAL: modl.development-mode=true is set but no development context was detected. " + + "Development mode disables CSRF protection, captcha validation, and weakens cookie security, " + + "and must never run in production. To run development mode, activate a dev/local/test Spring " + + "profile (e.g. SPRING_PROFILES_ACTIVE=dev) or set MODL_ALLOW_DEV_MODE=true. For production, " + + "remove modl.development-mode or set it to false." + ); + } + + log.warn("======================================================================"); + log.warn(" DEVELOPMENT MODE IS ENABLED"); + log.warn(" CSRF protection, captcha validation, and cookie security are relaxed."); + log.warn(" Do NOT use this setting in production."); + log.warn("======================================================================"); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/config/ModlCorsProperties.java b/src/main/java/gg/modl/backend/infrastructure/config/ModlCorsProperties.java index d332d2c..41e6e05 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/ModlCorsProperties.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/ModlCorsProperties.java @@ -1,19 +1,19 @@ -package gg.modl.backend.infrastructure.config; - -import jakarta.validation.constraints.NotNull; -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; - -@Getter -@Setter -@Validated -@ConfigurationProperties(prefix = "modl.cors") -public class ModlCorsProperties { - @NotNull - private String systemOrigins = "https://modl.gg,https://admin.modl.gg,https://modl.top,https://admin.modl.top"; - +package gg.modl.backend.infrastructure.config; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "modl.cors") +public class ModlCorsProperties { + @NotNull + private String systemOrigins = "https://modl.gg,https://admin.modl.gg,https://modl.top,https://admin.modl.top"; + @NotNull private String appDomains = "modl.gg,modl.top"; diff --git a/src/main/java/gg/modl/backend/infrastructure/config/ModlDevProperties.java b/src/main/java/gg/modl/backend/infrastructure/config/ModlDevProperties.java index 39f790a..df236c0 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/ModlDevProperties.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/ModlDevProperties.java @@ -1,17 +1,17 @@ -package gg.modl.backend.infrastructure.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; - -@Getter -@Setter -@Validated -@ConfigurationProperties(prefix = "modl.dev") -public class ModlDevProperties { - private String serverDomain = ""; - private String serverName = "Local Test"; - private String seedAdminEmail = "admin@localtest.dev"; - private String seedApiKey = ""; -} +package gg.modl.backend.infrastructure.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "modl.dev") +public class ModlDevProperties { + private String serverDomain = ""; + private String serverName = "Local Test"; + private String seedAdminEmail = "admin@localtest.dev"; + private String seedApiKey = ""; +} diff --git a/src/main/java/gg/modl/backend/infrastructure/config/ModlProperties.java b/src/main/java/gg/modl/backend/infrastructure/config/ModlProperties.java index 42a92a3..872b51a 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/ModlProperties.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/ModlProperties.java @@ -1,18 +1,18 @@ -package gg.modl.backend.infrastructure.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; - -@Getter -@Setter -@Validated -@ConfigurationProperties(prefix = "modl") -public class ModlProperties { - private boolean developmentMode; - - private String domain = "modl.gg"; - - private String appDomain; -} +package gg.modl.backend.infrastructure.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "modl") +public class ModlProperties { + private boolean developmentMode; + + private String domain = "modl.gg"; + + private String appDomain; +} diff --git a/src/main/java/gg/modl/backend/infrastructure/config/ProfileEnvironment.java b/src/main/java/gg/modl/backend/infrastructure/config/ProfileEnvironment.java new file mode 100644 index 0000000..d99e6cd --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/config/ProfileEnvironment.java @@ -0,0 +1,15 @@ +package gg.modl.backend.infrastructure.config; + +import java.util.Arrays; +import java.util.Set; +import org.springframework.core.env.Environment; + +public final class ProfileEnvironment { + private ProfileEnvironment() { + } + + public static boolean hasAnyActiveProfile(Environment environment, Set profiles) { + return Arrays.stream(environment.getActiveProfiles()) + .anyMatch(profile -> profiles.contains(profile.toLowerCase())); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/cors/DynamicCorsConfigurationSource.java b/src/main/java/gg/modl/backend/infrastructure/cors/DynamicCorsConfigurationSource.java index ff8762a..95758fb 100644 --- a/src/main/java/gg/modl/backend/infrastructure/cors/DynamicCorsConfigurationSource.java +++ b/src/main/java/gg/modl/backend/infrastructure/cors/DynamicCorsConfigurationSource.java @@ -1,154 +1,140 @@ -package gg.modl.backend.infrastructure.cors; - -import gg.modl.backend.infrastructure.config.ModlCorsProperties; -import gg.modl.backend.infrastructure.config.ModlProperties; -import gg.modl.backend.infrastructure.origin.OriginPolicy; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.server.ServerService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.util.HostExtractionUtil; -import jakarta.annotation.PostConstruct; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; - -@Component -@RequiredArgsConstructor -public class DynamicCorsConfigurationSource implements CorsConfigurationSource { - private final ServerService serverService; - private final ModlCorsProperties corsProperties; - private final ModlProperties modlProperties; - private final Map originCache = Collections.synchronizedMap( - new LinkedHashMap<>(64, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_CACHE_SIZE; - } - } - ); - private volatile OriginPolicy originPolicy = new OriginPolicy(Set.of(), Set.of(), false); - private volatile Set parsedReplayLiteOrigins = Set.of(); - private static final int MAX_CACHE_SIZE = 10_000; - private static final long CACHE_TTL_MS = 5 * 60 * 1000; - - @Override - public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { - String origin = request.getHeader("Origin"); - if (origin == null) { - return null; - } - - String path = request.getRequestURI(); - boolean adminPath = isAdminPath(path); - - if (adminPath && !originPolicy.isSystemOrigin(origin)) { - return null; - } - - if (!isOriginAllowed(path, origin)) { - return null; - } - - CorsConfiguration config = new CorsConfiguration(); - config.addAllowedOrigin(origin); - config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - config.setAllowedHeaders(List.of("Content-Type", "X-Server-Domain", "X-API-Key", "Cookie", "Accept", "Origin", "Authorization")); - config.setExposedHeaders(List.of( - "X-RateLimit-Remaining", - "X-RateLimit-Retry-After", - "X-Rate-Limit-Remaining", - "X-Rate-Limit-Retry-After-Seconds" - )); - config.setAllowCredentials(true); - config.setMaxAge(3600L); - return config; - } - - private boolean isAdminPath(String path) { - return path != null && ( - path.startsWith(RESTMappingV1.PREFIX_ADMIN + "/") - || path.equals(RESTMappingV1.PREFIX_ADMIN) - ); - } - - private boolean isReplayLitePath(String path) { - return path != null && ( - path.startsWith(RESTMappingV1.PREFIX_REPLAY_LITE + "/") - || path.startsWith(RESTMappingV1.PREFIX_PUBLIC + "/replay-lite/") - ); - } - - private boolean isOriginAllowed(String path, String origin) { - String cacheKey = (isReplayLitePath(path) ? "replay-lite" : "default") + ":" + origin; - CachedOrigin cached = originCache.get(cacheKey); - if (cached != null && !cached.isExpired()) { - return cached.allowed; - } - - boolean allowed = checkOriginAllowed(path, origin); - originCache.put(cacheKey, new CachedOrigin(allowed, System.currentTimeMillis() + CACHE_TTL_MS)); - return allowed; - } - - private boolean checkOriginAllowed(String path, String origin) { - if (isReplayLitePath(path)) { - return parsedReplayLiteOrigins.contains(origin); - } - - if (originPolicy.isSystemOrigin(origin)) { - return true; - } - - String host = HostExtractionUtil.extractHost(origin); - if (host == null) { - return false; - } - - if (originPolicy.isAppDomainOrSubdomain(host)) { - return true; - } - - Server server = serverService.getServerFromDomain(host); - return server != null; - } - - @PostConstruct - void initParsedOrigins() { - originPolicy = new OriginPolicy( - HostExtractionUtil.parseCommaSeparated(corsProperties.getSystemOrigins()), - HostExtractionUtil.parseCommaSeparated(corsProperties.getAppDomains()), - modlProperties.isDevelopmentMode() - ); - parsedReplayLiteOrigins = HostExtractionUtil.parseCommaSeparated(corsProperties.getReplayLiteOrigins()); - } - - public void invalidateCache(String domain) { - originCache.entrySet().removeIf(entry -> { - String host = HostExtractionUtil.extractHost(originFromCacheKey(entry.getKey())); - return domain.equals(host); - }); - } - - public void invalidateCacheForOrigin(String origin) { - originCache.remove("default:" + origin); - originCache.remove("replay-lite:" + origin); - } - - private String originFromCacheKey(String key) { - int separator = key.indexOf(':'); - return separator >= 0 ? key.substring(separator + 1) : key; - } - - private record CachedOrigin(boolean allowed, long expiresAt) { - boolean isExpired() { - return System.currentTimeMillis() > expiresAt; - } - } -} +package gg.modl.backend.infrastructure.cors; + +import gg.modl.backend.infrastructure.config.ModlCorsProperties; +import gg.modl.backend.infrastructure.origin.OriginPolicy; +import gg.modl.backend.infrastructure.origin.OriginPolicyFactory; +import gg.modl.backend.infrastructure.rest.RouteGroups; +import gg.modl.backend.server.ServerService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.util.HostExtractionUtil; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +@Component +@RequiredArgsConstructor +public class DynamicCorsConfigurationSource implements CorsConfigurationSource { + private final ServerService serverService; + private final ModlCorsProperties corsProperties; + private final OriginPolicyFactory originPolicyFactory; + private final Map originCache = Collections.synchronizedMap( + new LinkedHashMap<>(64, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_SIZE; + } + } + ); + private volatile OriginPolicy originPolicy = new OriginPolicy(Set.of(), Set.of(), false); + private volatile Set parsedReplayLiteOrigins = Set.of(); + private static final int MAX_CACHE_SIZE = 10_000; + private static final long CACHE_TTL_MS = 5 * 60 * 1000; + + @Override + public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { + String origin = request.getHeader("Origin"); + if (origin == null) { + return null; + } + + String path = request.getRequestURI(); + boolean adminPath = RouteGroups.isAdminArea(path); + + if (adminPath && !originPolicy.isSystemOrigin(origin)) { + return null; + } + + if (!isOriginAllowed(path, origin)) { + return null; + } + + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedOrigin(origin); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("Content-Type", "X-Server-Domain", "X-API-Key", "Cookie", "Accept", "Origin", "Authorization")); + config.setExposedHeaders(List.of( + "X-RateLimit-Remaining", + "X-RateLimit-Retry-After", + "X-Rate-Limit-Remaining", + "X-Rate-Limit-Retry-After-Seconds" + )); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + return config; + } + + private boolean isReplayLitePath(String path) { + return RouteGroups.isReplayLiteChild(path) || RouteGroups.isPublicReplayLiteChild(path); + } + + private boolean isOriginAllowed(String path, String origin) { + String cacheKey = (isReplayLitePath(path) ? "replay-lite" : "default") + ":" + origin; + CachedOrigin cached = originCache.get(cacheKey); + if (cached != null && !cached.isExpired()) { + return cached.allowed; + } + + boolean allowed = checkOriginAllowed(path, origin); + originCache.put(cacheKey, new CachedOrigin(allowed, System.currentTimeMillis() + CACHE_TTL_MS)); + return allowed; + } + + private boolean checkOriginAllowed(String path, String origin) { + if (isReplayLitePath(path)) { + return parsedReplayLiteOrigins.contains(origin); + } + + if (originPolicy.isSystemOrigin(origin)) { + return true; + } + + String host = HostExtractionUtil.extractHost(origin); + if (host == null) { + return false; + } + + if (originPolicy.isAppDomainOrSubdomain(host)) { + return true; + } + + Server server = serverService.getServerFromDomain(host); + return server != null; + } + + @PostConstruct + void initParsedOrigins() { + originPolicy = originPolicyFactory.withAppDomains(); + parsedReplayLiteOrigins = HostExtractionUtil.parseCommaSeparated(corsProperties.getReplayLiteOrigins()); + } + + public void invalidateCache(String domain) { + originCache.entrySet().removeIf(entry -> { + String host = HostExtractionUtil.extractHost(originFromCacheKey(entry.getKey())); + return domain.equals(host); + }); + } + + public void invalidateCacheForOrigin(String origin) { + originCache.remove("default:" + origin); + originCache.remove("replay-lite:" + origin); + } + + private String originFromCacheKey(String key) { + int separator = key.indexOf(':'); + return separator >= 0 ? key.substring(separator + 1) : key; + } + + private record CachedOrigin(boolean allowed, long expiresAt) { + boolean isExpired() { + return System.currentTimeMillis() > expiresAt; + } + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/BaseApplicationException.java b/src/main/java/gg/modl/backend/infrastructure/exception/BaseApplicationException.java index ad51039..4a32d23 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/BaseApplicationException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/BaseApplicationException.java @@ -1,19 +1,19 @@ -package gg.modl.backend.infrastructure.exception; - -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public abstract class BaseApplicationException extends RuntimeException { - private final HttpStatus status; - - protected BaseApplicationException(String message, HttpStatus status) { - super(message); - this.status = status; - } - - protected BaseApplicationException(String message, HttpStatus status, Throwable cause) { - super(message, cause); - this.status = status; - } -} +package gg.modl.backend.infrastructure.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public abstract class BaseApplicationException extends RuntimeException { + private final HttpStatus status; + + protected BaseApplicationException(String message, HttpStatus status) { + super(message); + this.status = status; + } + + protected BaseApplicationException(String message, HttpStatus status, Throwable cause) { + super(message, cause); + this.status = status; + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ConflictException.java b/src/main/java/gg/modl/backend/infrastructure/exception/ConflictException.java index 9b6eae5..7ea12df 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/ConflictException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ConflictException.java @@ -1,13 +1,13 @@ -package gg.modl.backend.infrastructure.exception; - -import org.springframework.http.HttpStatus; - -public class ConflictException extends BaseApplicationException { - public ConflictException(String message) { - super(message, HttpStatus.CONFLICT); - } - - public ConflictException(String message, Throwable cause) { - super(message, HttpStatus.CONFLICT, cause); - } -} +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class ConflictException extends BaseApplicationException { + public ConflictException(String message) { + super(message, HttpStatus.CONFLICT); + } + + public ConflictException(String message, Throwable cause) { + super(message, HttpStatus.CONFLICT, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/CustomErrorController.java b/src/main/java/gg/modl/backend/infrastructure/exception/CustomErrorController.java index f028825..527f53c 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/CustomErrorController.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/CustomErrorController.java @@ -5,9 +5,7 @@ import gg.modl.proto.modl.v1.ApiError; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.http.HttpServletRequest; -import lombok.Getter; import lombok.RequiredArgsConstructor; -import lombok.Setter; import org.springframework.boot.webmvc.error.ErrorController; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -59,25 +57,6 @@ public ResponseEntity handleError(HttpServletRequest request) { .build()); } - ErrorResponse errorResponse = new ErrorResponse( - statusCode, - error, - errorMessage); - - return ResponseEntity.status(statusCode).body(errorResponse); - } - - @Setter - @Getter - public static class ErrorResponse { - private int status; - private String error; - private String message; - - public ErrorResponse(int status, String error, String message) { - this.status = status; - this.error = error; - this.message = message; - } + return ResponseEntity.status(statusCode).body(new ErrorResponseDTO(statusCode, error, errorMessage)); } } diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ErrorResponseDTO.java b/src/main/java/gg/modl/backend/infrastructure/exception/ErrorResponseDTO.java index 5d9f892..11310ec 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/ErrorResponseDTO.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ErrorResponseDTO.java @@ -1,7 +1,7 @@ -package gg.modl.backend.infrastructure.exception; - -public record ErrorResponseDTO(int status, String error, String message) { - public ErrorResponseDTO(int status, String error) { - this(status, error, error); - } -} +package gg.modl.backend.infrastructure.exception; + +public record ErrorResponseDTO(int status, String error, String message) { + public ErrorResponseDTO(int status, String error) { + this(status, error, error); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ExternalServiceException.java b/src/main/java/gg/modl/backend/infrastructure/exception/ExternalServiceException.java index 21c7390..00fb824 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/ExternalServiceException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ExternalServiceException.java @@ -1,13 +1,13 @@ -package gg.modl.backend.infrastructure.exception; - -import org.springframework.http.HttpStatus; - -public class ExternalServiceException extends BaseApplicationException { - public ExternalServiceException(String message) { - super(message, HttpStatus.BAD_GATEWAY); - } - - public ExternalServiceException(String message, Throwable cause) { - super(message, HttpStatus.BAD_GATEWAY, cause); - } -} +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class ExternalServiceException extends BaseApplicationException { + public ExternalServiceException(String message) { + super(message, HttpStatus.BAD_GATEWAY); + } + + public ExternalServiceException(String message, Throwable cause) { + super(message, HttpStatus.BAD_GATEWAY, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ForbiddenException.java b/src/main/java/gg/modl/backend/infrastructure/exception/ForbiddenException.java index b9f11a0..67c78bc 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/ForbiddenException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ForbiddenException.java @@ -1,13 +1,13 @@ -package gg.modl.backend.infrastructure.exception; - -import org.springframework.http.HttpStatus; - -public class ForbiddenException extends BaseApplicationException { - public ForbiddenException(String message) { - super(message, HttpStatus.FORBIDDEN); - } - - public ForbiddenException(String message, Throwable cause) { - super(message, HttpStatus.FORBIDDEN, cause); - } -} +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class ForbiddenException extends BaseApplicationException { + public ForbiddenException(String message) { + super(message, HttpStatus.FORBIDDEN); + } + + public ForbiddenException(String message, Throwable cause) { + super(message, HttpStatus.FORBIDDEN, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/GlobalExceptionHandler.java b/src/main/java/gg/modl/backend/infrastructure/exception/GlobalExceptionHandler.java index 2409295..41332dc 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/GlobalExceptionHandler.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -31,18 +32,11 @@ @RestControllerAdvice @Slf4j +@RequiredArgsConstructor public class GlobalExceptionHandler { private static final String INVALID_DATA_MESSAGE = "Invalid data provided."; private final ProtobufErrorResponseWriter protobufErrorResponseWriter; - public GlobalExceptionHandler() { - this(new ProtobufErrorResponseWriter()); - } - - public GlobalExceptionHandler(ProtobufErrorResponseWriter protobufErrorResponseWriter) { - this.protobufErrorResponseWriter = protobufErrorResponseWriter; - } - @ExceptionHandler(SettingsConflictException.class) public ResponseEntity handleSettingsConflict(SettingsConflictException ex, HttpServletRequest request) { if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { @@ -58,31 +52,19 @@ public ResponseEntity handleSettingsConflict(SettingsConflictException ex, Ht @ExceptionHandler(BaseApplicationException.class) public ResponseEntity handleApplicationException(BaseApplicationException ex, HttpServletRequest request) { - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(ex.getStatus(), HttpErrorMapping.machineCode(ex.getStatus()), ex.getMessage()); - } - return ResponseEntity.status(ex.getStatus()) - .body(new ErrorResponseDTO(ex.getStatus().value(), ex.getMessage())); + return respond(request, ex.getStatus(), HttpErrorMapping.machineCode(ex.getStatus()), ex.getMessage()); } @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity handleDuplicateKey(DuplicateKeyException ex, HttpServletRequest request) { log.debug("Duplicate key violation for {} {}", request.getMethod(), request.getRequestURI()); - String message = "The request conflicts with an existing record."; - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.CONFLICT, "CONFLICT", message); - } - return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorResponseDTO(409, message)); + return respond(request, HttpStatus.CONFLICT, "CONFLICT", "The request conflicts with an existing record."); } @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request) { log.warn("Unhandled IllegalArgumentException. Consider replacing with a typed exception at the throw site", ex); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", "Invalid argument"); - } - return ResponseEntity.badRequest() - .body(new ErrorResponseDTO(400, "Invalid argument")); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", "Invalid argument"); } @ExceptionHandler(MissingServletRequestParameterException.class) @@ -90,39 +72,22 @@ public ResponseEntity handleMissingParam( MissingServletRequestParameterException ex, HttpServletRequest request ) { - String message = "Missing required parameter: " + ex.getParameterName(); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", message); - } - return ResponseEntity.badRequest() - .body(new ErrorResponseDTO(400, message)); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", "Missing required parameter: " + ex.getParameterName()); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException ex, HttpServletRequest request) { - String message = "Invalid value for parameter: " + ex.getName(); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", message); - } - return ResponseEntity.badRequest() - .body(new ErrorResponseDTO(400, message)); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", "Invalid value for parameter: " + ex.getName()); } @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity handleConstraintViolation(ConstraintViolationException ex, HttpServletRequest request) { - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", INVALID_DATA_MESSAGE); - } - return ResponseEntity.badRequest().body(new ErrorResponseDTO(400, INVALID_DATA_MESSAGE)); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", INVALID_DATA_MESSAGE); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidationException(MethodArgumentNotValidException ex, HttpServletRequest request) { - String message = fieldErrorMessage(ex); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", message); - } - return ResponseEntity.badRequest().body(new ErrorResponseDTO(400, message)); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", fieldErrorMessage(ex)); } private static String fieldErrorMessage(MethodArgumentNotValidException ex) { @@ -138,36 +103,22 @@ public ResponseEntity handleHandlerMethodValidation( HandlerMethodValidationException ex, HttpServletRequest request ) { - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", INVALID_DATA_MESSAGE); - } - return ResponseEntity.badRequest().body(new ErrorResponseDTO(400, INVALID_DATA_MESSAGE)); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", INVALID_DATA_MESSAGE); } @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleNotReadable(HttpMessageNotReadableException ex, HttpServletRequest request) { - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", INVALID_DATA_MESSAGE); - } - return ResponseEntity.badRequest().body(new ErrorResponseDTO(400, INVALID_DATA_MESSAGE)); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", INVALID_DATA_MESSAGE); } @ExceptionHandler(HttpMediaTypeNotSupportedException.class) public ResponseEntity handleMediaTypeNotSupported(HttpMediaTypeNotSupportedException ex, HttpServletRequest request) { - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "UNSUPPORTED_MEDIA_TYPE", "Unsupported media type"); - } - return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) - .body(new ErrorResponseDTO(415, "Unsupported media type")); + return respond(request, HttpStatus.UNSUPPORTED_MEDIA_TYPE, "UNSUPPORTED_MEDIA_TYPE", "Unsupported media type"); } @ExceptionHandler(HttpMediaTypeNotAcceptableException.class) public ResponseEntity handleMediaTypeNotAcceptable(HttpMediaTypeNotAcceptableException ex, HttpServletRequest request) { - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.NOT_ACCEPTABLE, "NOT_ACCEPTABLE", "Not acceptable"); - } - return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE) - .body(new ErrorResponseDTO(406, "Not acceptable")); + return respond(request, HttpStatus.NOT_ACCEPTABLE, "NOT_ACCEPTABLE", "Not acceptable"); } @ExceptionHandler(HttpRequestMethodNotSupportedException.class) @@ -175,22 +126,12 @@ public ResponseEntity handleMethodNotSupported( HttpRequestMethodNotSupportedException ex, HttpServletRequest request ) { - String message = "HTTP method not supported: " + ex.getMethod(); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", message); - } - return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) - .body(new ErrorResponseDTO(405, message)); + return respond(request, HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "HTTP method not supported: " + ex.getMethod()); } @ExceptionHandler(NoHandlerFoundException.class) public ResponseEntity handleNoHandlerFound(NoHandlerFoundException ex, HttpServletRequest request) { - String message = "No endpoint found for " + ex.getHttpMethod() + " " + ex.getRequestURL(); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.NOT_FOUND, "NOT_FOUND", message); - } - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new ErrorResponseDTO(404, message)); + return respond(request, HttpStatus.NOT_FOUND, "NOT_FOUND", "No endpoint found for " + ex.getHttpMethod() + " " + ex.getRequestURL()); } @ExceptionHandler(NoResourceFoundException.class) @@ -198,12 +139,7 @@ public ResponseEntity handleNoResourceFound(NoResourceFoundException ex, Http if (log.isDebugEnabled()) { log.debug("No resource found for {} {}", request.getMethod(), ex.getResourcePath()); } - String message = "No endpoint found for " + request.getMethod() + " " + request.getRequestURI(); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.NOT_FOUND, "NOT_FOUND", message); - } - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new ErrorResponseDTO(404, message)); + return respond(request, HttpStatus.NOT_FOUND, "NOT_FOUND", "No endpoint found for " + request.getMethod() + " " + request.getRequestURI()); } @ExceptionHandler(ProtoValidationException.class) @@ -238,18 +174,18 @@ public ResponseEntity handleGenericException(Exception ex, HttpServletRequest } String code = HttpErrorMapping.machineCode(status); String message = status.is4xxClientError() ? status.getReasonPhrase() : "An internal error occurred"; - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(status, code, message); - } - return ResponseEntity.status(status).body(new ErrorResponseDTO(status.value(), message)); + return respond(request, status, code, message); } log.error("Unhandled exception", ex); + return respond(request, HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL", "An internal error occurred"); + } + + private ResponseEntity respond(HttpServletRequest request, HttpStatus status, String code, String message) { if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL", "An internal error occurred"); + return protobufError(status, code, message); } - return ResponseEntity.internalServerError() - .body(new ErrorResponseDTO(500, "An internal error occurred")); + return ResponseEntity.status(status).body(new ErrorResponseDTO(status.value(), message)); } private ResponseEntity protobufError(HttpStatus status, String code, String message) { diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ResourceNotFoundException.java b/src/main/java/gg/modl/backend/infrastructure/exception/ResourceNotFoundException.java index 50d4262..cbeb25e 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/ResourceNotFoundException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ResourceNotFoundException.java @@ -1,13 +1,13 @@ -package gg.modl.backend.infrastructure.exception; - -import org.springframework.http.HttpStatus; - -public class ResourceNotFoundException extends BaseApplicationException { - public ResourceNotFoundException(String message) { - super(message, HttpStatus.NOT_FOUND); - } - - public ResourceNotFoundException(String message, Throwable cause) { - super(message, HttpStatus.NOT_FOUND, cause); - } -} +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class ResourceNotFoundException extends BaseApplicationException { + public ResourceNotFoundException(String message) { + super(message, HttpStatus.NOT_FOUND); + } + + public ResourceNotFoundException(String message, Throwable cause) { + super(message, HttpStatus.NOT_FOUND, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ServiceUnavailableException.java b/src/main/java/gg/modl/backend/infrastructure/exception/ServiceUnavailableException.java new file mode 100644 index 0000000..cf79beb --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ServiceUnavailableException.java @@ -0,0 +1,13 @@ +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class ServiceUnavailableException extends BaseApplicationException { + public ServiceUnavailableException(String message) { + super(message, HttpStatus.SERVICE_UNAVAILABLE); + } + + public ServiceUnavailableException(String message, Throwable cause) { + super(message, HttpStatus.SERVICE_UNAVAILABLE, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/UnauthorizedException.java b/src/main/java/gg/modl/backend/infrastructure/exception/UnauthorizedException.java index 20bc036..1a7428c 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/UnauthorizedException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/UnauthorizedException.java @@ -1,13 +1,13 @@ -package gg.modl.backend.infrastructure.exception; - -import org.springframework.http.HttpStatus; - -public class UnauthorizedException extends BaseApplicationException { - public UnauthorizedException(String message) { - super(message, HttpStatus.UNAUTHORIZED); - } - - public UnauthorizedException(String message, Throwable cause) { - super(message, HttpStatus.UNAUTHORIZED, cause); - } -} +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class UnauthorizedException extends BaseApplicationException { + public UnauthorizedException(String message) { + super(message, HttpStatus.UNAUTHORIZED); + } + + public UnauthorizedException(String message, Throwable cause) { + super(message, HttpStatus.UNAUTHORIZED, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ValidationException.java b/src/main/java/gg/modl/backend/infrastructure/exception/ValidationException.java index 65746dc..9033e38 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/ValidationException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ValidationException.java @@ -1,13 +1,13 @@ -package gg.modl.backend.infrastructure.exception; - -import org.springframework.http.HttpStatus; - -public class ValidationException extends BaseApplicationException { - public ValidationException(String message) { - super(message, HttpStatus.BAD_REQUEST); - } - - public ValidationException(String message, Throwable cause) { - super(message, HttpStatus.BAD_REQUEST, cause); - } -} +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class ValidationException extends BaseApplicationException { + public ValidationException(String message) { + super(message, HttpStatus.BAD_REQUEST); + } + + public ValidationException(String message, Throwable cause) { + super(message, HttpStatus.BAD_REQUEST, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/AdminAuthFilter.java b/src/main/java/gg/modl/backend/infrastructure/filter/AdminAuthFilter.java index 76c8058..9a884ad 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/AdminAuthFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/AdminAuthFilter.java @@ -1,59 +1,68 @@ -package gg.modl.backend.infrastructure.filter; - -import gg.modl.backend.admin.service.AdminAuthService; -import gg.modl.backend.infrastructure.rest.RESTSecurityRole; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -@Component -@RequiredArgsConstructor -public class AdminAuthFilter extends OncePerRequestFilter { - private final AdminAuthService adminAuthService; - public static final String ADMIN_SESSION_ATTR = "adminSession"; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - - String path = request.getRequestURI(); - - if (path.startsWith("/v1/admin/") && !path.startsWith("/v1/admin/auth/")) { - Optional sessionOpt = adminAuthService.getAuthenticatedSession(request); - if (sessionOpt.isEmpty()) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.getWriter().write("{\"success\":false,\"message\":\"Unauthorized\"}"); - return; - } - - AdminAuthService.AdminSession session = sessionOpt.get(); - request.setAttribute(ADMIN_SESSION_ATTR, session); - - List authorities = List.of( - new SimpleGrantedAuthority(RESTSecurityRole.ADMIN) - ); - - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken( - session.email(), - null, - authorities - ); - authentication.setDetails(session); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - - filterChain.doFilter(request, response); - } -} +package gg.modl.backend.infrastructure.filter; + +import gg.modl.backend.admin.service.AdminAuthService; +import gg.modl.backend.infrastructure.rest.RESTSecurityRole; +import gg.modl.backend.infrastructure.rest.RouteGroups; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class AdminAuthFilter extends OncePerRequestFilter { + private final AdminAuthService adminAuthService; + public static final String ADMIN_SESSION_ATTR = "adminSession"; + + public static Optional actingEmail(HttpServletRequest request) { + Object attribute = request.getAttribute(ADMIN_SESSION_ATTR); + if (attribute instanceof AdminAuthService.AdminSession session) { + return Optional.ofNullable(session.email()); + } + return Optional.empty(); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String path = request.getRequestURI(); + + if (RouteGroups.isAdminChild(path) && !RouteGroups.isAdminAuthChild(path)) { + Optional sessionOpt = adminAuthService.getAuthenticatedSession(request); + if (sessionOpt.isEmpty()) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"success\":false,\"message\":\"Unauthorized\"}"); + return; + } + + AdminAuthService.AdminSession session = sessionOpt.get(); + request.setAttribute(ADMIN_SESSION_ATTR, session); + + List authorities = List.of( + new SimpleGrantedAuthority(RESTSecurityRole.ADMIN) + ); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + session.email(), + null, + authorities + ); + authentication.setDetails(session); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/ApiKeyFilter.java b/src/main/java/gg/modl/backend/infrastructure/filter/ApiKeyFilter.java index 710a3ee..f372594 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/ApiKeyFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/ApiKeyFilter.java @@ -1,12 +1,10 @@ package gg.modl.backend.infrastructure.filter; import gg.modl.backend.infrastructure.config.StagingEnvironment; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RESTMappingV2; -import gg.modl.backend.infrastructure.rest.RESTMappingV3; import gg.modl.backend.infrastructure.rest.RESTSecurityRole; import gg.modl.backend.infrastructure.rest.RequestAttribute; import gg.modl.backend.infrastructure.rest.RequestHeader; +import gg.modl.backend.infrastructure.rest.RouteGroups; import gg.modl.backend.infrastructure.proto.ProtobufErrorResponseWriter; import gg.modl.backend.server.ServerService; import gg.modl.backend.server.data.Server; @@ -35,10 +33,7 @@ public class ApiKeyFilter extends OncePerRequestFilter { protected boolean shouldNotFilter(HttpServletRequest request) { final String path = request.getRequestURI(); - return !path.startsWith(RESTMappingV1.PREFIX_MINECRAFT) - && !path.startsWith(RESTMappingV1.PREFIX_REPLAY_LITE) - && !path.startsWith(RESTMappingV2.PREFIX_MINECRAFT) - && !path.startsWith(RESTMappingV3.PREFIX_MINECRAFT); + return !RouteGroups.isMinecraftOrReplayLitePrefix(path); } @Override diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/FilterConfig.java b/src/main/java/gg/modl/backend/infrastructure/filter/FilterConfig.java index 13e6ba2..3472b16 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/FilterConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/FilterConfig.java @@ -1,45 +1,45 @@ -package gg.modl.backend.infrastructure.filter; - -import gg.modl.backend.infrastructure.config.ModlCorsProperties; -import gg.modl.backend.infrastructure.config.ModlDevProperties; -import gg.modl.backend.infrastructure.config.ModlProperties; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.server.ServerService; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.web.servlet.FilterRegistrationBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.Ordered; - -@Configuration -@RequiredArgsConstructor -public class FilterConfig { - private final ServerService serverService; - private final ApiKeyFilter apiKeyFilter; - private final ModlProperties modlProperties; - private final ModlDevProperties devProperties; - private final ModlCorsProperties corsProperties; - - @Bean - public FilterRegistrationBean serverDomainFilter() { - final FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new ServerHeaderFilter( - serverService, - modlProperties.isDevelopmentMode(), - devProperties.getServerDomain(), - corsProperties.getSystemOrigins() - )); - registrationBean.addUrlPatterns(RESTMappingV1.PREFIX_PANEL + "/*", RESTMappingV1.PREFIX_PUBLIC + "/*"); - registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE + 2); - - return registrationBean; - } - - @Bean - public FilterRegistrationBean apiKeyFilterRegistration() { - final FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(apiKeyFilter); - registrationBean.setEnabled(false); - - return registrationBean; - } -} +package gg.modl.backend.infrastructure.filter; + +import gg.modl.backend.infrastructure.config.ModlDevProperties; +import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.infrastructure.origin.OriginPolicyFactory; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.server.ServerService; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +@Configuration +@RequiredArgsConstructor +public class FilterConfig { + private final ServerService serverService; + private final ApiKeyFilter apiKeyFilter; + private final ModlProperties modlProperties; + private final ModlDevProperties devProperties; + private final OriginPolicyFactory originPolicyFactory; + + @Bean + public FilterRegistrationBean serverDomainFilter() { + final FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new ServerHeaderFilter( + serverService, + modlProperties.isDevelopmentMode(), + devProperties.getServerDomain(), + originPolicyFactory.systemOriginsOnly() + )); + registrationBean.addUrlPatterns(RESTMappingV1.PREFIX_PANEL + "/*", RESTMappingV1.PREFIX_PUBLIC + "/*"); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE + 2); + + return registrationBean; + } + + @Bean + public FilterRegistrationBean apiKeyFilterRegistration() { + final FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(apiKeyFilter); + registrationBean.setEnabled(false); + + return registrationBean; + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/OriginCsrfFilter.java b/src/main/java/gg/modl/backend/infrastructure/filter/OriginCsrfFilter.java index f2ad6b6..36b2980 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/OriginCsrfFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/OriginCsrfFilter.java @@ -1,11 +1,10 @@ package gg.modl.backend.infrastructure.filter; import gg.modl.backend.auth.AuthConfiguration; -import gg.modl.backend.infrastructure.config.ModlCorsProperties; -import gg.modl.backend.infrastructure.config.ModlProperties; import gg.modl.backend.infrastructure.origin.OriginPolicy; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.origin.OriginPolicyFactory; import gg.modl.backend.infrastructure.rest.RESTSecurityRole; +import gg.modl.backend.infrastructure.rest.RouteGroups; import gg.modl.backend.infrastructure.rest.RequestAttribute; import gg.modl.backend.infrastructure.rest.RequestHeader; import gg.modl.backend.infrastructure.util.HostExtractionUtil; @@ -28,8 +27,7 @@ @RequiredArgsConstructor public class OriginCsrfFilter extends OncePerRequestFilter { private final AuthConfiguration authConfiguration; - private final ModlCorsProperties corsProperties; - private final ModlProperties modlProperties; + private final OriginPolicyFactory originPolicyFactory; private volatile OriginPolicy originPolicy = new OriginPolicy(Set.of(), Set.of(), false); @Override @@ -40,10 +38,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) { } final String path = request.getRequestURI(); - final boolean panelOrAdmin = path.startsWith(RESTMappingV1.PREFIX_PANEL + "/") - || path.equals(RESTMappingV1.PREFIX_PANEL) - || path.startsWith(RESTMappingV1.PREFIX_ADMIN + "/") - || path.equals(RESTMappingV1.PREFIX_ADMIN); + final boolean panelOrAdmin = RouteGroups.isPanelArea(path) || RouteGroups.isAdminArea(path); return !panelOrAdmin || !hasSessionCookie(request); } @@ -94,11 +89,7 @@ private boolean hasSessionCookie(HttpServletRequest request) { @PostConstruct void initParsedOrigins() { - originPolicy = new OriginPolicy( - HostExtractionUtil.parseCommaSeparated(corsProperties.getSystemOrigins()), - Set.of(), - modlProperties.isDevelopmentMode() - ); + originPolicy = originPolicyFactory.systemOriginsOnly(); } private void reject(HttpServletResponse response) throws IOException { @@ -144,8 +135,7 @@ private boolean isAllowedReferer(HttpServletRequest request, String referer) { } private boolean isPanelPath(String path) { - return path != null && (path.startsWith(RESTMappingV1.PREFIX_PANEL + "/") - || path.equals(RESTMappingV1.PREFIX_PANEL)); + return RouteGroups.isPanelArea(path); } private String resolveRequestServerDomain(HttpServletRequest request) { diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/PanelPermissionFilter.java b/src/main/java/gg/modl/backend/infrastructure/filter/PanelPermissionFilter.java index 445951a..0bd8b65 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/PanelPermissionFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/PanelPermissionFilter.java @@ -1,233 +1,90 @@ -package gg.modl.backend.infrastructure.filter; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestAttribute; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.role.service.RoleAuthorization; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.staff.service.StaffService; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -@Component -@RequiredArgsConstructor -public class PanelPermissionFilter extends OncePerRequestFilter { - private final PermissionService permissionService; - private final StaffService staffService; - private static final List FIXED_PERMISSIONS = List.of( - new PermissionMapping(RESTMappingV1.PANEL_STAFF, "admin.staff.manage.members"), - new PermissionMapping(RESTMappingV1.PANEL_ROLES, "admin.staff.manage.roles"), - new PermissionMapping(RESTMappingV1.PANEL_DASHBOARD, "admin.audit.view.dashboard"), - new PermissionMapping(RESTMappingV1.PANEL_ANALYTICS, "admin.audit.view.analytics"), - new PermissionMapping(RESTMappingV1.PANEL_AUDIT, "admin.audit.view.logs"), - new PermissionMapping(RESTMappingV1.PANEL_LOGS, "admin.audit.view.logs"), - new PermissionMapping(RESTMappingV1.PANEL_REPLAYS, "punishment.modify") - ); - private static final String PERMIT = "__PERMIT__"; - private static final String PLAYER_READ = "__PLAYER_READ__"; - private static final List RW_PERMISSIONS = List.of( - new PermissionMapping(RESTMappingV1.PANEL_BILLING, "admin.settings.view.billing", "admin.settings.modify.billing"), - new PermissionMapping(RESTMappingV1.PANEL_HOMEPAGE_CARDS, "admin.settings.view.content", "admin.settings.modify.content"), - new PermissionMapping(RESTMappingV1.PANEL_KNOWLEDGEBASE, "admin.settings.view.content", "admin.settings.modify.content"), - new PermissionMapping(RESTMappingV1.PANEL_MEDIA, "admin.settings.view.content", "admin.settings.modify.content"), - new PermissionMapping(RESTMappingV1.PANEL_MIGRATION, "admin.settings.view.migration", "admin.settings.modify.migration"), - new PermissionMapping(RESTMappingV1.PANEL_STORAGE, "admin.settings.view.storage", "admin.settings.modify.storage"), - new PermissionMapping(RESTMappingV1.PANEL_TICKET_SUBSCRIPTIONS, "ticket.view.all", "ticket.reply.all"), - new PermissionMapping(RESTMappingV1.PANEL_TICKETS, "ticket.view.all", "ticket.reply.all"), - new PermissionMapping(RESTMappingV1.PANEL_APPEALS, "ticket.view.all", "appeal.modify"), - new PermissionMapping(RESTMappingV1.PANEL_SERVER, "admin.settings.view", "admin.settings.modify") - ); - private static final Set SETTINGS_PUNISHMENT_PATHS = Set.of( - "/punishment-types", "/status-thresholds", "/ai-moderation", - "/ai-dismiss-suggestion" - ); - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - String path = request.getRequestURI(); - if (!path.startsWith(RESTMappingV1.PREFIX_PANEL)) { - return true; - } - return startsWithEndpoint(path, RESTMappingV1.PANEL_AUTH); - } - - @Override - protected void doFilterInternal( - HttpServletRequest request, - @NotNull HttpServletResponse response, - @NotNull FilterChain filterChain - ) throws ServletException, IOException { - Server server = (Server) request.getAttribute(RequestAttribute.SERVER); - String email = RequestUtil.getSessionEmail(request); - if (server == null || email == null) { - deny(response); - return; - } - - if (isPanelDashboardAlertsRead(request)) { - filterChain.doFilter(request, response); - return; - } - - if (permissionService.isSuperAdmin(server, email)) { - filterChain.doFilter(request, response); - return; - } - - String requiredPermission = resolveRequiredPermission(request.getRequestURI(), request.getMethod()); - if (requiredPermission == null) { - deny(response); - return; - } - - if (PERMIT.equals(requiredPermission)) { - filterChain.doFilter(request, response); - return; - } - - Optional staffOpt = staffService.getStaffByEmail(server, email); - String roleId = staffOpt.map(staff -> RoleAuthorization.effectiveRoleId(server, staff)).orElse(null); - - boolean authorized; - if (PLAYER_READ.equals(requiredPermission)) { - authorized = hasPlayerReadAccess(server, roleId); - } else { - authorized = roleId != null - && hasRequiredPermission(server, roleId, requiredPermission, request.getRequestURI(), request.getMethod()); - } - if (!authorized) { - deny(response); - return; - } - - filterChain.doFilter(request, response); - } - - private boolean hasPlayerReadAccess(Server server, String roleId) { - return roleId != null - && (permissionService.hasPermission(server, roleId, "punishment.view") - || permissionService.hasPermission(server, roleId, "punishment.modify") - || permissionService.hasAnyPermissionWithPrefix(server, roleId, "punishment.apply.")); - } - - private boolean hasRequiredPermission(Server server, String role, String requiredPermission, String path, String method) { - if (permissionService.hasPermission(server, role, requiredPermission)) { - return true; - } - return isAppealReplyWrite(path, method) - && "appeal.modify".equals(requiredPermission) - && permissionService.hasPermission(server, role, "ticket.reply.all"); - } - - private boolean isAppealReplyWrite(String path, String method) { - return "POST".equalsIgnoreCase(method) - && startsWithEndpoint(path, RESTMappingV1.PANEL_APPEALS) - && path.endsWith("/replies"); - } - - private boolean isPanelDashboardAlertsRead(HttpServletRequest request) { - return isReadOnly(request.getMethod()) - && startsWithEndpoint(request.getRequestURI(), RESTMappingV1.PANEL_DASHBOARD + "/alerts"); - } - - private void deny(HttpServletResponse response) throws IOException { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json"); - response.getWriter().write("{\"success\":false,\"status\":403,\"error\":\"Insufficient permissions\",\"message\":\"Insufficient permissions\"}"); - } - - private String resolveRequiredPermission(String path, String method) { - for (PermissionMapping mapping : FIXED_PERMISSIONS) { - if (startsWithEndpoint(path, mapping.endpoint())) { - return mapping.readPermission(); - } - } - - if (startsWithEndpoint(path, RESTMappingV1.PANEL_PLAYERS)) { - return resolvePlayersPermission(path, method); - } - - if (startsWithEndpoint(path, RESTMappingV1.PANEL_TICKETS + "/bulk")) { - return isReadOnly(method) ? "ticket.view.all" : "ticket.close.all"; - } - - if (startsWithEndpoint(path, RESTMappingV1.PANEL_SETTINGS)) { - return resolveSettingsPermission(path, method); - } - - for (PermissionMapping mapping : RW_PERMISSIONS) { - if (startsWithEndpoint(path, mapping.endpoint())) { - return isReadOnly(method) ? mapping.readPermission() : mapping.writePermission(); - } - } - - return null; - } - - private String resolvePlayersPermission(String path, String method) { - if (isReadOnly(method)) { - return PLAYER_READ; - } - if ("POST".equalsIgnoreCase(method) && isCreatePunishmentPath(path)) { - return PERMIT; - } - return "punishment.modify"; - } - - private boolean isCreatePunishmentPath(String path) { - String prefix = RESTMappingV1.PANEL_PLAYERS + "/"; - if (!path.startsWith(prefix)) { - return false; - } - String[] segments = path.substring(prefix.length()).split("/"); - return segments.length == 2 - && !segments[0].isEmpty() - && "punishments".equals(segments[1]); - } - - private String resolveSettingsPermission(String path, String method) { - String base = RESTMappingV1.PANEL_SETTINGS; - - if (startsWithEndpoint(path, base + "/ai-apply-punishment")) { - return PERMIT; - } - - for (String suffix : SETTINGS_PUNISHMENT_PATHS) { - if (startsWithEndpoint(path, base + suffix)) { - return isReadOnly(method) ? "admin.settings.view.punishments" : "admin.settings.modify.punishments"; - } - } - - if (startsWithEndpoint(path, base + "/domain")) { - return isReadOnly(method) ? "admin.settings.view.domain" : "admin.settings.modify.domain"; - } - - return isReadOnly(method) ? "admin.settings.view" : "admin.settings.modify"; - } - - private boolean isReadOnly(String method) { - return "GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method); - } - - private boolean startsWithEndpoint(String path, String endpoint) { - return path.equals(endpoint) || path.startsWith(endpoint + "/"); - } - - private record PermissionMapping(String endpoint, String readPermission, String writePermission) { - PermissionMapping(String endpoint, String permission) { - this(endpoint, permission, permission); - } - } -} +package gg.modl.backend.infrastructure.filter; + +import gg.modl.backend.infrastructure.authorization.PanelAccessPolicy; +import gg.modl.backend.infrastructure.authorization.PanelAccessPolicyResolver; +import gg.modl.backend.infrastructure.authorization.PanelAccessRequest; +import gg.modl.backend.infrastructure.authorization.PanelPrincipalPermissions; +import gg.modl.backend.infrastructure.rest.RequestAttribute; +import gg.modl.backend.infrastructure.rest.RouteGroups; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.role.service.RoleAuthorization; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.staff.data.Staff; +import gg.modl.backend.staff.service.StaffLookupCache; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class PanelPermissionFilter extends OncePerRequestFilter { + private final PermissionService permissionService; + private final StaffLookupCache staffLookupCache; + private final PanelAccessPolicyResolver policyResolver; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + if (!RouteGroups.isPanelPrefix(path)) { + return true; + } + return RouteGroups.isPanelAuthArea(path); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + @NotNull HttpServletResponse response, + @NotNull FilterChain filterChain + ) throws ServletException, IOException { + Server server = (Server) request.getAttribute(RequestAttribute.SERVER); + String email = RequestUtil.getSessionEmail(request); + if (server == null || email == null) { + deny(response); + return; + } + + if (permissionService.isSuperAdmin(server, email)) { + filterChain.doFilter(request, response); + return; + } + + List policies = policyResolver.resolvePolicies(request); + if (policies.isEmpty()) { + deny(response); + return; + } + + PanelAccessRequest accessRequest = new PanelAccessRequest(request.getMethod(), request.getRequestURI()); + if (policies.stream().anyMatch(policy -> policy.permitsWithoutRole(accessRequest))) { + filterChain.doFilter(request, response); + return; + } + + Optional staffOpt = staffLookupCache.findByEmail(server, email); + String roleId = staffOpt.map(staff -> RoleAuthorization.effectiveRoleId(server, staff)).orElse(null); + PanelPrincipalPermissions permissions = new PanelPrincipalPermissions(server, roleId, permissionService); + + if (policies.stream().anyMatch(policy -> policy.permitsWithRole(accessRequest, permissions))) { + filterChain.doFilter(request, response); + return; + } + + deny(response); + } + + private void deny(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.getWriter().write("{\"success\":false,\"status\":403,\"error\":\"Insufficient permissions\",\"message\":\"Insufficient permissions\"}"); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/ServerHeaderFilter.java b/src/main/java/gg/modl/backend/infrastructure/filter/ServerHeaderFilter.java index ddcf088..77fc44f 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/ServerHeaderFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/ServerHeaderFilter.java @@ -30,19 +30,19 @@ public class ServerHeaderFilter extends OncePerRequestFilter { ); public ServerHeaderFilter(ServerService serverService) { - this(serverService, false, null, null); + this(serverService, false, null, new OriginPolicy(Set.of(), Set.of(), false)); } public ServerHeaderFilter( ServerService serverService, boolean developmentMode, @Nullable String devServerDomain, - @Nullable String systemOrigins + OriginPolicy originPolicy ) { this.serverService = serverService; this.developmentMode = developmentMode; this.devServerDomain = devServerDomain; - this.originPolicy = new OriginPolicy(HostExtractionUtil.parseCommaSeparated(systemOrigins), Set.of(), developmentMode); + this.originPolicy = originPolicy; } @Override diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/SessionAuthenticationFilter.java b/src/main/java/gg/modl/backend/infrastructure/filter/SessionAuthenticationFilter.java index 0b7dbd4..4faba7a 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/SessionAuthenticationFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/SessionAuthenticationFilter.java @@ -3,11 +3,9 @@ import gg.modl.backend.auth.AuthConfiguration; import gg.modl.backend.auth.session.AuthSessionData; import gg.modl.backend.auth.session.SessionService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RESTMappingV2; -import gg.modl.backend.infrastructure.rest.RESTMappingV3; import gg.modl.backend.infrastructure.rest.RESTSecurityRole; import gg.modl.backend.infrastructure.rest.RequestAttribute; +import gg.modl.backend.infrastructure.rest.RouteGroups; import gg.modl.backend.infrastructure.util.CookieUtil; import gg.modl.backend.server.data.Server; import jakarta.servlet.FilterChain; @@ -52,9 +50,7 @@ protected boolean shouldNotFilter(@NotNull HttpServletRequest request) { return true; } - if (path.startsWith(RESTMappingV1.PREFIX_MINECRAFT) - || path.startsWith(RESTMappingV2.PREFIX_MINECRAFT) - || path.startsWith(RESTMappingV3.PREFIX_MINECRAFT)) { + if (RouteGroups.isMinecraftApiPrefix(path)) { return true; } diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/V1SecurityConfig.java b/src/main/java/gg/modl/backend/infrastructure/filter/V1SecurityConfig.java index 2b9d145..93c1920 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/V1SecurityConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/V1SecurityConfig.java @@ -1,51 +1,49 @@ -package gg.modl.backend.infrastructure.filter; - -import gg.modl.backend.infrastructure.filter.AdminAuthFilter; -import gg.modl.backend.infrastructure.filter.SessionAuthenticationFilter; -import gg.modl.backend.infrastructure.cors.DynamicCorsConfigurationSource; +package gg.modl.backend.infrastructure.filter; + +import gg.modl.backend.infrastructure.cors.DynamicCorsConfigurationSource; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RESTMappingV2; import gg.modl.backend.infrastructure.rest.RESTMappingV3; import gg.modl.backend.infrastructure.rest.RESTSecurityRole; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -public class V1SecurityConfig { - private final SessionAuthenticationFilter sessionAuthenticationFilter; - private final ApiKeyFilter apiKeyFilter; - private final AdminAuthFilter adminAuthFilter; - private final PanelPermissionFilter panelPermissionFilter; - private final OriginCsrfFilter originCsrfFilter; - private final DynamicCorsConfigurationSource dynamicCorsConfigurationSource; - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - return http - .cors(cors -> cors.configurationSource(dynamicCorsConfigurationSource)) - .csrf(AbstractHttpConfigurer::disable) - .headers(headers -> headers - .frameOptions(fo -> fo.deny()) - .contentTypeOptions(cto -> {}) - .httpStrictTransportSecurity(hsts -> hsts - .includeSubDomains(true) - .maxAgeInSeconds(31536000) - ) - ) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(c -> c - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers(HttpMethod.HEAD, "/v1").permitAll() +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class V1SecurityConfig { + private final SessionAuthenticationFilter sessionAuthenticationFilter; + private final ApiKeyFilter apiKeyFilter; + private final AdminAuthFilter adminAuthFilter; + private final PanelPermissionFilter panelPermissionFilter; + private final OriginCsrfFilter originCsrfFilter; + private final DynamicCorsConfigurationSource dynamicCorsConfigurationSource; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .cors(cors -> cors.configurationSource(dynamicCorsConfigurationSource)) + .csrf(AbstractHttpConfigurer::disable) + .headers(headers -> headers + .frameOptions(fo -> fo.deny()) + .contentTypeOptions(cto -> {}) + .httpStrictTransportSecurity(hsts -> hsts + .includeSubDomains(true) + .maxAgeInSeconds(31536000) + ) + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(c -> c + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers(HttpMethod.HEAD, "/v1").permitAll() .requestMatchers("/error").permitAll() .requestMatchers("/actuator/health", "/actuator/health/**", "/actuator/prometheus").permitAll() .requestMatchers(RESTMappingV1.HEALTH).permitAll() @@ -62,13 +60,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(RESTMappingV3.PREFIX_MINECRAFT + "/**").hasAuthority(RESTSecurityRole.MINECRAFT) .anyRequest().authenticated() ) - .addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(sessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(adminAuthFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterAfter(panelPermissionFilter, SessionAuthenticationFilter.class) - .addFilterAfter(originCsrfFilter, AdminAuthFilter.class) - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .build(); - } -} + .addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(sessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(adminAuthFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(panelPermissionFilter, SessionAuthenticationFilter.class) + .addFilterAfter(originCsrfFilter, AdminAuthFilter.class) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .build(); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/onetimecode/OneTimeCodeCodec.java b/src/main/java/gg/modl/backend/infrastructure/onetimecode/OneTimeCodeCodec.java index 92c20bf..76672f1 100644 --- a/src/main/java/gg/modl/backend/infrastructure/onetimecode/OneTimeCodeCodec.java +++ b/src/main/java/gg/modl/backend/infrastructure/onetimecode/OneTimeCodeCodec.java @@ -1,7 +1,9 @@ package gg.modl.backend.infrastructure.onetimecode; import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Base64; import javax.crypto.Mac; @@ -32,7 +34,7 @@ public String hash(String code, String secret) { } MessageDigest digest = MessageDigest.getInstance(DIGEST_ALGORITHM); return Base64.getEncoder().encodeToString(digest.digest(input)); - } catch (Exception e) { + } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new IllegalStateException("Failed to hash one-time code", e); } } diff --git a/src/main/java/gg/modl/backend/infrastructure/origin/OriginPolicyFactory.java b/src/main/java/gg/modl/backend/infrastructure/origin/OriginPolicyFactory.java new file mode 100644 index 0000000..b88af11 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/origin/OriginPolicyFactory.java @@ -0,0 +1,31 @@ +package gg.modl.backend.infrastructure.origin; + +import gg.modl.backend.infrastructure.config.ModlCorsProperties; +import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.infrastructure.util.HostExtractionUtil; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OriginPolicyFactory { + private final ModlCorsProperties corsProperties; + private final ModlProperties modlProperties; + + public OriginPolicy withAppDomains() { + return new OriginPolicy( + HostExtractionUtil.parseCommaSeparated(corsProperties.getSystemOrigins()), + HostExtractionUtil.parseCommaSeparated(corsProperties.getAppDomains()), + modlProperties.isDevelopmentMode() + ); + } + + public OriginPolicy systemOriginsOnly() { + return new OriginPolicy( + HostExtractionUtil.parseCommaSeparated(corsProperties.getSystemOrigins()), + Set.of(), + modlProperties.isDevelopmentMode() + ); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoBinaryHttpMessageConverter.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoBinaryHttpMessageConverter.java index bb26276..1bae9f8 100644 --- a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoBinaryHttpMessageConverter.java +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoBinaryHttpMessageConverter.java @@ -7,11 +7,9 @@ import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; import java.io.IOException; -@Component public class ProtoBinaryHttpMessageConverter extends AbstractHttpMessageConverter { public ProtoBinaryHttpMessageConverter() { @@ -28,7 +26,7 @@ protected boolean supports(@NonNull Class clazz) { protected Message readInternal(@NonNull Class clazz, @NonNull HttpInputMessage inputMessage) throws IOException { try { - Message defaultInstance = getDefaultInstance(clazz); + Message defaultInstance = ProtoMessages.defaultInstance(clazz); return defaultInstance.getParserForType().parseFrom(inputMessage.getBody()); } catch (Exception e) { throw new HttpMessageNotReadableException("Failed to parse binary protobuf: " + e.getMessage(), e, inputMessage); @@ -44,12 +42,4 @@ protected void writeInternal(@NonNull Message message, throw new HttpMessageNotWritableException("Failed to write binary protobuf: " + e.getMessage(), e); } } - - private static Message getDefaultInstance(Class clazz) { - try { - return (Message) clazz.getMethod("getDefaultInstance").invoke(null); - } catch (ReflectiveOperationException e) { - throw new IllegalArgumentException("Cannot get default instance for " + clazz.getName(), e); - } - } } diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoJsonHttpMessageConverter.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoJsonHttpMessageConverter.java index ba2d47f..91ef76a 100644 --- a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoJsonHttpMessageConverter.java +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoJsonHttpMessageConverter.java @@ -9,14 +9,12 @@ import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; -@Component public class ProtoJsonHttpMessageConverter extends AbstractHttpMessageConverter { private static final JsonFormat.Printer PRINTER = JsonFormat.printer() @@ -38,7 +36,7 @@ protected boolean supports(@NonNull Class clazz) { protected Message readInternal(@NonNull Class clazz, @NonNull HttpInputMessage inputMessage) throws IOException { try { - Message.Builder builder = getDefaultInstance(clazz).newBuilderForType(); + Message.Builder builder = ProtoMessages.defaultInstance(clazz).newBuilderForType(); try (InputStreamReader reader = new InputStreamReader(inputMessage.getBody(), StandardCharsets.UTF_8)) { PARSER.merge(reader, builder); } @@ -57,12 +55,4 @@ protected void writeInternal(@NonNull Message message, throw new HttpMessageNotWritableException("Failed to write proto JSON: " + e.getMessage(), e); } } - - private static Message getDefaultInstance(Class clazz) { - try { - return (Message) clazz.getMethod("getDefaultInstance").invoke(null); - } catch (ReflectiveOperationException e) { - throw new IllegalArgumentException("Cannot get default instance for " + clazz.getName(), e); - } - } } diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMapperSupport.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMapperSupport.java index bf4088e..f0a3de8 100644 --- a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMapperSupport.java +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMapperSupport.java @@ -48,6 +48,14 @@ public static String stringValue(Object value) { return value == null ? "" : Objects.toString(value); } + public static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + public static String emptyToNull(String value) { + return value == null || value.isEmpty() ? null : value; + } + public static String dateAwareString(Object value) { if (value instanceof Date date) { return date.toInstant().toString(); @@ -133,9 +141,21 @@ public static void setOptionalBoolean(Consumer setter, Object value) { } } - public static void setOptionalDouble(Consumer setter, Object value) { + public static void setOptionalEpochMillis(Consumer setter, Date value) { + if (value != null) { + setter.accept(value.getTime()); + } + } + + public static void setOptionalTimestamp(Consumer setter, Date value) { if (value != null) { - setter.accept(doubleValue(value)); + setter.accept(toTimestamp(value)); + } + } + + public static void setOptionalStrings(Consumer> setter, Collection values) { + if (values != null) { + setter.accept(values); } } @@ -161,6 +181,24 @@ public static Map structToMap(Struct struct) { return result; } + public static List structListToObjects(List structs) { + if (structs.isEmpty()) { + return null; + } + return structs.stream() + .map(struct -> (Object) structToMap(struct)) + .toList(); + } + + public static List> structListToMaps(List structs) { + if (structs.isEmpty()) { + return null; + } + return structs.stream() + .map(ProtoMapperSupport::structToMap) + .toList(); + } + public static Timestamp toTimestamp(Object value) { long millis = longValue(value); return Timestamp.newBuilder() @@ -183,6 +221,13 @@ public static List> listOfMaps(Object object) { .toList(); } + public static List listOf(Object object, Class elementType) { + return list(object).stream() + .filter(elementType::isInstance) + .map(elementType::cast) + .toList(); + } + public static Map map(Object object) { if (object instanceof Map rawMap) { return stringObjectMap(rawMap); @@ -196,6 +241,14 @@ public static Map stringObjectMap(Map rawMap) { return result; } + @SuppressWarnings("unchecked") + public static Map asMap(Object value) { + if (value instanceof Map map) { + return (Map) map; + } + return Map.of(); + } + public static void addAll(Collection source, Function converter, Consumer adder) { @@ -295,7 +348,16 @@ private static Value integralValue(Value.Builder builder, BigInteger value) { : builder.setNumberValue(value.doubleValue()).build(); } - private static Object valueToObject(Value value) { + public static List valuesToObjects(List values) { + if (values.isEmpty()) { + return null; + } + return values.stream() + .map(ProtoMapperSupport::valueToObject) + .toList(); + } + + public static Object valueToObject(Value value) { return switch (value.getKindCase()) { case NULL_VALUE, KIND_NOT_SET -> null; case NUMBER_VALUE -> value.getNumberValue(); diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessageConverterConfig.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessageConverterConfig.java index c5fce18..fbb9ee9 100644 --- a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessageConverterConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessageConverterConfig.java @@ -1,27 +1,18 @@ package gg.modl.backend.infrastructure.proto; -import java.util.List; import org.springframework.context.annotation.Configuration; -import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverters; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; -@Configuration +@Configuration(proxyBeanMethods = false) public class ProtoMessageConverterConfig implements WebMvcConfigurer { - private final ProtoJsonHttpMessageConverter protoJsonConverter; - private final ProtoBinaryHttpMessageConverter protoBinaryConverter; - - public ProtoMessageConverterConfig(ProtoJsonHttpMessageConverter protoJsonConverter, - ProtoBinaryHttpMessageConverter protoBinaryConverter) { - this.protoJsonConverter = protoJsonConverter; - this.protoBinaryConverter = protoBinaryConverter; - } + private final ProtoJsonHttpMessageConverter protoJsonConverter = new ProtoJsonHttpMessageConverter(); + private final ProtoBinaryHttpMessageConverter protoBinaryConverter = new ProtoBinaryHttpMessageConverter(); @Override - public void extendMessageConverters(List> converters) { - converters.removeIf(c -> c instanceof ProtoJsonHttpMessageConverter - || c instanceof ProtoBinaryHttpMessageConverter); - converters.add(0, protoBinaryConverter); - converters.add(0, protoJsonConverter); + public void configureMessageConverters(HttpMessageConverters.ServerBuilder builder) { + builder.addCustomConverter(protoJsonConverter); + builder.addCustomConverter(protoBinaryConverter); } } diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessages.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessages.java new file mode 100644 index 0000000..0db049c --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessages.java @@ -0,0 +1,16 @@ +package gg.modl.backend.infrastructure.proto; + +import com.google.protobuf.Message; + +public final class ProtoMessages { + private ProtoMessages() { + } + + public static Message defaultInstance(Class clazz) { + try { + return (Message) clazz.getMethod("getDefaultInstance").invoke(null); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException("Cannot get default instance for " + clazz.getName(), e); + } + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoValidationException.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoValidationException.java index 4a7f58f..17a7fb7 100644 --- a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoValidationException.java +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoValidationException.java @@ -15,7 +15,7 @@ public class ProtoValidationException extends RuntimeException { public ProtoValidationException(ValidationResult result) { super(formatViolations(result.getViolations())); - this.violations = result.getViolations(); + this.violations = List.copyOf(result.getViolations()); } public List getViolations() { diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtobufErrorResponseWriter.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtobufErrorResponseWriter.java index 5465ff2..a0137cb 100644 --- a/src/main/java/gg/modl/backend/infrastructure/proto/ProtobufErrorResponseWriter.java +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtobufErrorResponseWriter.java @@ -1,7 +1,6 @@ package gg.modl.backend.infrastructure.proto; -import gg.modl.backend.infrastructure.rest.RESTMappingV2; -import gg.modl.backend.infrastructure.rest.RESTMappingV3; +import gg.modl.backend.infrastructure.rest.RouteGroups; import gg.modl.proto.modl.v1.ApiError; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.http.HttpServletRequest; @@ -20,10 +19,10 @@ public class ProtobufErrorResponseWriter { public boolean shouldWriteProtobuf(HttpServletRequest request) { String path = protobufDecisionPath(request); - if (path != null && path.startsWith(RESTMappingV3.PREFIX)) { + if (RouteGroups.isVersion3Prefix(path)) { return true; } - if (path != null && (path.startsWith("/v1") || path.startsWith(RESTMappingV2.PREFIX_MINECRAFT))) { + if (RouteGroups.isVersion1Prefix(path) || RouteGroups.isVersion2MinecraftPrefix(path)) { return false; } return acceptsProtobuf(Collections.list(request.getHeaders(HttpHeaders.ACCEPT))); diff --git a/src/main/java/gg/modl/backend/infrastructure/ratelimit/BucketPool.java b/src/main/java/gg/modl/backend/infrastructure/ratelimit/BucketPool.java new file mode 100644 index 0000000..b2fdd07 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/ratelimit/BucketPool.java @@ -0,0 +1,34 @@ +package gg.modl.backend.infrastructure.ratelimit; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; + +@Component +public class BucketPool { + + private static final int MAX_BUCKETS_PER_NAMESPACE = 50_000; + + private final Map> namespaces = new ConcurrentHashMap<>(); + + public Bucket resolveBucket(String namespace, String key, int capacity, Duration refillDuration) { + return namespaces + .computeIfAbsent(namespace, ignored -> Caffeine.newBuilder() + .maximumSize(MAX_BUCKETS_PER_NAMESPACE) + .build()) + .get(key, ignored -> createBucket(capacity, refillDuration)); + } + + private Bucket createBucket(int capacity, Duration refillDuration) { + Bandwidth limit = Bandwidth.builder() + .capacity(capacity) + .refillGreedy(capacity, refillDuration) + .build(); + return Bucket.builder().addLimit(limit).build(); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/ratelimit/RateLimitConfig.java b/src/main/java/gg/modl/backend/infrastructure/ratelimit/RateLimitConfig.java index 37f88c5..5fb6116 100644 --- a/src/main/java/gg/modl/backend/infrastructure/ratelimit/RateLimitConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/ratelimit/RateLimitConfig.java @@ -1,21 +1,23 @@ package gg.modl.backend.infrastructure.ratelimit; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import io.github.bucket4j.Bandwidth; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RESTMappingV2; +import gg.modl.backend.infrastructure.rest.RESTMappingV3; +import gg.modl.backend.infrastructure.rest.RouteGroups; import io.github.bucket4j.Bucket; import java.time.Duration; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class RateLimitConfig { - private final Map> buckets = new ConcurrentHashMap<>(); - private static final int MAX_BUCKETS_PER_TIER = 50_000; + private final BucketPool bucketPool; + private static final Set HEAVY_PANEL_WRITE_PATTERNS = Set.of( "/staff/invite", "/settings", "/find-linked" ); @@ -29,34 +31,22 @@ public class RateLimitConfig { ); private static final List PREFIX_RULES = List.of( new PathRule("/v1/webhooks/", RateLimitTier.WEBHOOK), - new PathRule("/v1/replay-lite/", RateLimitTier.REPLAY_LITE_UPLOAD), - new PathRule("/v1/minecraft/", RateLimitTier.MINECRAFT_STANDARD), - new PathRule("/v2/minecraft/", RateLimitTier.MINECRAFT_STANDARD), - new PathRule("/v3/minecraft/", RateLimitTier.MINECRAFT_STANDARD), - new PathRule("/v1/admin/auth/session", RateLimitTier.ADMIN_SESSION), - new PathRule("/v1/admin/auth/", RateLimitTier.ADMIN_AUTH), - new PathRule("/v1/admin/beta-testers", RateLimitTier.ADMIN_BETA), - new PathRule("/v1/admin/", RateLimitTier.ADMIN_STANDARD) + new PathRule(RESTMappingV1.PREFIX_REPLAY_LITE + "/", RateLimitTier.REPLAY_LITE_UPLOAD), + new PathRule(RESTMappingV1.PREFIX_MINECRAFT + "/", RateLimitTier.MINECRAFT_STANDARD), + new PathRule(RESTMappingV2.PREFIX_MINECRAFT + "/", RateLimitTier.MINECRAFT_STANDARD), + new PathRule(RESTMappingV3.PREFIX_MINECRAFT + "/", RateLimitTier.MINECRAFT_STANDARD), + new PathRule(RESTMappingV1.ADMIN_AUTH + "/session", RateLimitTier.ADMIN_SESSION), + new PathRule(RESTMappingV1.ADMIN_AUTH + "/", RateLimitTier.ADMIN_AUTH), + new PathRule(RESTMappingV1.ADMIN_BETA_TESTERS, RateLimitTier.ADMIN_BETA), + new PathRule(RESTMappingV1.PREFIX_ADMIN + "/", RateLimitTier.ADMIN_STANDARD) ); private static final Map EXACT_PATH_RULES = Map.of( - "/v1/minecraft/players/login", RateLimitTier.MINECRAFT_LOGIN, - "/v3/minecraft/players/login", RateLimitTier.MINECRAFT_LOGIN + RESTMappingV1.MINECRAFT_PLAYERS + "/login", RateLimitTier.MINECRAFT_LOGIN, + RESTMappingV3.PREFIX_MINECRAFT + "/players/login", RateLimitTier.MINECRAFT_LOGIN ); public Bucket resolveBucket(String clientKey, RateLimitTier tier) { - return buckets - .computeIfAbsent(tier, t -> Caffeine.newBuilder() - .maximumSize(MAX_BUCKETS_PER_TIER) - .build()) - .get(clientKey, k -> createBucket(tier)); - } - - private Bucket createBucket(RateLimitTier tier) { - Bandwidth limit = Bandwidth.builder() - .capacity(tier.getCapacity()) - .refillGreedy(tier.getCapacity(), tier.getRefillDuration()) - .build(); - return Bucket.builder().addLimit(limit).build(); + return bucketPool.resolveBucket(tier.name(), clientKey, tier.getCapacity(), tier.getRefillDuration()); } public RateLimitTier getTierForPath(String path, String method) { @@ -75,22 +65,22 @@ public RateLimitTier getTierForPath(String path, String method) { } } - if (path.startsWith("/v1/panel/auth/")) { - boolean sendCode = path.equals("/v1/panel/auth/send-email-code") - || path.equals("/v1/panel/auth/email/send-code"); + if (path.startsWith(RESTMappingV1.PANEL_AUTH + "/")) { + boolean sendCode = path.equals(RESTMappingV1.PANEL_AUTH + "/send-email-code") + || path.equals(RESTMappingV1.PANEL_AUTH + "/email/send-code"); return sendCode ? RateLimitTier.AUTH_SEND_CODE : RateLimitTier.AUTH; } - if (path.startsWith("/v1/panel/migration/")) { - return path.equals("/v1/panel/migration/status") && "GET".equalsIgnoreCase(method) + if (path.startsWith(RESTMappingV1.PANEL_MIGRATION + "/")) { + return path.equals(RESTMappingV1.PANEL_MIGRATION + "/status") && "GET".equalsIgnoreCase(method) ? RateLimitTier.MIGRATION_STATUS : RateLimitTier.MIGRATION; } - if (path.startsWith("/v1/panel/")) { + if (RouteGroups.isPanelChild(path)) { return resolvePanelTier(path, method); } - if (path.startsWith("/v1/public/")) { + if (RouteGroups.isPublicChild(path)) { return resolvePublicTier(path, method); } @@ -108,21 +98,21 @@ private RateLimitTier resolvePanelTier(String path, String method) { } private RateLimitTier resolvePublicTier(String path, String method) { - if (path.startsWith("/v1/public/replay-lite/") && isWriteMethod(method)) { + if (RouteGroups.isPublicReplayLiteChild(path) && isWriteMethod(method)) { return RateLimitTier.REPLAY_LITE_LABEL; } - if (path.startsWith("/v1/public/media/") && isWriteMethod(method)) { + if (path.startsWith(RESTMappingV1.PUBLIC_MEDIA + "/") && isWriteMethod(method)) { return RateLimitTier.PUBLIC_MEDIA_UPLOAD; } - if (path.startsWith("/v1/public/appeals") && isWriteMethod(method) + if (path.startsWith(RESTMappingV1.PUBLIC_APPEALS) && isWriteMethod(method) && (path.contains("/verify") || path.contains("/request-verification"))) { return RateLimitTier.PUBLIC_TICKET_VERIFY; } - if (path.startsWith("/v1/public/tickets") && isWriteMethod(method)) { + if (path.startsWith(RESTMappingV1.PUBLIC_TICKETS) && isWriteMethod(method)) { if (path.contains("/verify") || path.contains("/request-verification")) { return RateLimitTier.PUBLIC_TICKET_VERIFY; } - if (path.equals("/v1/public/tickets") || path.equals("/v1/public/tickets/unfinished")) { + if (path.equals(RESTMappingV1.PUBLIC_TICKETS) || path.equals(RESTMappingV1.PUBLIC_TICKETS + "/unfinished")) { return RateLimitTier.PUBLIC_TICKET_CREATE; } return RateLimitTier.PUBLIC_TICKET_INTERACT; diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/HealthController.java b/src/main/java/gg/modl/backend/infrastructure/rest/HealthController.java index b537723..bd5a49f 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/HealthController.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/HealthController.java @@ -1,26 +1,26 @@ -package gg.modl.backend.infrastructure.rest; - -import java.util.Map; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.HEALTH) -public class HealthController { - - @GetMapping - public ResponseEntity> health() { - return ResponseEntity.ok(Map.of( - "status", "ok", - "timestamp", System.currentTimeMillis() - )); - } - - @RequestMapping(method = RequestMethod.HEAD) - public ResponseEntity healthHead() { - return ResponseEntity.ok().build(); - } -} +package gg.modl.backend.infrastructure.rest; + +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.HEALTH) +public class HealthController { + + @GetMapping + public ResponseEntity> health() { + return ResponseEntity.ok(Map.of( + "status", "ok", + "timestamp", System.currentTimeMillis() + )); + } + + @RequestMapping(method = RequestMethod.HEAD) + public ResponseEntity healthHead() { + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/MetricsConfig.java b/src/main/java/gg/modl/backend/infrastructure/rest/MetricsConfig.java index 45c38cf..bf977fe 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/MetricsConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/MetricsConfig.java @@ -1,6 +1,5 @@ package gg.modl.backend.infrastructure.rest; -import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.config.MeterFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -8,12 +7,6 @@ @Configuration public class MetricsConfig { - /** - * Limit the cardinality of the 'uri' tag on HTTP server metrics. - * Without this, dynamic path segments (e.g. /v1/public/players/{uuid}) - * create unbounded unique tag values, triggering - * MaximumAllowableTagsMeterFilter warnings and bloating Prometheus. - */ @Bean public MeterFilter uriTagCardinalityFilter() { return MeterFilter.maximumAllowableTags("http.server.requests", "uri", 200, MeterFilter.deny()); diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV1.java b/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV1.java index 73250ae..9b21a71 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV1.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV1.java @@ -1,48 +1,39 @@ package gg.modl.backend.infrastructure.rest; public final class RESTMappingV1 { - private static final String V1 = "/v1"; + public static final String V1 = "/v1"; public static final String PREFIX_PANEL = V1 + "/panel"; public static final String PREFIX_ADMIN = V1 + "/admin"; - // Admin routes public static final String ADMIN_AUTH = PREFIX_ADMIN + "/auth"; public static final String PREFIX_PUBLIC = V1 + "/public"; public static final String PREFIX_MINECRAFT = V1 + "/minecraft"; public static final String PREFIX_REPLAY_LITE = V1 + "/replay-lite"; - // Health check + public static final String PUBLIC_REPLAY_LITE = PREFIX_PUBLIC + "/replay-lite"; public static final String HEALTH = V1 + "/health"; - // Server private static final String SERVER = "/server"; public static final String PUBLIC_SERVER = PREFIX_PUBLIC + SERVER; public static final String PANEL_SERVER = PREFIX_PANEL + SERVER; - // Player private static final String PLAYERS = "/players"; public static final String PANEL_PLAYERS = PREFIX_PANEL + PLAYERS; public static final String PUBLIC_PLAYERS = PREFIX_PUBLIC + PLAYERS; public static final String MINECRAFT_PLAYERS = PREFIX_MINECRAFT + PLAYERS; - // Auth private static final String AUTH = "/auth"; public static final String PANEL_AUTH = PREFIX_PANEL + AUTH; - // Settings private static final String SETTINGS = "/settings"; public static final String PANEL_SETTINGS = PREFIX_PANEL + SETTINGS; - // Public settings and media public static final String PUBLIC_SETTINGS = PREFIX_PUBLIC + SETTINGS; - // Tickets private static final String TICKETS = "/tickets"; public static final String PANEL_TICKETS = PREFIX_PANEL + TICKETS; public static final String PUBLIC_TICKETS = PREFIX_PUBLIC + TICKETS; public static final String MINECRAFT_TICKETS = PREFIX_MINECRAFT + TICKETS; private static final String TICKET_SUBSCRIPTIONS = "/ticket-subscriptions"; public static final String PANEL_TICKET_SUBSCRIPTIONS = PREFIX_PANEL + TICKET_SUBSCRIPTIONS; - // Staff & Roles private static final String STAFF = "/staff"; public static final String PANEL_STAFF = PREFIX_PANEL + STAFF; public static final String MINECRAFT_STAFF = PREFIX_MINECRAFT + STAFF; private static final String ROLES = "/roles"; public static final String PANEL_ROLES = PREFIX_PANEL + ROLES; public static final String MINECRAFT_ROLES = PREFIX_MINECRAFT + ROLES; - // Analytics, Audit & Dashboard private static final String ANALYTICS = "/analytics"; public static final String PANEL_ANALYTICS = PREFIX_PANEL + ANALYTICS; public static final String ADMIN_ANALYTICS = PREFIX_ADMIN + ANALYTICS; @@ -51,7 +42,6 @@ public final class RESTMappingV1 { private static final String DASHBOARD = "/dashboard"; public static final String PANEL_DASHBOARD = PREFIX_PANEL + DASHBOARD; public static final String MINECRAFT_DASHBOARD = PREFIX_MINECRAFT + DASHBOARD; - // Billing & Storage private static final String BILLING = "/billing"; public static final String PANEL_BILLING = PREFIX_PANEL + BILLING; private static final String MEDIA = "/media"; @@ -59,41 +49,32 @@ public final class RESTMappingV1 { public static final String PUBLIC_MEDIA = PREFIX_PUBLIC + MEDIA; private static final String STORAGE = "/storage"; public static final String PANEL_STORAGE = PREFIX_PANEL + STORAGE; - // Knowledgebase & Homepage private static final String KNOWLEDGEBASE = "/knowledgebase"; public static final String PANEL_KNOWLEDGEBASE = PREFIX_PANEL + KNOWLEDGEBASE; public static final String PUBLIC_KNOWLEDGEBASE = PREFIX_PUBLIC + KNOWLEDGEBASE; private static final String HOMEPAGE_CARDS = "/homepage-cards"; public static final String PANEL_HOMEPAGE_CARDS = PREFIX_PANEL + HOMEPAGE_CARDS; public static final String PUBLIC_HOMEPAGE_CARDS = PREFIX_PUBLIC + HOMEPAGE_CARDS; - // Webhooks private static final String WEBHOOKS = "/webhooks"; public static final String WEBHOOKS_STRIPE = V1 + WEBHOOKS + "/stripe"; - // Appeals private static final String APPEALS = "/appeals"; public static final String PANEL_APPEALS = PREFIX_PANEL + APPEALS; public static final String PUBLIC_APPEALS = PREFIX_PUBLIC + APPEALS; - // Registration private static final String REGISTRATION = "/registration"; public static final String PUBLIC_REGISTRATION = PREFIX_PUBLIC + REGISTRATION; - // Logs private static final String LOGS = "/logs"; public static final String PANEL_LOGS = PREFIX_PANEL + LOGS; - // Migration private static final String MIGRATION = "/migration"; public static final String PANEL_MIGRATION = PREFIX_PANEL + MIGRATION; public static final String MINECRAFT_MIGRATION = PREFIX_MINECRAFT + MIGRATION; - // Minecraft API endpoints private static final String PUNISHMENTS = "/punishments"; public static final String MINECRAFT_PUNISHMENTS = PREFIX_MINECRAFT + PUNISHMENTS; - // Public punishment endpoint private static final String PUNISHMENT = "/punishment"; public static final String PUBLIC_PUNISHMENT = PREFIX_PUBLIC + PUNISHMENT; private static final String NOTIFICATIONS = "/notifications"; public static final String MINECRAFT_NOTIFICATIONS = PREFIX_MINECRAFT + NOTIFICATIONS; private static final String REPORTS = "/reports"; public static final String MINECRAFT_REPORTS = PREFIX_MINECRAFT + REPORTS; - // Evidence upload (public, token-based auth) private static final String EVIDENCE_UPLOAD = "/evidence-upload"; public static final String PUBLIC_EVIDENCE_UPLOAD = PREFIX_PUBLIC + EVIDENCE_UPLOAD; private static final String SERVERS = "/servers"; @@ -115,11 +96,13 @@ public final class RESTMappingV1 { private static final String REALTIME = "/realtime"; public static final String REALTIME_WS = V1 + REALTIME + "/ws"; - // Replays private static final String REPLAYS = "/replays"; public static final String MINECRAFT_REPLAYS = PREFIX_MINECRAFT + REPLAYS; public static final String PUBLIC_REPLAYS = PREFIX_PUBLIC + REPLAYS; public static final String PANEL_REPLAYS = PREFIX_PANEL + REPLAYS; public static final String REPLAY_LITE_REPLAYS = PREFIX_REPLAY_LITE + REPLAYS; - public static final String PUBLIC_REPLAY_LITE_REPLAYS = PREFIX_PUBLIC + "/replay-lite" + REPLAYS; + public static final String PUBLIC_REPLAY_LITE_REPLAYS = PUBLIC_REPLAY_LITE + REPLAYS; + + private RESTMappingV1() { + } } diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV2.java b/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV2.java index b165de4..8ea18f8 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV2.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV2.java @@ -5,4 +5,7 @@ public final class RESTMappingV2 { public static final String PREFIX_MINECRAFT = V2 + "/minecraft"; private static final String PLAYERS = "/players"; public static final String MINECRAFT_PLAYERS = PREFIX_MINECRAFT + PLAYERS; + + private RESTMappingV2() { + } } diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RESTSecurityRole.java b/src/main/java/gg/modl/backend/infrastructure/rest/RESTSecurityRole.java index 7efe416..b155165 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RESTSecurityRole.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RESTSecurityRole.java @@ -1,8 +1,11 @@ -package gg.modl.backend.infrastructure.rest; - -public final class RESTSecurityRole { - public static final String USER = "USER"; - public static final String ADMIN = "ADMIN"; - public static final String MINECRAFT = "MINECRAFT"; - public static final String ADMIN_SESSION_COOKIE = "modl.admin.session"; -} +package gg.modl.backend.infrastructure.rest; + +public final class RESTSecurityRole { + public static final String USER = "USER"; + public static final String ADMIN = "ADMIN"; + public static final String MINECRAFT = "MINECRAFT"; + public static final String ADMIN_SESSION_COOKIE = "modl.admin.session"; + + private RESTSecurityRole() { + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RequestAttribute.java b/src/main/java/gg/modl/backend/infrastructure/rest/RequestAttribute.java index c69ba32..eb2e4f3 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RequestAttribute.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RequestAttribute.java @@ -1,7 +1,10 @@ -package gg.modl.backend.infrastructure.rest; - +package gg.modl.backend.infrastructure.rest; + public final class RequestAttribute { public static final String SERVER = "gg.modl.attribute.server"; public static final String SERVER_DOMAIN = "gg.modl.attribute.serverDomain"; public static final String SESSION = "gg.modl.attribute.session"; + + private RequestAttribute() { + } } diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RequestHeader.java b/src/main/java/gg/modl/backend/infrastructure/rest/RequestHeader.java index 441a366..fd58840 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RequestHeader.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RequestHeader.java @@ -1,7 +1,10 @@ -package gg.modl.backend.infrastructure.rest; - +package gg.modl.backend.infrastructure.rest; + public final class RequestHeader { public static final String SERVER_DOMAIN = "X-Server-Domain"; public static final String FORWARDED_HOST = "X-Forwarded-Host"; public static final String API_KEY = "X-API-Key"; + + private RequestHeader() { + } } diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RequestUtil.java b/src/main/java/gg/modl/backend/infrastructure/rest/RequestUtil.java index f4fe46a..eb492ff 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RequestUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RequestUtil.java @@ -1,209 +1,204 @@ -package gg.modl.backend.infrastructure.rest; - -import gg.modl.backend.auth.session.AuthSessionData; -import gg.modl.backend.server.data.Server; -import jakarta.servlet.http.HttpServletRequest; -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.security.SecureRandom; -import java.util.Base64; -import java.util.Objects; -import java.util.regex.Pattern; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -public final class RequestUtil { - private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RequestUtil.class); - private static volatile boolean warnedAboutProxy = false; - private static final SecureRandom RANDOM = new SecureRandom(); - private static final boolean TRUST_PROXY_HEADERS = resolveTrustProxyHeaders(); - private static final String CLIENT_IP_HEADER = resolveClientIpHeaderName(); - private static final int TRUSTED_PROXY_COUNT = resolveTrustedProxyCount(); - private static final Pattern IPV6_LITERAL_CHARS = Pattern.compile("[0-9A-Fa-f:.%]+"); - - private static boolean resolveTrustProxyHeaders() { - String value = System.getProperty("modl.trust-proxy-headers"); - if (value == null) { - value = System.getProperty("MODL_TRUST_PROXY_HEADERS"); - } - if (value == null) { - value = System.getenv("MODL_TRUST_PROXY_HEADERS"); - } - return Boolean.parseBoolean(value); - } - - private static String resolveClientIpHeaderName() { - String value = System.getProperty("modl.client-ip-header"); - if (value == null) { - value = System.getProperty("MODL_CLIENT_IP_HEADER"); - } - if (value == null) { - value = System.getenv("MODL_CLIENT_IP_HEADER"); - } - if (value == null || value.isBlank()) { - return "CF-Connecting-IP"; - } - return value.trim(); - } - - private static int resolveTrustedProxyCount() { - String value = System.getProperty("modl.trusted-proxy-count"); - if (value == null) { - value = System.getProperty("MODL_TRUSTED_PROXY_COUNT"); - } - if (value == null) { - value = System.getenv("MODL_TRUSTED_PROXY_COUNT"); - } - if (value == null || value.isBlank()) { - return 1; - } - try { - return Math.max(0, Integer.parseInt(value.trim())); - } catch (NumberFormatException e) { - return 1; - } - } - - @NotNull - public static Server getRequestServer(HttpServletRequest request) { - return Objects.requireNonNull((Server) request.getAttribute(RequestAttribute.SERVER), "Server should not be null if being called from panel route!"); - } - - @Nullable - public static String getSessionEmail(HttpServletRequest request) { - AuthSessionData session = getSession(request); - return session != null ? session.getEmail() : null; - } - - @Nullable - public static AuthSessionData getSession(HttpServletRequest request) { - return (AuthSessionData) request.getAttribute(RequestAttribute.SESSION); - } - - @Nullable - public static String getActingStaffId(HttpServletRequest request) { - String actingStaffId = request.getHeader("X-Acting-Staff-Id"); - if (actingStaffId == null) { - return null; - } - String trimmed = actingStaffId.trim(); - return trimmed.isEmpty() ? null : trimmed; - } - - @NotNull - public static String getCurrentUsername(HttpServletRequest request) { - AuthSessionData session = getSession(request); - if (session == null || session.getEmail() == null) { - return "Unknown"; - } - // Use email as username fallback - the service layer should resolve actual username if needed - return session.getEmail(); - } - - public static String getClientIp(HttpServletRequest request) { - if (!TRUST_PROXY_HEADERS) { - warnAboutUntrustedForwardingHeaderOnce(request); - return request.getRemoteAddr(); - } - - String authoritative = request.getHeader(CLIENT_IP_HEADER); - if (authoritative != null && isValidIp(authoritative.trim())) { - return authoritative.trim(); - } - - String forwarded = firstValidIp(request.getHeader("X-Forwarded-For"), TRUSTED_PROXY_COUNT); - if (forwarded != null) { - return forwarded; - } - - String realIp = request.getHeader("X-Real-IP"); - if (realIp != null && isValidIp(realIp.trim())) { - return realIp.trim(); - } - - return request.getRemoteAddr(); - } - - private static void warnAboutUntrustedForwardingHeaderOnce(HttpServletRequest request) { - if (warnedAboutProxy) { - return; - } - String xForwardedFor = request.getHeader("X-Forwarded-For"); - if (xForwardedFor == null || xForwardedFor.isEmpty()) { - return; - } - log.warn("Request has X-Forwarded-For header ({}) but MODL_TRUST_PROXY_HEADERS is not set. " - + "Client IP will be reported as {}. Set MODL_TRUST_PROXY_HEADERS=true if running behind a proxy.", - xForwardedFor, request.getRemoteAddr()); - warnedAboutProxy = true; - } - - static String firstValidIp(String headerValue, int trustedProxyCount) { - if (headerValue == null || headerValue.isEmpty()) { - return null; - } - String[] entries = headerValue.split(","); - int index = entries.length - trustedProxyCount; - if (index < 0 || index >= entries.length) { - return null; - } - String candidate = entries[index].trim(); - return isValidIp(candidate) ? candidate : null; - } - - private static boolean isValidIp(String value) { - if (value == null) { - return false; - } - String candidate = value.trim(); - if (candidate.isEmpty()) { - return false; - } - if (candidate.length() > 1 && candidate.charAt(0) == '[' && candidate.charAt(candidate.length() - 1) == ']') { - candidate = candidate.substring(1, candidate.length() - 1); - } - if (candidate.indexOf(':') < 0) { - return isIpv4Literal(candidate); - } - if (!IPV6_LITERAL_CHARS.matcher(candidate).matches()) { - return false; - } - try { - InetAddress.getByName(candidate); - return true; - } catch (UnknownHostException e) { - return false; - } - } - - private static boolean isIpv4Literal(String value) { - String[] octets = value.split("\\.", -1); - if (octets.length != 4) { - return false; - } - for (String octet : octets) { - if (octet.isEmpty() || octet.length() > 3) { - return false; - } - for (int i = 0; i < octet.length(); i++) { - if (!Character.isDigit(octet.charAt(i))) { - return false; - } - } - if (Integer.parseInt(octet) > 255) { - return false; - } - } - return true; - } - - public static boolean trustsProxyHeaders() { - return TRUST_PROXY_HEADERS; - } - - public static String generateSecureToken(int byteLength) { - byte[] bytes = new byte[byteLength]; - RANDOM.nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - } -} +package gg.modl.backend.infrastructure.rest; + +import gg.modl.backend.auth.session.AuthSessionData; +import gg.modl.backend.server.data.Server; +import jakarta.servlet.http.HttpServletRequest; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Objects; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Slf4j +public final class RequestUtil { + private static volatile boolean warnedAboutProxy = false; + private static final SecureRandom RANDOM = new SecureRandom(); + private static final boolean TRUST_PROXY_HEADERS = resolveTrustProxyHeaders(); + private static final String CLIENT_IP_HEADER = resolveClientIpHeaderName(); + private static final int TRUSTED_PROXY_COUNT = resolveTrustedProxyCount(); + private static final Pattern IPV6_LITERAL_CHARS = Pattern.compile("[0-9A-Fa-f:.%]+"); + + private RequestUtil() { + } + + private static String resolveConfig(String propertyKey, String envKey) { + String value = System.getProperty(propertyKey); + if (value == null) { + value = System.getProperty(envKey); + } + if (value == null) { + value = System.getenv(envKey); + } + return value; + } + + private static boolean resolveTrustProxyHeaders() { + return Boolean.parseBoolean(resolveConfig("modl.trust-proxy-headers", "MODL_TRUST_PROXY_HEADERS")); + } + + private static String resolveClientIpHeaderName() { + String value = resolveConfig("modl.client-ip-header", "MODL_CLIENT_IP_HEADER"); + if (value == null || value.isBlank()) { + return "CF-Connecting-IP"; + } + return value.trim(); + } + + private static int resolveTrustedProxyCount() { + String value = resolveConfig("modl.trusted-proxy-count", "MODL_TRUSTED_PROXY_COUNT"); + if (value == null || value.isBlank()) { + return 1; + } + try { + return Math.max(0, Integer.parseInt(value.trim())); + } catch (NumberFormatException e) { + return 1; + } + } + + @NotNull + public static Server getRequestServer(HttpServletRequest request) { + return Objects.requireNonNull((Server) request.getAttribute(RequestAttribute.SERVER), "Server should not be null if being called from panel route!"); + } + + @Nullable + public static String getSessionEmail(HttpServletRequest request) { + AuthSessionData session = getSession(request); + return session != null ? session.getEmail() : null; + } + + @Nullable + public static AuthSessionData getSession(HttpServletRequest request) { + return (AuthSessionData) request.getAttribute(RequestAttribute.SESSION); + } + + @Nullable + public static String getActingStaffId(HttpServletRequest request) { + String actingStaffId = request.getHeader("X-Acting-Staff-Id"); + if (actingStaffId == null) { + return null; + } + String trimmed = actingStaffId.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + @NotNull + public static String getCurrentUsername(HttpServletRequest request) { + AuthSessionData session = getSession(request); + if (session == null || session.getEmail() == null) { + return "Unknown"; + } + return session.getEmail(); + } + + public static String getClientIp(HttpServletRequest request) { + if (!TRUST_PROXY_HEADERS) { + warnAboutUntrustedForwardingHeaderOnce(request); + return request.getRemoteAddr(); + } + + String authoritative = request.getHeader(CLIENT_IP_HEADER); + if (authoritative != null && isValidIp(authoritative.trim())) { + return authoritative.trim(); + } + + String forwarded = firstValidIp(request.getHeader("X-Forwarded-For"), TRUSTED_PROXY_COUNT); + if (forwarded != null) { + return forwarded; + } + + String realIp = request.getHeader("X-Real-IP"); + if (realIp != null && isValidIp(realIp.trim())) { + return realIp.trim(); + } + + return request.getRemoteAddr(); + } + + private static void warnAboutUntrustedForwardingHeaderOnce(HttpServletRequest request) { + if (warnedAboutProxy) { + return; + } + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor == null || xForwardedFor.isEmpty()) { + return; + } + log.warn("Request has X-Forwarded-For header ({}) but MODL_TRUST_PROXY_HEADERS is not set. " + + "Client IP will be reported as {}. Set MODL_TRUST_PROXY_HEADERS=true if running behind a proxy.", + xForwardedFor, request.getRemoteAddr()); + warnedAboutProxy = true; + } + + static String firstValidIp(String headerValue, int trustedProxyCount) { + if (headerValue == null || headerValue.isEmpty()) { + return null; + } + String[] entries = headerValue.split(","); + int index = entries.length - trustedProxyCount; + if (index < 0 || index >= entries.length) { + return null; + } + String candidate = entries[index].trim(); + return isValidIp(candidate) ? candidate : null; + } + + private static boolean isValidIp(String value) { + if (value == null) { + return false; + } + String candidate = value.trim(); + if (candidate.isEmpty()) { + return false; + } + if (candidate.length() > 1 && candidate.charAt(0) == '[' && candidate.charAt(candidate.length() - 1) == ']') { + candidate = candidate.substring(1, candidate.length() - 1); + } + if (candidate.indexOf(':') < 0) { + return isIpv4Literal(candidate); + } + if (!IPV6_LITERAL_CHARS.matcher(candidate).matches()) { + return false; + } + try { + InetAddress.getByName(candidate); + return true; + } catch (UnknownHostException e) { + return false; + } + } + + private static boolean isIpv4Literal(String value) { + String[] octets = value.split("\\.", -1); + if (octets.length != 4) { + return false; + } + for (String octet : octets) { + if (octet.isEmpty() || octet.length() > 3) { + return false; + } + for (int i = 0; i < octet.length(); i++) { + if (!Character.isDigit(octet.charAt(i))) { + return false; + } + } + if (Integer.parseInt(octet) > 255) { + return false; + } + } + return true; + } + + public static boolean trustsProxyHeaders() { + return TRUST_PROXY_HEADERS; + } + + public static String generateSecureToken(int byteLength) { + byte[] bytes = new byte[byteLength]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RouteGroups.java b/src/main/java/gg/modl/backend/infrastructure/rest/RouteGroups.java new file mode 100644 index 0000000..0ce84b8 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RouteGroups.java @@ -0,0 +1,81 @@ +package gg.modl.backend.infrastructure.rest; + +public final class RouteGroups { + + private RouteGroups() { + } + + public static boolean isPanelArea(String path) { + return matchesExactOrChild(path, RESTMappingV1.PREFIX_PANEL); + } + + public static boolean isAdminArea(String path) { + return matchesExactOrChild(path, RESTMappingV1.PREFIX_ADMIN); + } + + public static boolean isPanelAuthArea(String path) { + return matchesExactOrChild(path, RESTMappingV1.PANEL_AUTH); + } + + public static boolean isPanelPrefix(String path) { + return matchesPrefix(path, RESTMappingV1.PREFIX_PANEL); + } + + public static boolean isMinecraftApiPrefix(String path) { + return matchesPrefix(path, RESTMappingV1.PREFIX_MINECRAFT) + || matchesPrefix(path, RESTMappingV2.PREFIX_MINECRAFT) + || matchesPrefix(path, RESTMappingV3.PREFIX_MINECRAFT); + } + + public static boolean isMinecraftOrReplayLitePrefix(String path) { + return isMinecraftApiPrefix(path) || matchesPrefix(path, RESTMappingV1.PREFIX_REPLAY_LITE); + } + + public static boolean isVersion1Prefix(String path) { + return matchesPrefix(path, RESTMappingV1.V1); + } + + public static boolean isVersion2MinecraftPrefix(String path) { + return matchesPrefix(path, RESTMappingV2.PREFIX_MINECRAFT); + } + + public static boolean isVersion3Prefix(String path) { + return matchesPrefix(path, RESTMappingV3.PREFIX); + } + + public static boolean isPanelChild(String path) { + return matchesChild(path, RESTMappingV1.PREFIX_PANEL); + } + + public static boolean isPublicChild(String path) { + return matchesChild(path, RESTMappingV1.PREFIX_PUBLIC); + } + + public static boolean isPublicReplayLiteChild(String path) { + return matchesChild(path, RESTMappingV1.PUBLIC_REPLAY_LITE); + } + + public static boolean isReplayLiteChild(String path) { + return matchesChild(path, RESTMappingV1.PREFIX_REPLAY_LITE); + } + + public static boolean isAdminChild(String path) { + return matchesChild(path, RESTMappingV1.PREFIX_ADMIN); + } + + public static boolean isAdminAuthChild(String path) { + return matchesChild(path, RESTMappingV1.ADMIN_AUTH); + } + + private static boolean matchesExactOrChild(String path, String base) { + return path != null && (path.equals(base) || path.startsWith(base + "/")); + } + + private static boolean matchesChild(String path, String base) { + return path != null && path.startsWith(base + "/"); + } + + private static boolean matchesPrefix(String path, String base) { + return path != null && path.startsWith(base); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/scheduling/KeysetDrainer.java b/src/main/java/gg/modl/backend/infrastructure/scheduling/KeysetDrainer.java new file mode 100644 index 0000000..539dda3 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/scheduling/KeysetDrainer.java @@ -0,0 +1,40 @@ +package gg.modl.backend.infrastructure.scheduling; + +import java.util.List; +import java.util.function.Function; +import java.util.function.ToLongFunction; + +public final class KeysetDrainer { + + private KeysetDrainer() { + } + + public static long drain( + int maxPages, + int pageSize, + PageFinder finder, + Function, C> cursorExtractor, + ToLongFunction> pageConsumer + ) { + long total = 0; + C cursor = null; + int pages = 0; + while (pages++ < maxPages) { + List page = finder.find(cursor, pageSize); + if (page.isEmpty()) { + break; + } + total += pageConsumer.applyAsLong(page); + cursor = cursorExtractor.apply(page); + if (page.size() < pageSize) { + break; + } + } + return total; + } + + @FunctionalInterface + public interface PageFinder { + List find(C cursor, int limit); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileConfiguration.java b/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileConfiguration.java index 2f704c4..9dc0932 100644 --- a/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileConfiguration.java +++ b/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileConfiguration.java @@ -1,20 +1,20 @@ -package gg.modl.backend.infrastructure.turnstile; - -import java.util.ArrayList; -import java.util.List; -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; -import org.springframework.validation.annotation.Validated; - -@Getter -@Setter -@Validated -@Configuration -@ConfigurationProperties(prefix = "modl.turnstile") -public class TurnstileConfiguration { - private String secretKey; - private String verifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; - private List expectedHostnames = new ArrayList<>(); -} +package gg.modl.backend.infrastructure.turnstile; + +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@Configuration +@ConfigurationProperties(prefix = "modl.turnstile") +public class TurnstileConfiguration { + private String secretKey; + private String verifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + private List expectedHostnames = new ArrayList<>(); +} diff --git a/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileService.java b/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileService.java index 22489f6..e60e704 100644 --- a/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileService.java +++ b/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileService.java @@ -1,137 +1,137 @@ -package gg.modl.backend.infrastructure.turnstile; - -import com.fasterxml.jackson.annotation.JsonProperty; -import gg.modl.backend.infrastructure.config.ModlProperties; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; - -@Service -@RequiredArgsConstructor -@Slf4j -public class TurnstileService { - private final RestTemplate restTemplate; - private final TurnstileConfiguration config; - private final ModlProperties modlProperties; - private volatile boolean warnedAboutMissingHostnames = false; - - public boolean validateToken(String token, String remoteIp) { - if (config.getSecretKey() == null || config.getSecretKey().isBlank()) { - if (modlProperties.isDevelopmentMode()) { - log.warn("Turnstile secret key not configured in development mode, skipping validation"); - return true; - } - - log.error("Turnstile secret key not configured, rejecting request"); - return false; - } - - try { - final HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - - final MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("secret", config.getSecretKey()); - body.add("response", token); - if (remoteIp != null && !remoteIp.isEmpty()) { - body.add("remoteip", remoteIp); - } - - final HttpEntity> request = new HttpEntity<>(body, headers); - final TurnstileResponse response = restTemplate.postForObject( - config.getVerifyUrl(), - request, - TurnstileResponse.class - ); - - if (response == null) { - log.error("Turnstile validation returned null response"); - return false; - } - - if (!response.success()) { - log.warn("Turnstile validation rejected token, error-codes={}", response.errorCodes()); - return false; - } - - return isHostnameAllowed(response.hostname()); - } catch (Exception e) { - log.error("Error validating Turnstile token", e); - return false; - } - } - - private boolean isHostnameAllowed(String host) { - final List allowed = resolveAllowedHostnames(); - if (allowed.isEmpty()) { - if (modlProperties.isDevelopmentMode()) { - return true; - } - warnMissingHostnamePinningOnce(); - return false; - } - if (modlProperties.isDevelopmentMode()) { - return true; - } - final boolean hostAllowed = host != null - && allowed.stream().anyMatch(h -> h.equalsIgnoreCase(host.trim())); - if (!hostAllowed) { - log.warn("Turnstile token solved on unexpected hostname '{}', expected one of {}", host, allowed); - return false; - } - return true; - } - - private List resolveAllowedHostnames() { - final List explicit = normalizeHostnames(config.getExpectedHostnames()); - if (!explicit.isEmpty()) { - return explicit; - } - return defaultHostnamesFromAppDomain(); - } - - private List normalizeHostnames(List hostnames) { - if (hostnames == null) { - return List.of(); - } - return hostnames.stream() - .filter(h -> h != null && !h.isBlank()) - .map(String::trim) - .toList(); - } - - private List defaultHostnamesFromAppDomain() { - final String appDomain = modlProperties.getAppDomain(); - if (appDomain != null && !appDomain.isBlank()) { - return List.of(appDomain.trim()); - } - final String domain = modlProperties.getDomain(); - if (domain != null && !domain.isBlank()) { - return List.of(domain.trim()); - } - return List.of(); - } - - private void warnMissingHostnamePinningOnce() { - if (warnedAboutMissingHostnames) { - return; - } - warnedAboutMissingHostnames = true; - log.error("Turnstile hostname pinning is not configured and no app domain is available; rejecting token. " - + "Set CLOUDFLARE_TURNSTILE_HOSTNAMES (modl.turnstile.expected-hostnames) to the hostname(s) serving the widget."); - } - - public record TurnstileResponse( - boolean success, - @JsonProperty("challenge_ts") String challengeTs, - String hostname, - @JsonProperty("error-codes") List errorCodes - ) {} -} +package gg.modl.backend.infrastructure.turnstile; + +import com.fasterxml.jackson.annotation.JsonProperty; +import gg.modl.backend.infrastructure.config.ModlProperties; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TurnstileService { + private final RestTemplate restTemplate; + private final TurnstileConfiguration config; + private final ModlProperties modlProperties; + private volatile boolean warnedAboutMissingHostnames = false; + + public boolean validateToken(String token, String remoteIp) { + if (config.getSecretKey() == null || config.getSecretKey().isBlank()) { + if (modlProperties.isDevelopmentMode()) { + log.warn("Turnstile secret key not configured in development mode, skipping validation"); + return true; + } + + log.error("Turnstile secret key not configured, rejecting request"); + return false; + } + + try { + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + final MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("secret", config.getSecretKey()); + body.add("response", token); + if (remoteIp != null && !remoteIp.isEmpty()) { + body.add("remoteip", remoteIp); + } + + final HttpEntity> request = new HttpEntity<>(body, headers); + final TurnstileResponse response = restTemplate.postForObject( + config.getVerifyUrl(), + request, + TurnstileResponse.class + ); + + if (response == null) { + log.error("Turnstile validation returned null response"); + return false; + } + + if (!response.success()) { + log.warn("Turnstile validation rejected token, error-codes={}", response.errorCodes()); + return false; + } + + return isHostnameAllowed(response.hostname()); + } catch (Exception e) { + log.error("Error validating Turnstile token", e); + return false; + } + } + + private boolean isHostnameAllowed(String host) { + final List allowed = resolveAllowedHostnames(); + if (allowed.isEmpty()) { + if (modlProperties.isDevelopmentMode()) { + return true; + } + warnMissingHostnamePinningOnce(); + return false; + } + if (modlProperties.isDevelopmentMode()) { + return true; + } + final boolean hostAllowed = host != null + && allowed.stream().anyMatch(h -> h.equalsIgnoreCase(host.trim())); + if (!hostAllowed) { + log.warn("Turnstile token solved on unexpected hostname '{}', expected one of {}", host, allowed); + return false; + } + return true; + } + + private List resolveAllowedHostnames() { + final List explicit = normalizeHostnames(config.getExpectedHostnames()); + if (!explicit.isEmpty()) { + return explicit; + } + return defaultHostnamesFromAppDomain(); + } + + private List normalizeHostnames(List hostnames) { + if (hostnames == null) { + return List.of(); + } + return hostnames.stream() + .filter(h -> h != null && !h.isBlank()) + .map(String::trim) + .toList(); + } + + private List defaultHostnamesFromAppDomain() { + final String appDomain = modlProperties.getAppDomain(); + if (appDomain != null && !appDomain.isBlank()) { + return List.of(appDomain.trim()); + } + final String domain = modlProperties.getDomain(); + if (domain != null && !domain.isBlank()) { + return List.of(domain.trim()); + } + return List.of(); + } + + private void warnMissingHostnamePinningOnce() { + if (warnedAboutMissingHostnames) { + return; + } + warnedAboutMissingHostnames = true; + log.error("Turnstile hostname pinning is not configured and no app domain is available; rejecting token. " + + "Set CLOUDFLARE_TURNSTILE_HOSTNAMES (modl.turnstile.expected-hostnames) to the hostname(s) serving the widget."); + } + + public record TurnstileResponse( + boolean success, + @JsonProperty("challenge_ts") String challengeTs, + String hostname, + @JsonProperty("error-codes") List errorCodes + ) {} +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/ByteFormatUtil.java b/src/main/java/gg/modl/backend/infrastructure/util/ByteFormatUtil.java index 7af1ae6..94d2703 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/ByteFormatUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/ByteFormatUtil.java @@ -1,33 +1,33 @@ -package gg.modl.backend.infrastructure.util; - -import lombok.experimental.UtilityClass; - -@UtilityClass -public class ByteFormatUtil { - - private static final long KB = 1024L; - private static final long MB = KB * 1024; - private static final long GB = MB * 1024; - - public String format(long bytes) { - if (bytes < KB) { - return bytes + " B"; - } else if (bytes < MB) { - return String.format("%.2f KB", bytes / (double) KB); - } else if (bytes < GB) { - return String.format("%.2f MB", bytes / (double) MB); - } else { - return String.format("%.2f GB", bytes / (double) GB); - } - } - - public String formatCompact(long bytes) { - if (bytes < MB) { - return String.format("%.0f KB", bytes / (double) KB); - } else if (bytes < GB) { - return String.format("%.0f MB", bytes / (double) MB); - } else { - return String.format("%.1f GB", bytes / (double) GB); - } - } -} +package gg.modl.backend.infrastructure.util; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class ByteFormatUtil { + + private static final long KB = 1024L; + private static final long MB = KB * 1024; + private static final long GB = MB * 1024; + + public String format(long bytes) { + if (bytes < KB) { + return bytes + " B"; + } else if (bytes < MB) { + return String.format("%.2f KB", bytes / (double) KB); + } else if (bytes < GB) { + return String.format("%.2f MB", bytes / (double) MB); + } else { + return String.format("%.2f GB", bytes / (double) GB); + } + } + + public String formatCompact(long bytes) { + if (bytes < MB) { + return String.format("%.0f KB", bytes / (double) KB); + } else if (bytes < GB) { + return String.format("%.0f MB", bytes / (double) MB); + } else { + return String.format("%.1f GB", bytes / (double) GB); + } + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/CookieUtil.java b/src/main/java/gg/modl/backend/infrastructure/util/CookieUtil.java index e73c094..bfcb8bd 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/CookieUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/CookieUtil.java @@ -1,72 +1,73 @@ -package gg.modl.backend.infrastructure.util; - -import gg.modl.backend.auth.AuthConfiguration; -import jakarta.servlet.http.Cookie; -import java.util.ArrayList; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class CookieUtil { - - private final AuthConfiguration authConfiguration; - - public Cookie createSessionCookie(String sessionId) { - return createSessionCookie( - authConfiguration.getSessionCookieName(), - sessionId, - authConfiguration.getSessionDurationSeconds() - ); - } - - public Cookie createSessionCookie(String cookieName, String sessionId, long maxAgeSeconds) { - Cookie cookie = new Cookie(cookieName, sessionId); - cookie.setHttpOnly(true); - cookie.setSecure(authConfiguration.isCookieSecure()); - cookie.setPath("/"); - cookie.setMaxAge((int) maxAgeSeconds); - cookie.setAttribute("SameSite", authConfiguration.isDevelopmentMode() ? "Lax" : "Strict"); - return cookie; - } - - public List createExpiredSessionCookies() { - return createExpiredSessionCookies(authConfiguration.getSessionCookieName()); - } - - public List createExpiredSessionCookies(String cookieName) { - List cookies = new ArrayList<>(); - cookies.add(createExpiredCookie(cookieName, null)); - - String domain = getConfiguredCookieDomain(); - if (domain != null) { - cookies.add(createExpiredCookie(cookieName, domain)); - if (!domain.startsWith(".")) { - cookies.add(createExpiredCookie(cookieName, "." + domain)); - } else if (domain.length() > 1) { - cookies.add(createExpiredCookie(cookieName, domain.substring(1))); - } - } - - return cookies; - } - - private Cookie createExpiredCookie(String cookieName, String domain) { - Cookie cookie = new Cookie(cookieName, ""); - cookie.setHttpOnly(true); - cookie.setSecure(authConfiguration.isCookieSecure()); - cookie.setPath("/"); - cookie.setMaxAge(0); - cookie.setAttribute("SameSite", authConfiguration.isDevelopmentMode() ? "Lax" : "Strict"); - if (domain != null) { - cookie.setDomain(domain); - } - return cookie; - } - - private String getConfiguredCookieDomain() { - String domain = authConfiguration.getCookieDomain(); - return (domain == null || domain.isBlank()) ? null : domain; - } -} +package gg.modl.backend.infrastructure.util; + +import gg.modl.backend.auth.AuthConfiguration; +import jakarta.servlet.http.Cookie; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CookieUtil { + + private final AuthConfiguration authConfiguration; + + public Cookie createSessionCookie(String sessionId) { + return createSessionCookie( + authConfiguration.getSessionCookieName(), + sessionId, + authConfiguration.getSessionDurationSeconds() + ); + } + + public Cookie createSessionCookie(String cookieName, String sessionId, long maxAgeSeconds) { + Cookie cookie = baseCookie(cookieName, sessionId); + cookie.setMaxAge((int) maxAgeSeconds); + return cookie; + } + + public List createExpiredSessionCookies() { + return createExpiredSessionCookies(authConfiguration.getSessionCookieName()); + } + + public List createExpiredSessionCookies(String cookieName) { + List cookies = new ArrayList<>(); + cookies.add(createExpiredCookie(cookieName, null)); + + String domain = getConfiguredCookieDomain(); + if (domain != null) { + cookies.add(createExpiredCookie(cookieName, domain)); + if (!domain.startsWith(".")) { + cookies.add(createExpiredCookie(cookieName, "." + domain)); + } else if (domain.length() > 1) { + cookies.add(createExpiredCookie(cookieName, domain.substring(1))); + } + } + + return cookies; + } + + private Cookie createExpiredCookie(String cookieName, String domain) { + Cookie cookie = baseCookie(cookieName, ""); + cookie.setMaxAge(0); + if (domain != null) { + cookie.setDomain(domain); + } + return cookie; + } + + private Cookie baseCookie(String name, String value) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(true); + cookie.setSecure(authConfiguration.isCookieSecure()); + cookie.setPath("/"); + cookie.setAttribute("SameSite", authConfiguration.isDevelopmentMode() ? "Lax" : "Strict"); + return cookie; + } + + private String getConfiguredCookieDomain() { + String domain = authConfiguration.getCookieDomain(); + return (domain == null || domain.isBlank()) ? null : domain; + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/DateRangeUtil.java b/src/main/java/gg/modl/backend/infrastructure/util/DateRangeUtil.java index 4126b71..27f826d 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/DateRangeUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/DateRangeUtil.java @@ -1,53 +1,53 @@ -package gg.modl.backend.infrastructure.util; - -import gg.modl.backend.infrastructure.exception.ValidationException; -import java.util.Date; -import lombok.experimental.UtilityClass; - -@UtilityClass -public class DateRangeUtil { - - private static final long DAY_MS = 24 * 60 * 60 * 1000L; - - public Date getStartDate(String period) { - if (period == null || "all".equals(period)) { - return null; - } - long now = System.currentTimeMillis(); - return switch (period) { - case "7d" -> new Date(now - 7 * DAY_MS); - case "90d" -> new Date(now - 90 * DAY_MS); - case "1y" -> new Date(now - 365 * DAY_MS); - default -> new Date(now - 30 * DAY_MS); - }; - } - - public Date daysAgo(int days) { - return new Date(System.currentTimeMillis() - days * DAY_MS); - } - - public Date parseEpochMillis(String value) { - if (value == null) { - return null; - } - try { - return new Date(Long.parseLong(value.trim())); - } catch (NumberFormatException exception) { - throw new ValidationException("Invalid date parameter; expected epoch milliseconds."); - } - } - - public int resolveRangeDays(String range) { - String normalized = (range == null || range.isBlank()) ? "30d" : range; - return switch (normalized) { - case "7d" -> 7; - case "90d" -> 90; - case "365d", "1y" -> 365; - default -> 30; - }; - } - - public String normalizeAllFilter(String value) { - return "all".equalsIgnoreCase(value) ? null : value; - } -} +package gg.modl.backend.infrastructure.util; + +import gg.modl.backend.infrastructure.exception.ValidationException; +import java.util.Date; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class DateRangeUtil { + + private static final long DAY_MS = 24 * 60 * 60 * 1000L; + + public Date getStartDate(String period) { + if (period == null || "all".equals(period)) { + return null; + } + long now = System.currentTimeMillis(); + return switch (period) { + case "7d" -> new Date(now - 7 * DAY_MS); + case "90d" -> new Date(now - 90 * DAY_MS); + case "1y" -> new Date(now - 365 * DAY_MS); + default -> new Date(now - 30 * DAY_MS); + }; + } + + public Date daysAgo(int days) { + return new Date(System.currentTimeMillis() - days * DAY_MS); + } + + public Date parseEpochMillis(String value) { + if (value == null) { + return null; + } + try { + return new Date(Long.parseLong(value.trim())); + } catch (NumberFormatException exception) { + throw new ValidationException("Invalid date parameter; expected epoch milliseconds."); + } + } + + public int resolveRangeDays(String range) { + String normalized = (range == null || range.isBlank()) ? "30d" : range; + return switch (normalized) { + case "7d" -> 7; + case "90d" -> 90; + case "365d", "1y" -> 365; + default -> 30; + }; + } + + public String normalizeAllFilter(String value) { + return "all".equalsIgnoreCase(value) ? null : value; + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/DigestUtils.java b/src/main/java/gg/modl/backend/infrastructure/util/DigestUtils.java new file mode 100644 index 0000000..53e187d --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/util/DigestUtils.java @@ -0,0 +1,21 @@ +package gg.modl.backend.infrastructure.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +public final class DigestUtils { + + private DigestUtils() { + } + + public static String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm not available", exception); + } + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/HostExtractionUtil.java b/src/main/java/gg/modl/backend/infrastructure/util/HostExtractionUtil.java index 9e5e4a4..8afce98 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/HostExtractionUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/HostExtractionUtil.java @@ -28,11 +28,7 @@ public String extractHost(@Nullable String originOrDomain) { @Nullable public String normalizeServerDomain(@Nullable String serverDomain) { - String host = extractHost(serverDomain); - if (host == null || host.isBlank()) { - return null; - } - return host; + return extractHost(serverDomain); } public Set parseCommaSeparated(@Nullable String values) { diff --git a/src/main/java/gg/modl/backend/infrastructure/util/IdGenerator.java b/src/main/java/gg/modl/backend/infrastructure/util/IdGenerator.java index 0ec1bf9..8bb5708 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/IdGenerator.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/IdGenerator.java @@ -1,28 +1,26 @@ -package gg.modl.backend.infrastructure.util; - -import java.security.SecureRandom; -import java.util.Base64; -import org.springframework.stereotype.Component; - -@Component -public class IdGenerator { - private static final SecureRandom RANDOM = new SecureRandom(); - - public int nextSixDigitInt() { - return 100000 + RANDOM.nextInt(900000); - } - - public String generateToken() { - byte[] bytes = new byte[32]; - RANDOM.nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - } - - public static String generateShortId() { - StringBuilder sb = new StringBuilder(8); - for (int i = 0; i < 8; i++) { - sb.append((char) ('A' + RANDOM.nextInt(26))); - } - return sb.toString(); - } -} +package gg.modl.backend.infrastructure.util; + +import gg.modl.backend.infrastructure.rest.RequestUtil; +import java.security.SecureRandom; +import org.springframework.stereotype.Component; + +@Component +public class IdGenerator { + private static final SecureRandom RANDOM = new SecureRandom(); + + public int nextSixDigitInt() { + return 100000 + RANDOM.nextInt(900000); + } + + public String generateToken() { + return RequestUtil.generateSecureToken(32); + } + + public static String generateShortId() { + StringBuilder sb = new StringBuilder(8); + for (int i = 0; i < 8; i++) { + sb.append((char) ('A' + RANDOM.nextInt(26))); + } + return sb.toString(); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/MongoKeyUtils.java b/src/main/java/gg/modl/backend/infrastructure/util/MongoKeyUtils.java index 97eff24..5116535 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/MongoKeyUtils.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/MongoKeyUtils.java @@ -59,10 +59,6 @@ public static void validateUpdatePath(String path) { } } - /** - * Resolve a field key to a human-readable label using the provided mapping. - * Falls back to formatting the key directly if no label mapping exists. - */ public static String resolveFieldLabel(String key, Map fieldLabels) { if (fieldLabels != null && fieldLabels.containsKey(key)) { return fieldLabels.get(key); @@ -70,10 +66,6 @@ public static String resolveFieldLabel(String key, Map fieldLabe return formatFieldKey(key); } - /** - * Format a raw field key (e.g. "myField_name") into a human-readable label - * (e.g. "My Field Name"). - */ public static String formatFieldKey(String key) { if (key == null || key.isBlank()) { return key; diff --git a/src/main/java/gg/modl/backend/infrastructure/util/PaginationHelper.java b/src/main/java/gg/modl/backend/infrastructure/util/PaginationHelper.java index daf6800..00b559e 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/PaginationHelper.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/PaginationHelper.java @@ -1,38 +1,38 @@ -package gg.modl.backend.infrastructure.util; - -import java.util.List; -import lombok.experimental.UtilityClass; - -@UtilityClass -public class PaginationHelper { - - public int normalizeLimit(int limit, int maxLimit) { - return Math.min(maxLimit, Math.max(1, limit)); - } - - public PageResult paginate(List items, int page, int limit) { - int totalCount = items.size(); - int safeLimit = Math.max(0, limit); - int skip = calculateSkip(page, limit); - int from = Math.min(skip, totalCount); - int to = (int) Math.min((long) skip + safeLimit, totalCount); - List paged = items.subList(from, to); - boolean hasMore = (long) skip + safeLimit < totalCount; - return new PageResult<>(paged, totalCount, page, hasMore); - } - - public int calculateSkip(int page, int limit) { - long skip = (long) (normalizePage(page) - 1) * (long) Math.max(0, limit); - return (int) Math.min(skip, Integer.MAX_VALUE); - } - - public int normalizePage(int page) { - return Math.max(1, page); - } - - public int calculateTotalPages(long total, int limit) { - return (int) Math.ceil((double) total / limit); - } - - public record PageResult(List items, int totalCount, int page, boolean hasMore) {} -} +package gg.modl.backend.infrastructure.util; + +import java.util.List; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class PaginationHelper { + + public int normalizeLimit(int limit, int maxLimit) { + return Math.min(maxLimit, Math.max(1, limit)); + } + + public PageResult paginate(List items, int page, int limit) { + int totalCount = items.size(); + int safeLimit = Math.max(0, limit); + int skip = calculateSkip(page, limit); + int from = Math.min(skip, totalCount); + int to = (int) Math.min((long) skip + safeLimit, totalCount); + List paged = items.subList(from, to); + boolean hasMore = (long) skip + safeLimit < totalCount; + return new PageResult<>(paged, totalCount, page, hasMore); + } + + public int calculateSkip(int page, int limit) { + long skip = (long) (normalizePage(page) - 1) * (long) Math.max(0, limit); + return (int) Math.min(skip, Integer.MAX_VALUE); + } + + public int normalizePage(int page) { + return Math.max(1, page); + } + + public int calculateTotalPages(long total, int limit) { + return (int) Math.ceil((double) total / limit); + } + + public record PageResult(List items, int totalCount, int page, boolean hasMore) {} +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/SafeConvertUtil.java b/src/main/java/gg/modl/backend/infrastructure/util/SafeConvertUtil.java deleted file mode 100644 index 04fd50f..0000000 --- a/src/main/java/gg/modl/backend/infrastructure/util/SafeConvertUtil.java +++ /dev/null @@ -1,12 +0,0 @@ -package gg.modl.backend.infrastructure.util; - -import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; -import lombok.experimental.UtilityClass; -import org.jetbrains.annotations.Nullable; - -@UtilityClass -public class SafeConvertUtil { - public static int toInt(@Nullable Object value) { - return ProtoMapperSupport.intValueOrZero(value); - } -} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/UuidUtils.java b/src/main/java/gg/modl/backend/infrastructure/util/UuidUtils.java new file mode 100644 index 0000000..8a26f6f --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/util/UuidUtils.java @@ -0,0 +1,33 @@ +package gg.modl.backend.infrastructure.util; + +import java.util.Locale; +import java.util.UUID; + +public final class UuidUtils { + + private static final String UNDASHED_UUID_PATTERN = "^[0-9a-fA-F]{32}$"; + private static final String DASH_INSERT_PATTERN = "(.{8})(.{4})(.{4})(.{4})(.{12})"; + private static final String DASH_INSERT_REPLACEMENT = "$1-$2-$3-$4-$5"; + + private UuidUtils() { + } + + public static String normalize(String value) { + return value == null ? null : value.toLowerCase(Locale.ROOT); + } + + public static String dashed(String raw) { + if (raw == null) { + return null; + } + String candidate = raw.trim(); + if (candidate.matches(UNDASHED_UUID_PATTERN)) { + candidate = candidate.replaceFirst(DASH_INSERT_PATTERN, DASH_INSERT_REPLACEMENT); + } + try { + return UUID.fromString(candidate).toString(); + } catch (IllegalArgumentException invalid) { + return null; + } + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/validation/BeanValidationRunner.java b/src/main/java/gg/modl/backend/infrastructure/validation/BeanValidationRunner.java new file mode 100644 index 0000000..48cc9a4 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/validation/BeanValidationRunner.java @@ -0,0 +1,22 @@ +package gg.modl.backend.infrastructure.validation; + +import gg.modl.backend.infrastructure.exception.ValidationException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BeanValidationRunner { + + private final Validator validator; + + public void validate(T target) { + Set> violations = validator.validate(target); + if (!violations.isEmpty()) { + throw new ValidationException(violations.iterator().next().getMessage()); + } + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/validation/RegExpConstants.java b/src/main/java/gg/modl/backend/infrastructure/validation/RegExpConstants.java index 90c037b..76bed42 100644 --- a/src/main/java/gg/modl/backend/infrastructure/validation/RegExpConstants.java +++ b/src/main/java/gg/modl/backend/infrastructure/validation/RegExpConstants.java @@ -1,8 +1,9 @@ -package gg.modl.backend.infrastructure.validation; - -public final class RegExpConstants { - public static final String UUID = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; - public static final String MINECRAFT_USERNAME = "^[a-zA-Z0-9_.]{2,16}$"; - @Deprecated - public static final String IP = "^([0-9a-fA-F.:]+)$"; -} +package gg.modl.backend.infrastructure.validation; + +public final class RegExpConstants { + public static final String UUID = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + public static final String MINECRAFT_USERNAME = "^[a-zA-Z0-9_.]{2,16}$"; + + private RegExpConstants() { + } +} diff --git a/src/main/java/gg/modl/backend/knowledgebase/controller/KnowledgebaseProtoMapper.java b/src/main/java/gg/modl/backend/knowledgebase/controller/KnowledgebaseProtoMapper.java index 1795424..ae77bef 100644 --- a/src/main/java/gg/modl/backend/knowledgebase/controller/KnowledgebaseProtoMapper.java +++ b/src/main/java/gg/modl/backend/knowledgebase/controller/KnowledgebaseProtoMapper.java @@ -1,6 +1,7 @@ package gg.modl.backend.knowledgebase.controller; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.addAll; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.nullToEmpty; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalString; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toTimestamp; @@ -17,6 +18,7 @@ import gg.modl.proto.modl.v1.PublicKnowledgebaseCategoriesResponse; import gg.modl.proto.modl.v1.PublicKnowledgebaseCategoryWithArticlesResponse; import java.util.List; +import java.util.function.Function; import org.springframework.stereotype.Component; @Component @@ -71,7 +73,7 @@ public KnowledgebaseSearchResponse toSearchResponse(List a public PanelKnowledgebaseCategoriesResponse toPanelCategoriesResponse( List categories, - java.util.function.Function> articlesForCategory + Function> articlesForCategory ) { PanelKnowledgebaseCategoriesResponse.Builder builder = PanelKnowledgebaseCategoriesResponse.newBuilder(); addAll(categories, @@ -82,7 +84,7 @@ public PanelKnowledgebaseCategoriesResponse toPanelCategoriesResponse( public PublicKnowledgebaseCategoriesResponse toPublicCategoriesResponse( List categories, - java.util.function.Function> articlesForCategory + Function> articlesForCategory ) { PublicKnowledgebaseCategoriesResponse.Builder builder = PublicKnowledgebaseCategoriesResponse.newBuilder(); addAll(categories, @@ -136,8 +138,4 @@ private KnowledgebaseArticleStubResponse toArticleStub(KnowledgebaseArticle arti .setOrdinal(article.getOrdinal()) .build(); } - - private static String nullToEmpty(String value) { - return value == null ? "" : value; - } } diff --git a/src/main/java/gg/modl/backend/knowledgebase/controller/PanelKnowledgebaseController.java b/src/main/java/gg/modl/backend/knowledgebase/controller/PanelKnowledgebaseController.java index e46b3c2..84f2108 100644 --- a/src/main/java/gg/modl/backend/knowledgebase/controller/PanelKnowledgebaseController.java +++ b/src/main/java/gg/modl/backend/knowledgebase/controller/PanelKnowledgebaseController.java @@ -1,184 +1,187 @@ -package gg.modl.backend.knowledgebase.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; -import gg.modl.backend.knowledgebase.service.KnowledgebaseArticleService; -import gg.modl.backend.knowledgebase.service.KnowledgebaseCategoryService; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.CreateArticleRequest; -import gg.modl.proto.modl.v1.CreateCategoryRequest; -import gg.modl.proto.modl.v1.KnowledgebaseArticleResponse; -import gg.modl.proto.modl.v1.KnowledgebaseArticlesResponse; -import gg.modl.proto.modl.v1.KnowledgebaseCategoryResponse; -import gg.modl.proto.modl.v1.KnowledgebaseMessageResponse; -import gg.modl.proto.modl.v1.PanelKnowledgebaseCategoriesResponse; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.ReorderRequest; -import gg.modl.proto.modl.v1.UpdateArticleRequest; -import gg.modl.proto.modl.v1.UpdateCategoryRequest; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Collections; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_KNOWLEDGEBASE) -@RequiredArgsConstructor -public class PanelKnowledgebaseController { - private final KnowledgebaseCategoryService categoryService; - private final KnowledgebaseArticleService articleService; - private final KnowledgebaseProtoMapper mapper; - private final RealtimeEventPublisher publisher; - - @GetMapping("/categories") - public ResponseEntity getCategories(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - var categories = categoryService.getAllCategories(server); - Map> articlesByCategory = - articleService.getAllArticlesGroupedByCategory(server); - - return ResponseEntity.ok(mapper.toPanelCategoriesResponse( - categories, - category -> articlesByCategory.getOrDefault(category.getId(), Collections.emptyList()))); - } - - @PostMapping("/categories") - public ResponseEntity createCategory( - @RequestBody CreateCategoryRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - var category = categoryService.createCategory(server, createRequest); - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, category.getId()); - return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toCategoryResponse(category)); - } - - @PutMapping("/categories/{id}") - public ResponseEntity updateCategory( - @PathVariable String id, - @RequestBody UpdateCategoryRequest updateRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return categoryService.updateCategory(server, id, updateRequest) - .map(category -> { - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, category.getId()); - return ResponseEntity.ok(mapper.toCategoryResponse(category)); - }) - .orElseGet(() -> ResponseEntity.notFound().build()); - } - - @DeleteMapping("/categories/{id}") - public ResponseEntity deleteCategory( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - boolean deleted = categoryService.deleteCategory(server, id); - if (deleted) { - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, id); - return ResponseEntity.ok(mapper.message("Category deleted")); - } - return ResponseEntity.notFound().build(); - } - - @PutMapping("/categories/reorder") - public ResponseEntity reorderCategories( - @RequestBody ReorderRequest reorderRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - categoryService.reorderCategories(server, reorderRequest.getIdsList()); - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE); - return ResponseEntity.ok(mapper.message("Categories reordered")); - } - - @GetMapping("/categories/{categoryId}/articles") - public ResponseEntity getArticles( - @PathVariable String categoryId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(mapper.toArticlesResponse( - articleService.getArticlesByCategory(server, categoryId))); - } - - @GetMapping("/categories/{categoryId}/articles/{articleId}") - public ResponseEntity getArticle( - @PathVariable String categoryId, - @PathVariable String articleId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return articleService.getArticleById(server, articleId) - .map(article -> ResponseEntity.ok(mapper.toArticleResponse(article))) - .orElseGet(() -> ResponseEntity.notFound().build()); - } - - @PostMapping("/categories/{categoryId}/articles") - public ResponseEntity createArticle( - @PathVariable String categoryId, - @RequestBody CreateArticleRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - KnowledgebaseArticle article = articleService.createArticle(server, categoryId, createRequest); - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, article.getId()); - return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toArticleResponse(article)); - } - - @PutMapping("/categories/{categoryId}/articles/{articleId}") - public ResponseEntity updateArticle( - @PathVariable String categoryId, - @PathVariable String articleId, - @RequestBody UpdateArticleRequest updateRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return articleService.updateArticle(server, articleId, updateRequest) - .map(article -> { - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, article.getId()); - return ResponseEntity.ok(mapper.toArticleResponse(article)); - }) - .orElseGet(() -> ResponseEntity.notFound().build()); - } - - @DeleteMapping("/categories/{categoryId}/articles/{articleId}") - public ResponseEntity deleteArticle( - @PathVariable String categoryId, - @PathVariable String articleId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - boolean deleted = articleService.deleteArticle(server, articleId); - if (deleted) { - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, articleId); - return ResponseEntity.ok(mapper.message("Article deleted")); - } - return ResponseEntity.notFound().build(); - } - - @PutMapping("/categories/{categoryId}/articles/reorder") - public ResponseEntity reorderArticles( - @PathVariable String categoryId, - @RequestBody ReorderRequest reorderRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - articleService.reorderArticles(server, categoryId, reorderRequest.getIdsList()); - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE); - return ResponseEntity.ok(mapper.message("Articles reordered")); - } -} +package gg.modl.backend.knowledgebase.controller; + +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; +import gg.modl.backend.knowledgebase.service.KnowledgebaseArticleService; +import gg.modl.backend.knowledgebase.service.KnowledgebaseCategoryService; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.CreateArticleRequest; +import gg.modl.proto.modl.v1.CreateCategoryRequest; +import gg.modl.proto.modl.v1.KnowledgebaseArticleResponse; +import gg.modl.proto.modl.v1.KnowledgebaseArticlesResponse; +import gg.modl.proto.modl.v1.KnowledgebaseCategoryResponse; +import gg.modl.proto.modl.v1.KnowledgebaseMessageResponse; +import gg.modl.proto.modl.v1.PanelKnowledgebaseCategoriesResponse; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.ReorderRequest; +import gg.modl.proto.modl.v1.UpdateArticleRequest; +import gg.modl.proto.modl.v1.UpdateCategoryRequest; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_KNOWLEDGEBASE) +@RequiresPanelPermission(view = "admin.settings.view.content", modify = "admin.settings.modify.content") +@RequiredArgsConstructor +public class PanelKnowledgebaseController { + private final KnowledgebaseCategoryService categoryService; + private final KnowledgebaseArticleService articleService; + private final KnowledgebaseProtoMapper mapper; + private final RealtimeEventPublisher publisher; + + @GetMapping("/categories") + public ResponseEntity getCategories(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + var categories = categoryService.getAllCategories(server); + Map> articlesByCategory = + articleService.getAllArticlesGroupedByCategory(server); + + return ResponseEntity.ok(mapper.toPanelCategoriesResponse( + categories, + category -> articlesByCategory.getOrDefault(category.getId(), Collections.emptyList()))); + } + + @PostMapping("/categories") + public ResponseEntity createCategory( + @RequestBody CreateCategoryRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + var category = categoryService.createCategory(server, createRequest); + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, category.getId()); + return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toCategoryResponse(category)); + } + + @PutMapping("/categories/{id}") + public ResponseEntity updateCategory( + @PathVariable String id, + @RequestBody UpdateCategoryRequest updateRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return categoryService.updateCategory(server, id, updateRequest) + .map(category -> { + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, category.getId()); + return ResponseEntity.ok(mapper.toCategoryResponse(category)); + }) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @DeleteMapping("/categories/{id}") + public ResponseEntity deleteCategory( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + boolean deleted = categoryService.deleteCategory(server, id); + if (deleted) { + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, id); + return ResponseEntity.ok(mapper.message("Category deleted")); + } + return ResponseEntity.notFound().build(); + } + + @PutMapping("/categories/reorder") + public ResponseEntity reorderCategories( + @RequestBody ReorderRequest reorderRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + categoryService.reorderCategories(server, reorderRequest.getIdsList()); + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE); + return ResponseEntity.ok(mapper.message("Categories reordered")); + } + + @GetMapping("/categories/{categoryId}/articles") + public ResponseEntity getArticles( + @PathVariable String categoryId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(mapper.toArticlesResponse( + articleService.getArticlesByCategory(server, categoryId))); + } + + @GetMapping("/categories/{categoryId}/articles/{articleId}") + public ResponseEntity getArticle( + @PathVariable String categoryId, + @PathVariable String articleId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return articleService.getArticleById(server, articleId) + .map(article -> ResponseEntity.ok(mapper.toArticleResponse(article))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping("/categories/{categoryId}/articles") + public ResponseEntity createArticle( + @PathVariable String categoryId, + @RequestBody CreateArticleRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + KnowledgebaseArticle article = articleService.createArticle(server, categoryId, createRequest); + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, article.getId()); + return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toArticleResponse(article)); + } + + @PutMapping("/categories/{categoryId}/articles/{articleId}") + public ResponseEntity updateArticle( + @PathVariable String categoryId, + @PathVariable String articleId, + @RequestBody UpdateArticleRequest updateRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return articleService.updateArticle(server, articleId, updateRequest) + .map(article -> { + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, article.getId()); + return ResponseEntity.ok(mapper.toArticleResponse(article)); + }) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @DeleteMapping("/categories/{categoryId}/articles/{articleId}") + public ResponseEntity deleteArticle( + @PathVariable String categoryId, + @PathVariable String articleId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + boolean deleted = articleService.deleteArticle(server, articleId); + if (deleted) { + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, articleId); + return ResponseEntity.ok(mapper.message("Article deleted")); + } + return ResponseEntity.notFound().build(); + } + + @PutMapping("/categories/{categoryId}/articles/reorder") + public ResponseEntity reorderArticles( + @PathVariable String categoryId, + @RequestBody ReorderRequest reorderRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + articleService.reorderArticles(server, categoryId, reorderRequest.getIdsList()); + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE); + return ResponseEntity.ok(mapper.message("Articles reordered")); + } +} diff --git a/src/main/java/gg/modl/backend/knowledgebase/controller/PublicKnowledgebaseController.java b/src/main/java/gg/modl/backend/knowledgebase/controller/PublicKnowledgebaseController.java index ecf2321..3a11db2 100644 --- a/src/main/java/gg/modl/backend/knowledgebase/controller/PublicKnowledgebaseController.java +++ b/src/main/java/gg/modl/backend/knowledgebase/controller/PublicKnowledgebaseController.java @@ -1,76 +1,76 @@ -package gg.modl.backend.knowledgebase.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; -import gg.modl.backend.knowledgebase.service.KnowledgebaseArticleService; -import gg.modl.backend.knowledgebase.service.KnowledgebaseCategoryService; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.KnowledgebaseArticleResponse; -import gg.modl.proto.modl.v1.KnowledgebaseArticlesResponse; -import gg.modl.proto.modl.v1.KnowledgebaseSearchResponse; -import gg.modl.proto.modl.v1.PublicKnowledgebaseCategoriesResponse; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PUBLIC_KNOWLEDGEBASE) -@RequiredArgsConstructor -public class PublicKnowledgebaseController { - private final KnowledgebaseCategoryService categoryService; - private final KnowledgebaseArticleService articleService; - private final KnowledgebaseProtoMapper mapper; - - @GetMapping("/categories") - public ResponseEntity getCategories(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - var categories = categoryService.getVisibleCategories(server); - return ResponseEntity.ok(mapper.toPublicCategoriesResponse( - categories, - category -> articleService.getVisibleArticlesByCategory(server, category.getId()))); - } - - @GetMapping("/categories/{categoryId}/articles") - public ResponseEntity getArticles( - @PathVariable String categoryId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(mapper.toArticlesResponse( - articleService.getVisibleArticlesByCategory(server, categoryId))); - } - - @GetMapping("/articles/{idOrSlug}") - public ResponseEntity getArticle( - @PathVariable String idOrSlug, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Optional article = articleService.getArticleById(server, idOrSlug); - if (article.isEmpty()) { - article = articleService.getArticleBySlug(server, idOrSlug); - } - - return article - .filter(KnowledgebaseArticle::isVisible) - .map(found -> ResponseEntity.ok(mapper.toArticleResponse(found))) - .orElseGet(() -> ResponseEntity.notFound().build()); - } - - @GetMapping("/search") - public ResponseEntity searchArticles( - @RequestParam String q, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(mapper.toSearchResponse(articleService.searchArticles(server, q))); - } -} +package gg.modl.backend.knowledgebase.controller; + +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; +import gg.modl.backend.knowledgebase.service.KnowledgebaseArticleService; +import gg.modl.backend.knowledgebase.service.KnowledgebaseCategoryService; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.KnowledgebaseArticleResponse; +import gg.modl.proto.modl.v1.KnowledgebaseArticlesResponse; +import gg.modl.proto.modl.v1.KnowledgebaseSearchResponse; +import gg.modl.proto.modl.v1.PublicKnowledgebaseCategoriesResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PUBLIC_KNOWLEDGEBASE) +@RequiredArgsConstructor +public class PublicKnowledgebaseController { + private final KnowledgebaseCategoryService categoryService; + private final KnowledgebaseArticleService articleService; + private final KnowledgebaseProtoMapper mapper; + + @GetMapping("/categories") + public ResponseEntity getCategories(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + var categories = categoryService.getVisibleCategories(server); + return ResponseEntity.ok(mapper.toPublicCategoriesResponse( + categories, + category -> articleService.getVisibleArticlesByCategory(server, category.getId()))); + } + + @GetMapping("/categories/{categoryId}/articles") + public ResponseEntity getArticles( + @PathVariable String categoryId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(mapper.toArticlesResponse( + articleService.getVisibleArticlesByCategory(server, categoryId))); + } + + @GetMapping("/articles/{idOrSlug}") + public ResponseEntity getArticle( + @PathVariable String idOrSlug, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + Optional article = articleService.getArticleById(server, idOrSlug); + if (article.isEmpty()) { + article = articleService.getArticleBySlug(server, idOrSlug); + } + + return article + .filter(KnowledgebaseArticle::isVisible) + .map(found -> ResponseEntity.ok(mapper.toArticleResponse(found))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @GetMapping("/search") + public ResponseEntity searchArticles( + @RequestParam String q, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(mapper.toSearchResponse(articleService.searchArticles(server, q))); + } +} diff --git a/src/main/java/gg/modl/backend/knowledgebase/data/KnowledgebaseCategory.java b/src/main/java/gg/modl/backend/knowledgebase/data/KnowledgebaseCategory.java index 56cb30a..98389cd 100644 --- a/src/main/java/gg/modl/backend/knowledgebase/data/KnowledgebaseCategory.java +++ b/src/main/java/gg/modl/backend/knowledgebase/data/KnowledgebaseCategory.java @@ -1,39 +1,39 @@ -package gg.modl.backend.knowledgebase.data; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document(collection = CollectionName.KNOWLEDGEBASE_CATEGORIES) -@GenerateMongoFields -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class KnowledgebaseCategory { - @Id - private String id; - - @Field("name") - private String name; - @Field("slug") - private String slug; - @Field("description") - private String description; - @Field("ordinal") - private int ordinal; - @Field("isVisible") - private boolean isVisible; - - @Field("createdAt") - private Date createdAt; - @Field("updatedAt") - private Date updatedAt; -} +package gg.modl.backend.knowledgebase.data; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document(collection = CollectionName.KNOWLEDGEBASE_CATEGORIES) +@GenerateMongoFields +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KnowledgebaseCategory { + @Id + private String id; + + @Field("name") + private String name; + @Field("slug") + private String slug; + @Field("description") + private String description; + @Field("ordinal") + private int ordinal; + @Field("isVisible") + private boolean isVisible; + + @Field("createdAt") + private Date createdAt; + @Field("updatedAt") + private Date updatedAt; +} diff --git a/src/main/java/gg/modl/backend/knowledgebase/service/KnowledgebaseArticleService.java b/src/main/java/gg/modl/backend/knowledgebase/service/KnowledgebaseArticleService.java index fae5e57..9af5970 100644 --- a/src/main/java/gg/modl/backend/knowledgebase/service/KnowledgebaseArticleService.java +++ b/src/main/java/gg/modl/backend/knowledgebase/service/KnowledgebaseArticleService.java @@ -1,110 +1,110 @@ -package gg.modl.backend.knowledgebase.service; - -import com.github.slugify.Slugify; -import gg.modl.backend.database.mongo.repository.KnowledgebaseArticleMongoRepository; -import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.CreateArticleRequest; -import gg.modl.proto.modl.v1.UpdateArticleRequest; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class KnowledgebaseArticleService { - private final KnowledgebaseArticleMongoRepository articleRepository; - private final Slugify slugify = Slugify.builder().build(); - private static final int MAX_SEARCH_RESULTS = 20; - - public List getArticlesByCategory(Server server, String categoryId) { - return articleRepository.findByCategoryOrdered(server, categoryId); - } - - public Map> getAllArticlesGroupedByCategory(Server server) { - return articleRepository.findAll(server) - .stream() - .collect(Collectors.groupingBy(KnowledgebaseArticle::getCategoryId)); - } - - public List getVisibleArticlesByCategory(Server server, String categoryId) { - return articleRepository.findVisibleByCategoryOrdered(server, categoryId); - } - - public Map> getVisibleArticlesGroupedByCategoryIds(Server server, Collection categoryIds) { - return articleRepository.findVisibleByCategoryIdsOrdered(server, categoryIds) - .stream() - .collect(Collectors.groupingBy(KnowledgebaseArticle::getCategoryId)); - } - - public Optional getArticleById(Server server, String id) { - return articleRepository.findByArticleId(server, id); - } - - public Optional getArticleBySlug(Server server, String slug) { - return articleRepository.findBySlug(server, slug); - } - - public KnowledgebaseArticle createArticle(Server server, String categoryId, CreateArticleRequest request) { - KnowledgebaseArticle article = KnowledgebaseArticle.builder() - .title(request.getTitle()) - .slug(generateUniqueSlug(server, slugify.slugify(request.getTitle()), null)) - .content(request.getContent()) - .categoryId(categoryId) - .ordinal(articleRepository.findMaxOrdinalInCategory(server, categoryId) + 1) - .isVisible(!request.hasIsVisible() || request.getIsVisible()) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(); - - return articleRepository.saveEntity(server, article); - } - - private String generateUniqueSlug(Server server, String baseSlug, String excludeId) { - String slug = baseSlug; - int suffix = 1; - - while (articleRepository.existsBySlug(server, slug, excludeId)) { - slug = baseSlug + "-" + suffix; - suffix++; - } - - return slug; - } - - public Optional updateArticle(Server server, String id, UpdateArticleRequest request) { - String title = request.hasTitle() ? request.getTitle() : null; - String uniqueSlug = title != null - ? generateUniqueSlug(server, slugify.slugify(title), id) - : null; - - return articleRepository.updateArticle( - server, - id, - title, - uniqueSlug, - request.hasContent() ? request.getContent() : null, - request.hasIsVisible() ? request.getIsVisible() : null, - new Date() - ); - } - - public boolean deleteArticle(Server server, String id) { - return articleRepository.deleteByArticleId(server, id); - } - - public List searchArticles(Server server, String searchQuery) { - return articleRepository.searchVisibleArticles(server, searchQuery, MAX_SEARCH_RESULTS); - } - - public void reorderArticles(Server server, String categoryId, List ids) { - articleRepository.reorderArticles(server, categoryId, ids); - } -} +package gg.modl.backend.knowledgebase.service; + +import com.github.slugify.Slugify; +import gg.modl.backend.database.mongo.repository.KnowledgebaseArticleMongoRepository; +import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.CreateArticleRequest; +import gg.modl.proto.modl.v1.UpdateArticleRequest; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class KnowledgebaseArticleService { + private final KnowledgebaseArticleMongoRepository articleRepository; + private final Slugify slugify = Slugify.builder().build(); + private static final int MAX_SEARCH_RESULTS = 20; + + public List getArticlesByCategory(Server server, String categoryId) { + return articleRepository.findByCategoryOrdered(server, categoryId); + } + + public Map> getAllArticlesGroupedByCategory(Server server) { + return articleRepository.findAll(server) + .stream() + .collect(Collectors.groupingBy(KnowledgebaseArticle::getCategoryId)); + } + + public List getVisibleArticlesByCategory(Server server, String categoryId) { + return articleRepository.findVisibleByCategoryOrdered(server, categoryId); + } + + public Map> getVisibleArticlesGroupedByCategoryIds(Server server, Collection categoryIds) { + return articleRepository.findVisibleByCategoryIdsOrdered(server, categoryIds) + .stream() + .collect(Collectors.groupingBy(KnowledgebaseArticle::getCategoryId)); + } + + public Optional getArticleById(Server server, String id) { + return articleRepository.findByArticleId(server, id); + } + + public Optional getArticleBySlug(Server server, String slug) { + return articleRepository.findBySlug(server, slug); + } + + public KnowledgebaseArticle createArticle(Server server, String categoryId, CreateArticleRequest request) { + KnowledgebaseArticle article = KnowledgebaseArticle.builder() + .title(request.getTitle()) + .slug(generateUniqueSlug(server, slugify.slugify(request.getTitle()), null)) + .content(request.getContent()) + .categoryId(categoryId) + .ordinal(articleRepository.findMaxOrdinalInCategory(server, categoryId) + 1) + .isVisible(!request.hasIsVisible() || request.getIsVisible()) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(); + + return articleRepository.saveEntity(server, article); + } + + private String generateUniqueSlug(Server server, String baseSlug, String excludeId) { + String slug = baseSlug; + int suffix = 1; + + while (articleRepository.existsBySlug(server, slug, excludeId)) { + slug = baseSlug + "-" + suffix; + suffix++; + } + + return slug; + } + + public Optional updateArticle(Server server, String id, UpdateArticleRequest request) { + String title = request.hasTitle() ? request.getTitle() : null; + String uniqueSlug = title != null + ? generateUniqueSlug(server, slugify.slugify(title), id) + : null; + + return articleRepository.updateArticle( + server, + id, + title, + uniqueSlug, + request.hasContent() ? request.getContent() : null, + request.hasIsVisible() ? request.getIsVisible() : null, + new Date() + ); + } + + public boolean deleteArticle(Server server, String id) { + return articleRepository.deleteByArticleId(server, id); + } + + public List searchArticles(Server server, String searchQuery) { + return articleRepository.searchVisibleArticles(server, searchQuery, MAX_SEARCH_RESULTS); + } + + public void reorderArticles(Server server, String categoryId, List ids) { + articleRepository.reorderArticles(server, categoryId, ids); + } +} diff --git a/src/main/java/gg/modl/backend/log/controller/PanelLogController.java b/src/main/java/gg/modl/backend/log/controller/PanelLogController.java index 899220c..dd582a5 100644 --- a/src/main/java/gg/modl/backend/log/controller/PanelLogController.java +++ b/src/main/java/gg/modl/backend/log/controller/PanelLogController.java @@ -2,6 +2,7 @@ import gg.modl.backend.log.dto.response.SystemLogResponse; import gg.modl.backend.log.service.LogService; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.server.data.Server; @@ -21,6 +22,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_LOGS) +@RequiresPanelPermission("admin.audit.view.logs") @RequiredArgsConstructor @Validated public class PanelLogController { diff --git a/src/main/java/gg/modl/backend/log/data/SystemLog.java b/src/main/java/gg/modl/backend/log/data/ServerLog.java similarity index 93% rename from src/main/java/gg/modl/backend/log/data/SystemLog.java rename to src/main/java/gg/modl/backend/log/data/ServerLog.java index 9d10afd..a500506 100644 --- a/src/main/java/gg/modl/backend/log/data/SystemLog.java +++ b/src/main/java/gg/modl/backend/log/data/ServerLog.java @@ -1,36 +1,36 @@ -package gg.modl.backend.log.data; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@GenerateMongoFields(className = "ServerLogFields") -public class SystemLog { - @Id - private String id; - - @Field("description") - private String description; - - @Field("level") - @Builder.Default - private String level = "info"; - - @Field("source") - @Builder.Default - private String source = "system"; - - @Field("created") - private Date created; -} +package gg.modl.backend.log.data; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@GenerateMongoFields(className = "ServerLogFields") +public class ServerLog { + @Id + private String id; + + @Field("description") + private String description; + + @Field("level") + @Builder.Default + private String level = "info"; + + @Field("source") + @Builder.Default + private String source = "system"; + + @Field("created") + private Date created; +} diff --git a/src/main/java/gg/modl/backend/log/dto/response/SystemLogResponse.java b/src/main/java/gg/modl/backend/log/dto/response/SystemLogResponse.java index 8c08652..3ad76cb 100644 --- a/src/main/java/gg/modl/backend/log/dto/response/SystemLogResponse.java +++ b/src/main/java/gg/modl/backend/log/dto/response/SystemLogResponse.java @@ -1,11 +1,11 @@ -package gg.modl.backend.log.dto.response; - -import java.util.Date; - -public record SystemLogResponse( - String id, - String description, - String level, - String source, - Date created -) {} +package gg.modl.backend.log.dto.response; + +import java.util.Date; + +public record SystemLogResponse( + String id, + String description, + String level, + String source, + Date created +) {} diff --git a/src/main/java/gg/modl/backend/log/service/LogService.java b/src/main/java/gg/modl/backend/log/service/LogService.java index a500d24..8a8369d 100644 --- a/src/main/java/gg/modl/backend/log/service/LogService.java +++ b/src/main/java/gg/modl/backend/log/service/LogService.java @@ -1,60 +1,60 @@ -package gg.modl.backend.log.service; - -import gg.modl.backend.database.mongo.repository.ServerLogMongoRepository; -import gg.modl.backend.log.data.SystemLog; -import gg.modl.backend.log.dto.response.SystemLogResponse; -import gg.modl.backend.server.data.Server; -import java.util.Date; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class LogService { - private final ServerLogMongoRepository serverLogRepository; - - private static final int MAX_LIMIT = 500; - private static final String LEVEL_MODERATION = "moderation"; - private static final String LEVEL_INFO = "info"; - private static final String SOURCE_SYSTEM = "system"; - - public List getLogs(Server server, int limit) { - int safeLimit = Math.max(1, Math.min(limit, MAX_LIMIT)); - List logs = serverLogRepository.findRecent(server, safeLimit); - - return logs.stream() - .map(l -> new SystemLogResponse( - l.getId(), - l.getDescription(), - l.getLevel(), - l.getSource(), - l.getCreated() - )) - .toList(); - } - - public void recordModerationAction(Server server, String source, String description) { - write(server, description, LEVEL_MODERATION, source); - } - - public void recordStaffAction(Server server, String source, String description) { - write(server, description, LEVEL_INFO, source); - } - - private void write(Server server, String description, String level, String source) { - SystemLog entry = SystemLog.builder() - .description(description) - .level(level) - .source(source != null && !source.isBlank() ? source : SOURCE_SYSTEM) - .created(new Date()) - .build(); - try { - serverLogRepository.saveEntity(server, entry); - } catch (RuntimeException e) { - log.error("Failed to record audit log entry [{}] for server {}", description, server.getDatabaseName(), e); - } - } -} +package gg.modl.backend.log.service; + +import gg.modl.backend.database.mongo.repository.ServerLogMongoRepository; +import gg.modl.backend.log.data.ServerLog; +import gg.modl.backend.log.dto.response.SystemLogResponse; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class LogService { + private final ServerLogMongoRepository serverLogRepository; + + private static final int MAX_LIMIT = 500; + private static final String LEVEL_MODERATION = "moderation"; + private static final String LEVEL_INFO = "info"; + private static final String SOURCE_SYSTEM = "system"; + + public List getLogs(Server server, int limit) { + int safeLimit = Math.max(1, Math.min(limit, MAX_LIMIT)); + List logs = serverLogRepository.findRecent(server, safeLimit); + + return logs.stream() + .map(l -> new SystemLogResponse( + l.getId(), + l.getDescription(), + l.getLevel(), + l.getSource(), + l.getCreated() + )) + .toList(); + } + + public void recordModerationAction(Server server, String source, String description) { + write(server, description, LEVEL_MODERATION, source); + } + + public void recordStaffAction(Server server, String source, String description) { + write(server, description, LEVEL_INFO, source); + } + + private void write(Server server, String description, String level, String source) { + ServerLog entry = ServerLog.builder() + .description(description) + .level(level) + .source(source != null && !source.isBlank() ? source : SOURCE_SYSTEM) + .created(new Date()) + .build(); + try { + serverLogRepository.saveEntity(server, entry); + } catch (RuntimeException e) { + log.error("Failed to record audit log entry [{}] for server {}", description, server.getDatabaseName(), e); + } + } +} diff --git a/src/main/java/gg/modl/backend/log/service/PanelActionAuditor.java b/src/main/java/gg/modl/backend/log/service/PanelActionAuditor.java index e5a1762..0d0e7cc 100644 --- a/src/main/java/gg/modl/backend/log/service/PanelActionAuditor.java +++ b/src/main/java/gg/modl/backend/log/service/PanelActionAuditor.java @@ -2,7 +2,7 @@ import gg.modl.backend.server.data.Server; import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.staff.service.StaffService; +import gg.modl.backend.staff.service.StaffLookupCache; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -10,7 +10,7 @@ @RequiredArgsConstructor public class PanelActionAuditor { private final LogService logService; - private final StaffService staffService; + private final StaffLookupCache staffLookupCache; public void recordStaffAction(Server server, String actorEmail, String description) { logService.recordStaffAction(server, resolveActor(server, actorEmail), description); @@ -24,7 +24,7 @@ private String resolveActor(Server server, String actorEmail) { if (actorEmail == null || actorEmail.isBlank()) { return "Unknown"; } - return staffService.getStaffByEmail(server, actorEmail) + return staffLookupCache.findByEmail(server, actorEmail) .map(Staff::getUsername) .filter(username -> username != null && !username.isBlank()) .orElse(actorEmail); diff --git a/src/main/java/gg/modl/backend/migration/controller/MigrationProtoMapper.java b/src/main/java/gg/modl/backend/migration/controller/MigrationProtoMapper.java index b65ae25..823778d 100644 --- a/src/main/java/gg/modl/backend/migration/controller/MigrationProtoMapper.java +++ b/src/main/java/gg/modl/backend/migration/controller/MigrationProtoMapper.java @@ -1,11 +1,12 @@ package gg.modl.backend.migration.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.nullToEmpty; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toTimestamp; import gg.modl.backend.migration.data.MigrationStatus; +import gg.modl.backend.migration.service.MigrationService.MigrationOperationResult; import gg.modl.proto.modl.v1.MigrationOperationResponse; import gg.modl.proto.modl.v1.MigrationStatusResponse; -import java.util.Map; import org.jetbrains.annotations.Nullable; import org.springframework.stereotype.Component; @@ -47,20 +48,17 @@ public MigrationStatusResponse toStatusResponse(@Nullable MigrationStatus status return builder.build(); } - public MigrationOperationResponse toOperationResponse(Map result) { + public MigrationOperationResponse toOperationResponse(MigrationOperationResult result) { MigrationOperationResponse.Builder builder = MigrationOperationResponse.newBuilder() - .setSuccess(Boolean.TRUE.equals(result.get("success"))); - Object taskId = result.get("taskId"); - if (taskId != null) { - builder.setTaskId(taskId.toString()); + .setSuccess(result.success()); + if (result.taskId() != null) { + builder.setTaskId(result.taskId()); } - Object message = result.get("message"); - if (message != null) { - builder.setMessage(message.toString()); + if (result.message() != null) { + builder.setMessage(result.message()); } - Object error = result.get("error"); - if (error != null) { - builder.setError(error.toString()); + if (result.error() != null) { + builder.setError(result.error()); } return builder.build(); } @@ -80,8 +78,4 @@ private MigrationStatusResponse.MigrationProgress toProgress(MigrationStatus.Mig } return builder.build(); } - - private static String nullToEmpty(String value) { - return value == null ? "" : value; - } } diff --git a/src/main/java/gg/modl/backend/migration/controller/MinecraftMigrationController.java b/src/main/java/gg/modl/backend/migration/controller/MinecraftMigrationController.java index 1c86d0d..5c96b2e 100644 --- a/src/main/java/gg/modl/backend/migration/controller/MinecraftMigrationController.java +++ b/src/main/java/gg/modl/backend/migration/controller/MinecraftMigrationController.java @@ -1,95 +1,81 @@ -package gg.modl.backend.migration.controller; - -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.migration.dto.UpdateProgressRequest; -import gg.modl.backend.migration.service.MigrationProcessor; -import gg.modl.backend.migration.service.MigrationService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import java.nio.file.Path; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.core.task.TaskRejectedException; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -@RestController -@RequestMapping(RESTMappingV1.MINECRAFT_MIGRATION) -@RequiredArgsConstructor -public class MinecraftMigrationController { - private final MigrationService migrationService; - private final MigrationProcessor migrationProcessor; - - @PostMapping("/upload") - public ResponseEntity uploadMigrationFile( - @RequestParam("migrationFile") MultipartFile file, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - if (file.isEmpty()) { - throw new ValidationException("No file uploaded"); - } - - String originalFilename = file.getOriginalFilename(); - if (originalFilename == null || !originalFilename.endsWith(".json")) { - throw new ValidationException("Only JSON files are allowed"); - } - - Map sizeError = migrationService.validateFileSize(server, file); - if (sizeError != null) { - return ResponseEntity.status(413).body(sizeError); - } - - migrationService.requireActiveMigrationForUpload(server); - - Path filePath = migrationService.saveUploadedFile(file); - try { - migrationService.updateProgress(server, new UpdateProgressRequest( - "uploading_json", - "Migration file uploaded successfully. Starting data processing...", - 0, 0, null - )); - migrationProcessor.processFileAsync(server, filePath); - } catch (TaskRejectedException e) { - migrationService.discardUpload(server, filePath, - "Migration processing is busy. Please try again shortly."); - return ResponseEntity.status(503).body(Map.of( - "error", "Migration processing is busy", - "message", "The server is processing other migrations. Please try again shortly." - )); - } catch (RuntimeException e) { - migrationService.discardUpload(server, filePath, "Migration failed to start."); - return ResponseEntity.status(503).body(Map.of( - "error", "Migration failed to start", - "message", "The migration could not be started. Please try again." - )); - } - - return ResponseEntity.ok(Map.of( - "success", true, - "message", "Migration file uploaded successfully. Processing started.", - "fileSize", file.getSize() - )); - } - - @PostMapping("/progress") - public ResponseEntity updateProgress( - @RequestBody @Valid UpdateProgressRequest progressRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - migrationService.updateProgress(server, progressRequest); - - return ResponseEntity.ok(Map.of("success", true)); - } -} +package gg.modl.backend.migration.controller; + +import gg.modl.backend.migration.dto.UpdateProgressRequest; +import gg.modl.backend.migration.service.MigrationService; +import gg.modl.backend.migration.service.MigrationService.FileSizeError; +import gg.modl.backend.migration.service.MigrationUploadService; +import gg.modl.backend.migration.service.MigrationUploadService.UploadResult; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping(RESTMappingV1.MINECRAFT_MIGRATION) +@RequiredArgsConstructor +public class MinecraftMigrationController { + private final MigrationService migrationService; + private final MigrationUploadService migrationUploadService; + + @PostMapping("/upload") + public ResponseEntity uploadMigrationFile( + @RequestParam("migrationFile") MultipartFile file, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + UploadResult result = migrationUploadService.beginUpload(server, file); + return switch (result) { + case UploadResult.Success success -> { + Map body = Map.of( + "success", true, + "message", "Migration file uploaded successfully. Processing started.", + "fileSize", success.fileSize()); + yield ResponseEntity.ok(body); + } + case UploadResult.FileTooLarge fileTooLarge -> { + FileSizeError error = fileTooLarge.error(); + Map body = Map.of( + "error", error.error(), + "message", error.message(), + "fileSize", error.fileSize(), + "limit", error.limit()); + yield ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(body); + } + case UploadResult.ProcessingBusy ignored -> { + Map body = Map.of( + "error", "Migration processing is busy", + "message", "The server is processing other migrations. Please try again shortly."); + yield ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(body); + } + case UploadResult.StartFailed ignored -> { + Map body = Map.of( + "error", "Migration failed to start", + "message", "The migration could not be started. Please try again."); + yield ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(body); + } + }; + } + + @PostMapping("/progress") + public ResponseEntity updateProgress( + @RequestBody @Valid UpdateProgressRequest progressRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + migrationService.updateProgress(server, progressRequest); + + return ResponseEntity.ok(Map.of("success", true)); + } +} diff --git a/src/main/java/gg/modl/backend/migration/controller/PanelMigrationController.java b/src/main/java/gg/modl/backend/migration/controller/PanelMigrationController.java index 3a70993..265785c 100644 --- a/src/main/java/gg/modl/backend/migration/controller/PanelMigrationController.java +++ b/src/main/java/gg/modl/backend/migration/controller/PanelMigrationController.java @@ -1,62 +1,64 @@ -package gg.modl.backend.migration.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.migration.service.MigrationService; -import gg.modl.backend.migration.service.MigrationService.CooldownState; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.MigrationOperationResponse; -import gg.modl.proto.modl.v1.MigrationStatusResponse; -import gg.modl.proto.modl.v1.StartMigrationRequest; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_MIGRATION) -@RequiredArgsConstructor -public class PanelMigrationController { - private final MigrationService migrationService; - private final MigrationProtoMapper mapper; - - @GetMapping("/status") - public ResponseEntity getMigrationStatus(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - CooldownState cooldown = migrationService.checkCooldown(server); - return ResponseEntity.ok(mapper.toStatusResponse( - migrationService.getLatestMigration(server).orElse(null), - cooldown.onCooldown(), - cooldown.remainingTime())); - } - - @PostMapping("/start") - public ResponseEntity startMigration( - @RequestBody StartMigrationRequest startRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - Map result = migrationService.startMigration(server, startRequest.getMigrationType()); - return toResponse(result); - } - - @PostMapping("/cancel") - public ResponseEntity cancelMigration(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - Map result = migrationService.cancelMigration(server); - return toResponse(result); - } - - private ResponseEntity toResponse(Map result) { - MigrationOperationResponse response = mapper.toOperationResponse(result); - if (Boolean.FALSE.equals(result.get("success"))) { - return ResponseEntity.badRequest().body(response); - } - return ResponseEntity.ok(response); - } -} +package gg.modl.backend.migration.controller; + +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.migration.service.MigrationService; +import gg.modl.backend.migration.service.MigrationService.CooldownState; +import gg.modl.backend.migration.service.MigrationService.MigrationOperationResult; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.MigrationOperationResponse; +import gg.modl.proto.modl.v1.MigrationStatusResponse; +import gg.modl.proto.modl.v1.StartMigrationRequest; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_MIGRATION) +@RequiresPanelPermission(view = "admin.settings.view.migration", modify = "admin.settings.modify.migration") +@RequiredArgsConstructor +public class PanelMigrationController { + private final MigrationService migrationService; + private final MigrationProtoMapper mapper; + + @GetMapping("/status") + public ResponseEntity getMigrationStatus(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + CooldownState cooldown = migrationService.checkCooldown(server); + return ResponseEntity.ok(mapper.toStatusResponse( + migrationService.getLatestMigration(server).orElse(null), + cooldown.onCooldown(), + cooldown.remainingTime())); + } + + @PostMapping("/start") + public ResponseEntity startMigration( + @RequestBody StartMigrationRequest startRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + MigrationOperationResult result = migrationService.startMigration(server, startRequest.getMigrationType()); + return toResponse(result); + } + + @PostMapping("/cancel") + public ResponseEntity cancelMigration(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + MigrationOperationResult result = migrationService.cancelMigration(server); + return toResponse(result); + } + + private ResponseEntity toResponse(MigrationOperationResult result) { + MigrationOperationResponse response = mapper.toOperationResponse(result); + if (!result.success()) { + return ResponseEntity.badRequest().body(response); + } + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/gg/modl/backend/migration/data/MigrationStatus.java b/src/main/java/gg/modl/backend/migration/data/MigrationStatus.java index b02ce41..1675610 100644 --- a/src/main/java/gg/modl/backend/migration/data/MigrationStatus.java +++ b/src/main/java/gg/modl/backend/migration/data/MigrationStatus.java @@ -1,62 +1,62 @@ -package gg.modl.backend.migration.data; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; -import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document(collection = "migrations") -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@GenerateMongoFields -@MongoFieldAliases({ - @MongoFieldAlias(name = "PROGRESS_MESSAGE", path = "progress.message"), - @MongoFieldAlias(name = "PROGRESS_RECORDS_PROCESSED", path = "progress.recordsProcessed"), - @MongoFieldAlias(name = "PROGRESS_RECORDS_SKIPPED", path = "progress.recordsSkipped"), - @MongoFieldAlias(name = "PROGRESS_TOTAL_RECORDS", path = "progress.totalRecords") -}) -public class MigrationStatus { - @Id - private String id; - - @Field("taskId") - private String taskId; - @Field("type") - private String type; - @Field("status") - private String status; - @Field("progress") - private MigrationProgress progress; - @Field("startedAt") - private Date startedAt; - @Field("completedAt") - private Date completedAt; - @Field("error") - private String error; - @Field("cooldownExempt") - private Boolean cooldownExempt; - - @Data - @NoArgsConstructor - @AllArgsConstructor - @Builder - public static class MigrationProgress { - @Field("message") - private String message; - @Field("recordsProcessed") - private Integer recordsProcessed; - @Field("recordsSkipped") - private Integer recordsSkipped; - @Field("totalRecords") - private Integer totalRecords; - } -} +package gg.modl.backend.migration.data; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; +import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document(collection = "migrations") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@GenerateMongoFields +@MongoFieldAliases({ + @MongoFieldAlias(name = "PROGRESS_MESSAGE", path = "progress.message"), + @MongoFieldAlias(name = "PROGRESS_RECORDS_PROCESSED", path = "progress.recordsProcessed"), + @MongoFieldAlias(name = "PROGRESS_RECORDS_SKIPPED", path = "progress.recordsSkipped"), + @MongoFieldAlias(name = "PROGRESS_TOTAL_RECORDS", path = "progress.totalRecords") +}) +public class MigrationStatus { + @Id + private String id; + + @Field("taskId") + private String taskId; + @Field("type") + private String type; + @Field("status") + private String status; + @Field("progress") + private MigrationProgress progress; + @Field("startedAt") + private Date startedAt; + @Field("completedAt") + private Date completedAt; + @Field("error") + private String error; + @Field("cooldownExempt") + private Boolean cooldownExempt; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class MigrationProgress { + @Field("message") + private String message; + @Field("recordsProcessed") + private Integer recordsProcessed; + @Field("recordsSkipped") + private Integer recordsSkipped; + @Field("totalRecords") + private Integer totalRecords; + } +} diff --git a/src/main/java/gg/modl/backend/migration/dto/MigrationPlayerData.java b/src/main/java/gg/modl/backend/migration/dto/MigrationPlayerData.java deleted file mode 100644 index 4050087..0000000 --- a/src/main/java/gg/modl/backend/migration/dto/MigrationPlayerData.java +++ /dev/null @@ -1,46 +0,0 @@ -package gg.modl.backend.migration.dto; - -import java.util.List; -import java.util.Map; - -public record MigrationPlayerData( - String minecraftUuid, - List usernames, - List notes, - List ipAddresses, - List punishments, - Map data -) { - public record UsernameData(String username, String date) {} - - public record NoteData(String text, String date, String issuerName) {} - - public record IPData( - String ipAddress, - String country, - String region, - String asn, - Boolean proxy, - Boolean hosting, - String firstLogin, - List logins - ) {} - - public record PunishmentData( - String id, - String type, - int typeOrdinal, - String reason, - String issued, - String issuerName, - Long duration, - String started, - List notes, - List evidence, - List attachedTicketIds, - List modifications, - Map data - ) {} - - public record PunishmentNoteData(String text, String issuerName, String date) {} -} diff --git a/src/main/java/gg/modl/backend/migration/dto/UpdateProgressRequest.java b/src/main/java/gg/modl/backend/migration/dto/UpdateProgressRequest.java index 6cbbeb2..07fcc38 100644 --- a/src/main/java/gg/modl/backend/migration/dto/UpdateProgressRequest.java +++ b/src/main/java/gg/modl/backend/migration/dto/UpdateProgressRequest.java @@ -1,13 +1,13 @@ -package gg.modl.backend.migration.dto; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import jakarta.validation.constraints.NotBlank; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record UpdateProgressRequest( - @NotBlank String status, - @NotBlank String message, - Integer recordsProcessed, - Integer recordsSkipped, - Integer totalRecords -) {} +package gg.modl.backend.migration.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.NotBlank; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record UpdateProgressRequest( + @NotBlank String status, + @NotBlank String message, + Integer recordsProcessed, + Integer recordsSkipped, + Integer totalRecords +) {} diff --git a/src/main/java/gg/modl/backend/migration/service/MigrationMessages.java b/src/main/java/gg/modl/backend/migration/service/MigrationMessages.java new file mode 100644 index 0000000..8cacbd8 --- /dev/null +++ b/src/main/java/gg/modl/backend/migration/service/MigrationMessages.java @@ -0,0 +1,13 @@ +package gg.modl.backend.migration.service; + +final class MigrationMessages { + private MigrationMessages() { + } + + static String truncate(String value, int maxLength) { + if (value.length() > maxLength) { + return value.substring(0, maxLength - 1) + "…"; + } + return value; + } +} diff --git a/src/main/java/gg/modl/backend/migration/service/MigrationProcessor.java b/src/main/java/gg/modl/backend/migration/service/MigrationProcessor.java index 708bf60..8cf8579 100644 --- a/src/main/java/gg/modl/backend/migration/service/MigrationProcessor.java +++ b/src/main/java/gg/modl/backend/migration/service/MigrationProcessor.java @@ -1,849 +1,358 @@ -package gg.modl.backend.migration.service; - -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonToken; -import com.fasterxml.jackson.core.StreamReadConstraints; -import com.fasterxml.jackson.databind.ObjectMapper; -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.migration.dto.UpdateProgressRequest; -import gg.modl.backend.migration.validation.MigrationValidator; -import gg.modl.backend.player.PlayerDocumentIdGenerator; -import gg.modl.backend.player.data.IPEntry; -import gg.modl.backend.player.data.NoteEntry; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.player.data.UsernameEntry; -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentEvidence; -import gg.modl.backend.player.data.punishment.PunishmentModification; -import gg.modl.backend.player.data.punishment.PunishmentModificationType; -import gg.modl.backend.player.data.punishment.PunishmentNote; -import gg.modl.backend.player.data.punishment.PunishmentStatus; -import gg.modl.backend.server.data.Server; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import gg.modl.backend.infrastructure.util.IdGenerator; -import gg.modl.backend.infrastructure.validation.SafeUrls; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class MigrationProcessor { - private final PlayerMongoRepository playerRepository; - private final MigrationService migrationService; - private final MigrationValidator validator; - private final ObjectMapper objectMapper; - - private static final int BATCH_SIZE = 500; - private static final int PROGRESS_UPDATE_INTERVAL = 1000; - private static final int MAX_JSON_NESTING_DEPTH = 100; - private static final int MAX_JSON_STRING_LENGTH = 1_000_000; - private static final int MAX_FAILURE_MESSAGE_LENGTH = 900; - private static final String PLAYERS_FIELD = "players"; - private static final String METADATA_FIELD = "metadata"; - private static final String PLAYER_COUNT_FIELD = "playerCount"; - - @Async("migrationTaskExecutor") - public void processFileAsync(Server server, Path filePath) { - try { - processFile(server, filePath); - } catch (Exception e) { - log.error("Async migration processing failed", e); - } - } - - public void processFile(Server server, Path filePath) { - ProgressCounters counters = new ProgressCounters(); - - try { - migrationService.updateProgress(server, new UpdateProgressRequest( - "processing_data", - "Reading and validating migration file...", - 0, 0, null - )); - - ObjectMapper constrainedMapper = objectMapper.copy(); - constrainedMapper.getFactory().setStreamReadConstraints(StreamReadConstraints.builder() - .maxNestingDepth(MAX_JSON_NESTING_DEPTH) - .maxStringLength(MAX_JSON_STRING_LENGTH) - .build()); - - if (streamMigrationFile(server, filePath, constrainedMapper, counters)) { - return; - } - - try { - migrationService.updateProgress(server, new UpdateProgressRequest( - "completed", - "Migration completed successfully", - counters.processed(), counters.skipped(), counters.total() - )); - } catch (Exception e) { - log.error("Failed to persist terminal migration state (completed)", e); - } - - } catch (MigrationDataException e) { - failMigration(server, e.getMessage(), counters); - } catch (Exception e) { - log.error("Error processing migration file", e); - failMigration(server, boundFailureMessage("Migration failed: ", e.getMessage()), counters); - } finally { - try { - Files.deleteIfExists(filePath); - } catch (IOException e) { - log.warn("Failed to delete migration file: {}", filePath, e); - } - } - } - - private void failMigration(Server server, String message, ProgressCounters counters) { - try { - migrationService.updateProgress(server, new UpdateProgressRequest( - "failed", message, counters.processed(), counters.skipped(), null - )); - } catch (Exception e) { - log.error("Failed to persist terminal migration state (failed)", e); - } - } - - private boolean streamMigrationFile(Server server, Path filePath, ObjectMapper mapper, - ProgressCounters counters) throws IOException { - JsonFactory factory = mapper.getFactory(); - try (JsonParser parser = factory.createParser(filePath.toFile())) { - if (parser.nextToken() != JsonToken.START_OBJECT) { - throw new MigrationDataException("Migration data must be a JSON object"); - } - - boolean playersStreamed = false; - while (parser.nextToken() != JsonToken.END_OBJECT) { - String field = parser.currentName(); - parser.nextToken(); - - if (PLAYERS_FIELD.equals(field)) { - MigrationValidator.ValidationResult header = validator.validateHeader( - true, parser.currentToken() == JsonToken.START_ARRAY, counters.total()); - if (!header.valid()) { - throw new MigrationDataException(header.error()); - } - announceProcessingProgress(server, counters); - if (streamPlayers(server, parser, mapper, counters)) { - return true; - } - playersStreamed = true; - } else if (METADATA_FIELD.equals(field)) { - counters.total(readDeclaredPlayerCount(parser)); - } else { - parser.skipChildren(); - } - } - - if (!playersStreamed) { - throw new MigrationDataException( - validator.validateHeader(false, false, counters.total()).error()); - } - - return false; - } - } - - private boolean streamPlayers(Server server, JsonParser parser, ObjectMapper mapper, - ProgressCounters counters) throws IOException { - long seen = 0; - List> batch = new ArrayList<>(BATCH_SIZE); - - while (parser.nextToken() != JsonToken.END_ARRAY) { - seen++; - if (seen > MigrationValidator.MAX_PLAYER_RECORDS) { - throw new MigrationDataException("Players array exceeds maximum length of 1,000,000"); - } - - if (parser.currentToken() == JsonToken.START_OBJECT) { - batch.add(mapper.readValue(parser, Map.class)); - } else { - parser.skipChildren(); - counters.skipOne(); - } - - if (batch.size() >= BATCH_SIZE && drainBatch(server, batch, counters)) { - return true; - } - } - - if (seen == 0) { - throw new MigrationDataException("Players array cannot be empty"); - } - - return !batch.isEmpty() && drainBatch(server, batch, counters); - } - - private boolean drainBatch(Server server, List> batch, ProgressCounters counters) { - int[] results = processBatch(server, batch); - counters.addProcessed(results[0]); - counters.addSkipped(results[1]); - batch.clear(); - - if (counters.dueForAnnounce(PROGRESS_UPDATE_INTERVAL)) { - announceProcessingProgress(server, counters); - } - - if (!migrationService.isActiveMigrationPresent(server)) { - log.info("Migration cancelled or no longer active; stopping processing after {} records", - counters.processed()); - return true; - } - return false; - } - - private void announceProcessingProgress(Server server, ProgressCounters counters) { - migrationService.updateProgress(server, new UpdateProgressRequest( - "processing_data", - processingMessage(counters.processed(), counters.total()), - counters.processed(), counters.skipped(), counters.total() - )); - } - - private Integer readDeclaredPlayerCount(JsonParser parser) throws IOException { - if (parser.currentToken() != JsonToken.START_OBJECT) { - parser.skipChildren(); - return null; - } - Integer playerCount = null; - while (parser.nextToken() != JsonToken.END_OBJECT) { - String field = parser.currentName(); - parser.nextToken(); - if (PLAYER_COUNT_FIELD.equals(field) && parser.currentToken().isNumeric()) { - long value = parser.getValueAsLong(-1L); - playerCount = (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, value)); - } else { - parser.skipChildren(); - } - } - return playerCount; - } - - private static String processingMessage(int processed, Integer total) { - if (total == null) { - return "Processing player records... (" + processed + ")"; - } - return "Processing player records... (" + processed + "/" + total + ")"; - } - - private static String boundFailureMessage(String prefix, String detail) { - String message = prefix + (detail == null ? "unknown error" : detail); - if (message.length() > MAX_FAILURE_MESSAGE_LENGTH) { - return message.substring(0, MAX_FAILURE_MESSAGE_LENGTH - 1) + "…"; - } - return message; - } - - private int[] processBatch(Server server, List> batch) { - int processed = 0; - int skipped = 0; - - List uuids = new ArrayList<>(); - Map> playerDataMap = new HashMap<>(); - - for (Map playerMap : batch) { - Object uuidObj = playerMap.get("minecraftUuid"); - if (uuidObj == null || !(uuidObj instanceof String)) { - skipped++; - continue; - } - - String uuid = validator.normalizeUuid((String) uuidObj); - if (!validator.isValidUuid(uuid)) { - skipped++; - continue; - } - - uuids.add(uuid); - playerDataMap.put(uuid, playerMap); - } - - if (uuids.isEmpty()) { - return new int[]{0, skipped}; - } - - List existingPlayers = playerRepository.findByMinecraftUuids(server, - uuids.stream().map(UUID::fromString).toList()); - Map existingMap = new HashMap<>(); - for (Player p : existingPlayers) { - existingMap.put(p.getMinecraftUuid().toString(), p); - } - - List toInsert = new ArrayList<>(); - Map mergeUpdates = new HashMap<>(); - - for (String uuid : uuids) { - try { - Map playerMap = playerDataMap.get(uuid); - Player existing = existingMap.get(uuid); - - if (existing != null) { - Update update = buildMergeUpdate(existing, playerMap); - if (update != null) { - mergeUpdates.put(UUID.fromString(uuid), update); - } - } else { - Player newPlayer = buildNewPlayer(uuid, playerMap); - if (newPlayer != null) { - toInsert.add(newPlayer); - } - } - processed++; - } catch (Exception e) { - log.warn("Error processing player {}", uuid, e); - skipped++; - } - } - - if (!toInsert.isEmpty()) { - playerRepository.insertAll(server, toInsert); - } - - if (!mergeUpdates.isEmpty()) { - playerRepository.bulkMergeByUuid(server, mergeUpdates); - } - - return new int[]{processed, skipped}; - } - - private Player buildNewPlayer(String uuid, Map data) { - try { - Object ipObj = data.get("ipAddresses") != null ? data.get("ipAddresses") : data.get("ipList"); - Player player = Player.builder() - .id(PlayerDocumentIdGenerator.generate()) - .minecraftUuid(UUID.fromString(uuid)) - .usernames(parseUsernames(data.get("usernames"))) - .notes(parseNotes(data.get("notes"))) - .ipAddresses(parseIpAddresses(ipObj)) - .punishments(parsePunishments(data.get("punishments"))) - .data(parseData(data.get("data"))) - .build(); - - return player; - } catch (Exception e) { - log.warn("Error building new player for UUID {}", uuid, e); - return null; - } - } - - private List parseIpAddresses(Object data) { - List result = new ArrayList<>(); - if (!(data instanceof List)) { - return result; - } - - for (Object item : (List) data) { - if (!(item instanceof Map)) { - continue; - } - Map map = (Map) item; - - String ipAddress = (String) map.get("ipAddress"); - if (!validator.isValidIpAddress(ipAddress)) { - continue; - } - - Date firstLogin = validator.parseDate(map.get("firstLogin")); - if (firstLogin == null) { - firstLogin = new Date(); - } - - List logins = new ArrayList<>(); - Object loginsObj = map.get("logins"); - if (loginsObj instanceof List) { - for (Object loginObj : (List) loginsObj) { - Date login = validator.parseDate(loginObj); - if (login != null) { - logins.add(login); - } - } - } - - result.add(IPEntry.builder() - .ipAddress(ipAddress) - .country(validator.sanitizeString((String) map.get("country"), 100)) - .region(validator.sanitizeString((String) map.get("region"), 100)) - .asn(validator.sanitizeString((String) map.get("asn"), 100)) - .proxy(Boolean.TRUE.equals(map.get("proxy"))) - .hosting(Boolean.TRUE.equals(map.get("hosting"))) - .firstLogin(firstLogin) - .logins(logins) - .build()); - } - - return result; - } - - @SuppressWarnings("unchecked") - private Map parseData(Object data) { - if (data instanceof Map) { - Map result = new HashMap<>(); - for (Map.Entry entry : ((Map) data).entrySet()) { - if (entry.getKey() instanceof String) { - result.put((String) entry.getKey(), entry.getValue()); - } - } - return result; - } - return new HashMap<>(); - } - - private Update buildMergeUpdate(Player existing, Map newData) { - Update update = new Update(); - boolean hasChanges = false; - - List newUsernames = parseUsernames(newData.get("usernames")); - if (!newUsernames.isEmpty()) { - Set existingNames = new HashSet<>(); - if (existing.getUsernames() != null) { - for (UsernameEntry u : existing.getUsernames()) { - existingNames.add(u.username()); - } - } - List toAddUsernames = new ArrayList<>(); - for (UsernameEntry u : newUsernames) { - if (existingNames.add(u.username())) { - toAddUsernames.add(u); - } - } - if (!toAddUsernames.isEmpty()) { - update.push("usernames").each(toAddUsernames.toArray()); - hasChanges = true; - } - } - - List newNotes = parseNotes(newData.get("notes")); - if (!newNotes.isEmpty()) { - Set existingNoteIds = new HashSet<>(); - if (existing.getNotes() != null) { - for (NoteEntry note : existing.getNotes()) { - existingNoteIds.add(note.getId()); - } - } - List toAddNotes = new ArrayList<>(); - for (NoteEntry note : newNotes) { - if (existingNoteIds.add(note.getId())) { - toAddNotes.add(note); - } - } - if (!toAddNotes.isEmpty()) { - update.push("notes").each(toAddNotes.toArray()); - hasChanges = true; - } - } - - List newPunishments = parsePunishments(newData.get("punishments")); - if (!newPunishments.isEmpty()) { - Set existingIds = new HashSet<>(); - if (existing.getPunishments() != null) { - for (Punishment p : existing.getPunishments()) { - existingIds.add(p.getId()); - } - } - List toAddPunishments = new ArrayList<>(); - for (Punishment p : newPunishments) { - if (existingIds.add(p.getId())) { - toAddPunishments.add(p); - } - } - if (!toAddPunishments.isEmpty()) { - update.push("punishments").each(toAddPunishments.toArray()); - hasChanges = true; - } - } - - Object ipObj = newData.get("ipAddresses") != null ? newData.get("ipAddresses") : newData.get("ipList"); - List newIps = parseIpAddresses(ipObj); - if (!newIps.isEmpty()) { - Set existingIps = new HashSet<>(); - if (existing.getIpAddresses() != null) { - for (IPEntry ip : existing.getIpAddresses()) { - if (ip.getIpAddress() != null) { - existingIps.add(ip.getIpAddress()); - } - } - } - List ipsToAdd = new ArrayList<>(); - for (IPEntry ip : newIps) { - if (ip.getIpAddress() != null && existingIps.add(ip.getIpAddress())) { - ipsToAdd.add(ip); - } - } - if (!ipsToAdd.isEmpty()) { - update.push("ipAddresses").each(ipsToAdd.toArray()); - hasChanges = true; - } - } - - return hasChanges ? update : null; - } - - private List parseUsernames(Object data) { - List result = new ArrayList<>(); - if (!(data instanceof List)) { - return result; - } - - for (Object item : (List) data) { - if (!(item instanceof Map)) { - continue; - } - Map map = (Map) item; - - String username = validator.sanitizeString((String) map.get("username"), 100); - Date date = validator.parseDate(map.get("date")); - - if (username != null && !username.isBlank() && date != null) { - result.add(new UsernameEntry(username, date)); - } - } - - return result; - } - - private List parseNotes(Object data) { - List result = new ArrayList<>(); - if (!(data instanceof List)) { - return result; - } - - for (Object item : (List) data) { - if (!(item instanceof Map)) { - continue; - } - Map map = (Map) item; - - String text = validator.sanitizeString((String) map.get("text"), 5000); - Date date = validator.parseDate(map.get("date")); - String issuerName = validator.sanitizeString((String) map.get("issuerName"), 100); - - if (text != null && date != null && issuerName != null) { - String sourceId = validator.sanitizeString((String) map.get("id"), 100); - String noteId = (sourceId != null && !sourceId.isBlank()) - ? sourceId - : UUID.nameUUIDFromBytes( - (text + "|" + date.getTime() + "|" + issuerName).getBytes(StandardCharsets.UTF_8)) - .toString(); - result.add(new NoteEntry( - noteId, - text, - date, - issuerName, - null - )); - } - } - - return result; - } - - private List parsePunishments(Object data) { - List result = new ArrayList<>(); - if (!(data instanceof List)) { - return result; - } - - for (Object item : (List) data) { - if (!(item instanceof Map)) { - continue; - } - Map map = (Map) item; - - Object idObj = map.get("id") != null ? map.get("id") : map.get("_id"); - String id = idObj instanceof String s ? s : null; - - Date issued = validator.parseDate(map.get("issued")); - if (issued == null) { - continue; - } - - String issuerName = validator.sanitizeString((String) map.get("issuerName"), 100); - if (issuerName == null) { - issuerName = "Unknown"; - } - - Object typeOrdinalObj = map.get("typeOrdinal"); - int typeOrdinal = 0; - if (typeOrdinalObj instanceof Number) { - typeOrdinal = ((Number) typeOrdinalObj).intValue(); - } else if (typeOrdinalObj instanceof String typeOrdinalString) { - try { - typeOrdinal = Integer.parseInt(typeOrdinalString); - } catch (NumberFormatException ignored) { - typeOrdinal = 0; - } - } - - List notes = new ArrayList<>(); - Object notesObj = map.get("notes"); - if (notesObj instanceof List) { - for (Object noteObj : (List) notesObj) { - if (noteObj instanceof Map) { - Map noteMap = (Map) noteObj; - String text = validator.sanitizeString((String) noteMap.get("text"), 5000); - Date date = validator.parseDate(noteMap.get("date")); - String noteIssuer = validator.sanitizeString((String) noteMap.get("issuerName"), 100); - - if (text != null && date != null) { - notes.add(new PunishmentNote(IdGenerator.generateShortId(), text, date, noteIssuer != null ? noteIssuer : "Unknown", null)); - } - } - } - } - - List evidence = parseEvidence(map.get("evidence")); - - List modifications = parseModifications(map.get("modifications")); - - List attachedTicketIds = new ArrayList<>(); - Object ticketIdsObj = map.get("attachedTicketIds"); - if (ticketIdsObj instanceof List) { - for (Object ticketId : (List) ticketIdsObj) { - if (ticketId instanceof String) { - attachedTicketIds.add((String) ticketId); - } - } - } - - Map punishmentData = new HashMap<>(); - Object dataObj = map.get("data"); - if (dataObj instanceof Map) { - for (Map.Entry entry : ((Map) dataObj).entrySet()) { - if (entry.getKey() instanceof String) { - punishmentData.put((String) entry.getKey(), entry.getValue()); - } - } - } - - String reason = validator.sanitizeString((String) map.get("reason"), 1000); - if (reason != null && !reason.isBlank()) { - punishmentData.put("reason", reason); - } - - Object durationObj = map.get("duration"); - if (durationObj instanceof Number) { - punishmentData.put("duration", ((Number) durationObj).longValue()); - } - - Date started = validator.parseDate(map.get("started")); - - if (id == null || id.isBlank()) { - id = "import-" + UUID.nameUUIDFromBytes( - (typeOrdinal + "|" + issued.getTime() + "|" + issuerName + "|" + (reason != null ? reason : "")) - .getBytes(StandardCharsets.UTF_8)).toString(); - } - - Object activeObj = punishmentData.get("active"); - Object pardonedBy = punishmentData.get("pardonedBy"); - boolean sourceInactive = Boolean.FALSE.equals(activeObj) - || (activeObj instanceof String activeStr && "false".equalsIgnoreCase(activeStr)) - || pardonedBy != null; - boolean alreadyPardoned = modifications.stream() - .anyMatch(m -> PunishmentModificationType.isPardon(m.type())); - if (sourceInactive && !alreadyPardoned) { - Date pardonDate = validator.parseDate(punishmentData.get("pardonedDate")); - if (pardonDate == null) { - pardonDate = validator.parseDate(punishmentData.get("removedAt")); - } - if (pardonDate == null) { - pardonDate = issued; - } - String pardonIssuer = pardonedBy instanceof String pardonStr ? pardonStr : "System"; - modifications.add(new PunishmentModification( - IdGenerator.generateShortId(), - PunishmentModificationType.SYSTEM_PARDON.name(), - pardonDate, - pardonIssuer, - null, - "Imported as already removed/inactive", - null, - null, - null - )); - punishmentData.put("status", PunishmentStatus.PARDONED); - } - - Punishment punishment = new Punishment( - id, - typeOrdinal, - issuerName, - null, - issued, - started, - modifications, - notes, - evidence, - attachedTicketIds, - punishmentData.isEmpty() ? null : punishmentData - ); - - result.add(punishment); - } - - return result; - } - - private List parseModifications(Object data) { - List result = new ArrayList<>(); - if (!(data instanceof List)) { - return result; - } - - for (Object item : (List) data) { - if (!(item instanceof Map)) { - continue; - } - Map m = (Map) item; - - String type = validator.sanitizeString((String) m.get("type"), 100); - if (type == null) { - continue; - } - try { - PunishmentModificationType.valueOf(type); - } catch (IllegalArgumentException ignored) { - continue; - } - - Date date = validator.parseDate(m.get("date")); - if (date == null) { - continue; - } - - String sourceId = validator.sanitizeString((String) m.get("id"), 100); - String id = (sourceId != null && !sourceId.isBlank()) ? sourceId : IdGenerator.generateShortId(); - - String issuerName = validator.sanitizeString((String) m.get("issuerName"), 100); - String issuerId = validator.sanitizeString((String) m.get("issuerId"), 100); - - String reason = validator.sanitizeString((String) m.get("reason"), 1000); - if (reason == null) { - reason = ""; - } - - Long effectiveDuration = (m.get("effectiveDuration") instanceof Number n) ? n.longValue() : null; - String appealTicketId = validator.sanitizeString((String) m.get("appealTicketId"), 100); - - Map modData = null; - if (m.get("data") instanceof Map) { - modData = new HashMap<>(); - for (Map.Entry entry : ((Map) m.get("data")).entrySet()) { - if (entry.getKey() instanceof String) { - modData.put((String) entry.getKey(), entry.getValue()); - } - } - } - - result.add(new PunishmentModification( - id, type, date, issuerName, issuerId, reason, effectiveDuration, appealTicketId, modData)); - } - - return result; - } - - private List parseEvidence(Object data) { - List result = new ArrayList<>(); - if (!(data instanceof List)) { - return result; - } - - for (Object item : (List) data) { - if (item instanceof String) { - String text = validator.sanitizeString((String) item, 5000); - if (text == null || text.isBlank()) { - continue; - } - result.add(new PunishmentEvidence(text, null, "text", null, null, new Date(), null, null, null)); - } else if (item instanceof Map) { - Map m = (Map) item; - String text = validator.sanitizeString((String) m.get("text"), 5000); - String sanitizedUrl = validator.sanitizeString((String) m.get("url"), 2000); - String url = SafeUrls.isSafe(sanitizedUrl) ? sanitizedUrl : null; - String type = validator.sanitizeString((String) m.get("type"), 100); - if (type == null || type.isBlank()) { - type = "link"; - } - String uploadedBy = validator.sanitizeString((String) m.get("uploadedBy"), 100); - String uploadedById = validator.sanitizeString((String) m.get("uploadedById"), 100); - Date uploadedAt = validator.parseDate(m.get("uploadedAt")); - if (uploadedAt == null) { - uploadedAt = new Date(); - } - String fileName = validator.sanitizeString((String) m.get("fileName"), 500); - String fileType = validator.sanitizeString((String) m.get("fileType"), 100); - Long fileSize = (m.get("fileSize") instanceof Number n) ? n.longValue() : null; - - result.add(new PunishmentEvidence( - text, url, type, uploadedBy, uploadedById, uploadedAt, fileName, fileType, fileSize)); - } - } - - return result; - } - - private static final class ProgressCounters { - private int processed; - private int skipped; - private int lastAnnounced; - private Integer total; - - private boolean dueForAnnounce(int interval) { - if (processed - lastAnnounced >= interval) { - lastAnnounced = processed; - return true; - } - return false; - } - - private void addProcessed(int value) { - processed += value; - } - - private void addSkipped(int value) { - skipped += value; - } - - private void skipOne() { - skipped++; - } - - private int processed() { - return processed; - } - - private int skipped() { - return skipped; - } - - private Integer total() { - return total; - } - - private void total(Integer value) { - total = value; - } - } - - private static final class MigrationDataException extends RuntimeException { - private MigrationDataException(String message) { - super(message); - } - } -} +package gg.modl.backend.migration.service; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.migration.dto.UpdateProgressRequest; +import gg.modl.backend.migration.validation.MigrationValidator; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.server.data.Server; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MigrationProcessor { + private final PlayerMongoRepository playerRepository; + private final MigrationService migrationService; + private final MigrationValidator validator; + private final ObjectMapper objectMapper; + private final MigrationRecordMapper recordMapper; + + private static final int BATCH_SIZE = 500; + private static final int PROGRESS_UPDATE_INTERVAL = 1000; + private static final int MAX_JSON_NESTING_DEPTH = 100; + private static final int MAX_JSON_STRING_LENGTH = 1_000_000; + private static final int MAX_FAILURE_MESSAGE_LENGTH = 900; + private static final String PLAYERS_FIELD = "players"; + private static final String METADATA_FIELD = "metadata"; + private static final String PLAYER_COUNT_FIELD = "playerCount"; + + @Async("migrationTaskExecutor") + public void processFileAsync(Server server, Path filePath) { + try { + processFile(server, filePath); + } catch (Exception e) { + log.error("Async migration processing failed", e); + } + } + + public void processFile(Server server, Path filePath) { + ProgressCounters counters = new ProgressCounters(); + + try { + migrationService.updateProgress(server, new UpdateProgressRequest( + "processing_data", + "Reading and validating migration file...", + 0, 0, null + )); + + ObjectMapper constrainedMapper = objectMapper.copy(); + constrainedMapper.getFactory().setStreamReadConstraints(StreamReadConstraints.builder() + .maxNestingDepth(MAX_JSON_NESTING_DEPTH) + .maxStringLength(MAX_JSON_STRING_LENGTH) + .build()); + + if (streamMigrationFile(server, filePath, constrainedMapper, counters)) { + return; + } + + try { + migrationService.updateProgress(server, new UpdateProgressRequest( + "completed", + "Migration completed successfully", + counters.processed(), counters.skipped(), counters.total() + )); + } catch (Exception e) { + log.error("Failed to persist terminal migration state (completed)", e); + } + + } catch (MigrationDataException e) { + failMigration(server, e.getMessage(), counters); + } catch (Exception e) { + log.error("Error processing migration file", e); + failMigration(server, boundFailureMessage("Migration failed: ", e.getMessage()), counters); + } finally { + try { + Files.deleteIfExists(filePath); + } catch (IOException e) { + log.warn("Failed to delete migration file: {}", filePath, e); + } + } + } + + private void failMigration(Server server, String message, ProgressCounters counters) { + try { + migrationService.updateProgress(server, new UpdateProgressRequest( + "failed", message, counters.processed(), counters.skipped(), null + )); + } catch (Exception e) { + log.error("Failed to persist terminal migration state (failed)", e); + } + } + + private boolean streamMigrationFile(Server server, Path filePath, ObjectMapper mapper, + ProgressCounters counters) throws IOException { + JsonFactory factory = mapper.getFactory(); + try (JsonParser parser = factory.createParser(filePath.toFile())) { + if (parser.nextToken() != JsonToken.START_OBJECT) { + throw new MigrationDataException("Migration data must be a JSON object"); + } + + boolean playersStreamed = false; + while (parser.nextToken() != JsonToken.END_OBJECT) { + String field = parser.currentName(); + parser.nextToken(); + + if (PLAYERS_FIELD.equals(field)) { + MigrationValidator.ValidationResult header = validator.validateHeader( + true, parser.currentToken() == JsonToken.START_ARRAY, counters.total()); + if (!header.valid()) { + throw new MigrationDataException(header.error()); + } + announceProcessingProgress(server, counters); + if (streamPlayers(server, parser, mapper, counters)) { + return true; + } + playersStreamed = true; + } else if (METADATA_FIELD.equals(field)) { + counters.total(readDeclaredPlayerCount(parser)); + } else { + parser.skipChildren(); + } + } + + if (!playersStreamed) { + throw new MigrationDataException( + validator.validateHeader(false, false, counters.total()).error()); + } + + return false; + } + } + + private boolean streamPlayers(Server server, JsonParser parser, ObjectMapper mapper, + ProgressCounters counters) throws IOException { + long seen = 0; + List> batch = new ArrayList<>(BATCH_SIZE); + + while (parser.nextToken() != JsonToken.END_ARRAY) { + seen++; + if (seen > MigrationValidator.MAX_PLAYER_RECORDS) { + throw new MigrationDataException("Players array exceeds maximum length of 1,000,000"); + } + + if (parser.currentToken() == JsonToken.START_OBJECT) { + batch.add(mapper.readValue(parser, Map.class)); + } else { + parser.skipChildren(); + counters.skipOne(); + } + + if (batch.size() >= BATCH_SIZE && drainBatch(server, batch, counters)) { + return true; + } + } + + if (seen == 0) { + throw new MigrationDataException("Players array cannot be empty"); + } + + return !batch.isEmpty() && drainBatch(server, batch, counters); + } + + private boolean drainBatch(Server server, List> batch, ProgressCounters counters) { + BatchResult results = processBatch(server, batch); + counters.addProcessed(results.processed()); + counters.addSkipped(results.skipped()); + batch.clear(); + + if (counters.dueForAnnounce(PROGRESS_UPDATE_INTERVAL)) { + announceProcessingProgress(server, counters); + } + + if (!migrationService.isActiveMigrationPresent(server)) { + log.info("Migration cancelled or no longer active; stopping processing after {} records", + counters.processed()); + return true; + } + return false; + } + + private void announceProcessingProgress(Server server, ProgressCounters counters) { + migrationService.updateProgress(server, new UpdateProgressRequest( + "processing_data", + processingMessage(counters.processed(), counters.total()), + counters.processed(), counters.skipped(), counters.total() + )); + } + + private Integer readDeclaredPlayerCount(JsonParser parser) throws IOException { + if (parser.currentToken() != JsonToken.START_OBJECT) { + parser.skipChildren(); + return null; + } + Integer playerCount = null; + while (parser.nextToken() != JsonToken.END_OBJECT) { + String field = parser.currentName(); + parser.nextToken(); + if (PLAYER_COUNT_FIELD.equals(field) && parser.currentToken().isNumeric()) { + long value = parser.getValueAsLong(-1L); + playerCount = (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, value)); + } else { + parser.skipChildren(); + } + } + return playerCount; + } + + private static String processingMessage(int processed, Integer total) { + if (total == null) { + return "Processing player records... (" + processed + ")"; + } + return "Processing player records... (" + processed + "/" + total + ")"; + } + + private static String boundFailureMessage(String prefix, String detail) { + return MigrationMessages.truncate(prefix + (detail == null ? "unknown error" : detail), + MAX_FAILURE_MESSAGE_LENGTH); + } + + private BatchResult processBatch(Server server, List> batch) { + int processed = 0; + int skipped = 0; + + List uuids = new ArrayList<>(); + Map> playerDataMap = new HashMap<>(); + + for (Map playerMap : batch) { + Object uuidObj = playerMap.get("minecraftUuid"); + if (!(uuidObj instanceof String uuidStr)) { + skipped++; + continue; + } + + String uuid = validator.normalizeUuid(uuidStr); + if (!validator.isValidUuid(uuid)) { + skipped++; + continue; + } + + uuids.add(uuid); + playerDataMap.put(uuid, playerMap); + } + + if (uuids.isEmpty()) { + return new BatchResult(0, skipped); + } + + List existingPlayers = playerRepository.findByMinecraftUuids(server, + uuids.stream().map(UUID::fromString).toList()); + Map existingMap = new HashMap<>(); + for (Player p : existingPlayers) { + existingMap.put(p.getMinecraftUuid().toString(), p); + } + + List toInsert = new ArrayList<>(); + Map mergeUpdates = new HashMap<>(); + + for (String uuid : uuids) { + try { + Map playerMap = playerDataMap.get(uuid); + Player existing = existingMap.get(uuid); + + if (existing != null) { + Update update = recordMapper.buildMergeUpdate(existing, playerMap); + if (update != null) { + mergeUpdates.put(UUID.fromString(uuid), update); + } + } else { + Player newPlayer = recordMapper.buildNewPlayer(uuid, playerMap); + if (newPlayer != null) { + toInsert.add(newPlayer); + } + } + processed++; + } catch (Exception e) { + log.warn("Error processing player {}", uuid, e); + skipped++; + } + } + + if (!toInsert.isEmpty()) { + playerRepository.insertAll(server, toInsert); + } + + if (!mergeUpdates.isEmpty()) { + playerRepository.bulkMergeByUuid(server, mergeUpdates); + } + + return new BatchResult(processed, skipped); + } + + private record BatchResult(int processed, int skipped) {} + + private static final class ProgressCounters { + private int processed; + private int skipped; + private int lastAnnounced; + private Integer total; + + private boolean dueForAnnounce(int interval) { + if (processed - lastAnnounced >= interval) { + lastAnnounced = processed; + return true; + } + return false; + } + + private void addProcessed(int value) { + processed += value; + } + + private void addSkipped(int value) { + skipped += value; + } + + private void skipOne() { + skipped++; + } + + private int processed() { + return processed; + } + + private int skipped() { + return skipped; + } + + private Integer total() { + return total; + } + + private void total(Integer value) { + total = value; + } + } + + private static final class MigrationDataException extends RuntimeException { + private MigrationDataException(String message) { + super(message); + } + } +} diff --git a/src/main/java/gg/modl/backend/migration/service/MigrationRecordMapper.java b/src/main/java/gg/modl/backend/migration/service/MigrationRecordMapper.java new file mode 100644 index 0000000..0e65e72 --- /dev/null +++ b/src/main/java/gg/modl/backend/migration/service/MigrationRecordMapper.java @@ -0,0 +1,503 @@ +package gg.modl.backend.migration.service; + +import gg.modl.backend.infrastructure.util.IdGenerator; +import gg.modl.backend.infrastructure.validation.SafeUrls; +import gg.modl.backend.migration.validation.MigrationValidator; +import gg.modl.backend.player.PlayerDocumentIdGenerator; +import gg.modl.backend.player.data.IPEntry; +import gg.modl.backend.player.data.NoteEntry; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.UsernameEntry; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentEvidence; +import gg.modl.backend.player.data.punishment.PunishmentModification; +import gg.modl.backend.player.data.punishment.PunishmentModificationType; +import gg.modl.backend.player.data.punishment.PunishmentNote; +import gg.modl.backend.player.data.punishment.PunishmentStatus; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MigrationRecordMapper { + private final MigrationValidator validator; + + public Player buildNewPlayer(String uuid, Map data) { + try { + Object ipObj = data.get("ipAddresses") != null ? data.get("ipAddresses") : data.get("ipList"); + Player player = Player.builder() + .id(PlayerDocumentIdGenerator.generate()) + .minecraftUuid(UUID.fromString(uuid)) + .usernames(parseUsernames(data.get("usernames"))) + .notes(parseNotes(data.get("notes"))) + .ipAddresses(parseIpAddresses(ipObj)) + .punishments(parsePunishments(data.get("punishments"))) + .data(parseData(data.get("data"))) + .build(); + + return player; + } catch (Exception e) { + log.warn("Error building new player for UUID {}", uuid, e); + return null; + } + } + + public Update buildMergeUpdate(Player existing, Map newData) { + Update update = new Update(); + boolean hasChanges = false; + + List newUsernames = parseUsernames(newData.get("usernames")); + if (!newUsernames.isEmpty()) { + Set existingNames = new HashSet<>(); + if (existing.getUsernames() != null) { + for (UsernameEntry u : existing.getUsernames()) { + existingNames.add(u.username()); + } + } + List toAddUsernames = new ArrayList<>(); + for (UsernameEntry u : newUsernames) { + if (existingNames.add(u.username())) { + toAddUsernames.add(u); + } + } + if (!toAddUsernames.isEmpty()) { + update.push("usernames").each(toAddUsernames.toArray()); + hasChanges = true; + } + } + + List newNotes = parseNotes(newData.get("notes")); + if (!newNotes.isEmpty()) { + Set existingNoteIds = new HashSet<>(); + if (existing.getNotes() != null) { + for (NoteEntry note : existing.getNotes()) { + existingNoteIds.add(note.getId()); + } + } + List toAddNotes = new ArrayList<>(); + for (NoteEntry note : newNotes) { + if (existingNoteIds.add(note.getId())) { + toAddNotes.add(note); + } + } + if (!toAddNotes.isEmpty()) { + update.push("notes").each(toAddNotes.toArray()); + hasChanges = true; + } + } + + List newPunishments = parsePunishments(newData.get("punishments")); + if (!newPunishments.isEmpty()) { + Set existingIds = new HashSet<>(); + if (existing.getPunishments() != null) { + for (Punishment p : existing.getPunishments()) { + existingIds.add(p.getId()); + } + } + List toAddPunishments = new ArrayList<>(); + for (Punishment p : newPunishments) { + if (existingIds.add(p.getId())) { + toAddPunishments.add(p); + } + } + if (!toAddPunishments.isEmpty()) { + update.push("punishments").each(toAddPunishments.toArray()); + hasChanges = true; + } + } + + Object ipObj = newData.get("ipAddresses") != null ? newData.get("ipAddresses") : newData.get("ipList"); + List newIps = parseIpAddresses(ipObj); + if (!newIps.isEmpty()) { + Set existingIps = new HashSet<>(); + if (existing.getIpAddresses() != null) { + for (IPEntry ip : existing.getIpAddresses()) { + if (ip.getIpAddress() != null) { + existingIps.add(ip.getIpAddress()); + } + } + } + List ipsToAdd = new ArrayList<>(); + for (IPEntry ip : newIps) { + if (ip.getIpAddress() != null && existingIps.add(ip.getIpAddress())) { + ipsToAdd.add(ip); + } + } + if (!ipsToAdd.isEmpty()) { + update.push("ipAddresses").each(ipsToAdd.toArray()); + hasChanges = true; + } + } + + return hasChanges ? update : null; + } + + private List parseUsernames(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List items)) { + return result; + } + + for (Object item : items) { + if (!(item instanceof Map map)) { + continue; + } + + String username = validator.sanitizeString((String) map.get("username"), 100); + Date date = validator.parseDate(map.get("date")); + + if (username != null && !username.isBlank() && date != null) { + result.add(new UsernameEntry(username, date)); + } + } + + return result; + } + + private List parseNotes(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List items)) { + return result; + } + + for (Object item : items) { + if (!(item instanceof Map map)) { + continue; + } + + String text = validator.sanitizeString((String) map.get("text"), 5000); + Date date = validator.parseDate(map.get("date")); + String issuerName = validator.sanitizeString((String) map.get("issuerName"), 100); + + if (text != null && date != null && issuerName != null) { + String sourceId = validator.sanitizeString((String) map.get("id"), 100); + String noteId = (sourceId != null && !sourceId.isBlank()) + ? sourceId + : UUID.nameUUIDFromBytes( + (text + "|" + date.getTime() + "|" + issuerName).getBytes(StandardCharsets.UTF_8)) + .toString(); + result.add(new NoteEntry( + noteId, + text, + date, + issuerName, + null + )); + } + } + + return result; + } + + private List parseIpAddresses(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List items)) { + return result; + } + + for (Object item : items) { + if (!(item instanceof Map map)) { + continue; + } + + String ipAddress = (String) map.get("ipAddress"); + if (!validator.isValidIpAddress(ipAddress)) { + continue; + } + + Date firstLogin = validator.parseDate(map.get("firstLogin")); + if (firstLogin == null) { + firstLogin = new Date(); + } + + List logins = new ArrayList<>(); + Object loginsObj = map.get("logins"); + if (loginsObj instanceof List loginItems) { + for (Object loginObj : loginItems) { + Date login = validator.parseDate(loginObj); + if (login != null) { + logins.add(login); + } + } + } + + result.add(IPEntry.builder() + .ipAddress(ipAddress) + .country(validator.sanitizeString((String) map.get("country"), 100)) + .region(validator.sanitizeString((String) map.get("region"), 100)) + .asn(validator.sanitizeString((String) map.get("asn"), 100)) + .proxy(Boolean.TRUE.equals(map.get("proxy"))) + .hosting(Boolean.TRUE.equals(map.get("hosting"))) + .firstLogin(firstLogin) + .logins(logins) + .build()); + } + + return result; + } + + private List parsePunishments(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List items)) { + return result; + } + + for (Object item : items) { + if (!(item instanceof Map map)) { + continue; + } + + Object idObj = map.get("id") != null ? map.get("id") : map.get("_id"); + String id = idObj instanceof String s ? s : null; + + Date issued = validator.parseDate(map.get("issued")); + if (issued == null) { + continue; + } + + String issuerName = validator.sanitizeString((String) map.get("issuerName"), 100); + if (issuerName == null) { + issuerName = "Unknown"; + } + + Object typeOrdinalObj = map.get("typeOrdinal"); + int typeOrdinal = 0; + if (typeOrdinalObj instanceof Number number) { + typeOrdinal = number.intValue(); + } else if (typeOrdinalObj instanceof String typeOrdinalString) { + try { + typeOrdinal = Integer.parseInt(typeOrdinalString); + } catch (NumberFormatException ignored) { + typeOrdinal = 0; + } + } + + List notes = new ArrayList<>(); + Object notesObj = map.get("notes"); + if (notesObj instanceof List noteItems) { + for (Object noteObj : noteItems) { + if (noteObj instanceof Map noteMap) { + String text = validator.sanitizeString((String) noteMap.get("text"), 5000); + Date date = validator.parseDate(noteMap.get("date")); + String noteIssuer = validator.sanitizeString((String) noteMap.get("issuerName"), 100); + + if (text != null && date != null) { + notes.add(new PunishmentNote(IdGenerator.generateShortId(), text, date, noteIssuer != null ? noteIssuer : "Unknown", null)); + } + } + } + } + + List evidence = parseEvidence(map.get("evidence")); + + List modifications = parseModifications(map.get("modifications")); + + List attachedTicketIds = new ArrayList<>(); + Object ticketIdsObj = map.get("attachedTicketIds"); + if (ticketIdsObj instanceof List ticketItems) { + for (Object ticketId : ticketItems) { + if (ticketId instanceof String ticketIdStr) { + attachedTicketIds.add(ticketIdStr); + } + } + } + + Map punishmentData = new HashMap<>(); + Object dataObj = map.get("data"); + if (dataObj instanceof Map dataMap) { + for (Map.Entry entry : dataMap.entrySet()) { + if (entry.getKey() instanceof String key) { + punishmentData.put(key, entry.getValue()); + } + } + } + + String reason = validator.sanitizeString((String) map.get("reason"), 1000); + if (reason != null && !reason.isBlank()) { + punishmentData.put("reason", reason); + } + + Object durationObj = map.get("duration"); + if (durationObj instanceof Number durationNumber) { + punishmentData.put("duration", durationNumber.longValue()); + } + + Date started = validator.parseDate(map.get("started")); + + if (id == null || id.isBlank()) { + id = "import-" + UUID.nameUUIDFromBytes( + (typeOrdinal + "|" + issued.getTime() + "|" + issuerName + "|" + (reason != null ? reason : "")) + .getBytes(StandardCharsets.UTF_8)).toString(); + } + + Object activeObj = punishmentData.get("active"); + Object pardonedBy = punishmentData.get("pardonedBy"); + boolean sourceInactive = Boolean.FALSE.equals(activeObj) + || (activeObj instanceof String activeStr && "false".equalsIgnoreCase(activeStr)) + || pardonedBy != null; + boolean alreadyPardoned = modifications.stream() + .anyMatch(m -> PunishmentModificationType.isPardon(m.type())); + if (sourceInactive && !alreadyPardoned) { + Date pardonDate = validator.parseDate(punishmentData.get("pardonedDate")); + if (pardonDate == null) { + pardonDate = validator.parseDate(punishmentData.get("removedAt")); + } + if (pardonDate == null) { + pardonDate = issued; + } + String pardonIssuer = pardonedBy instanceof String pardonStr ? pardonStr : "System"; + modifications.add(new PunishmentModification( + IdGenerator.generateShortId(), + PunishmentModificationType.SYSTEM_PARDON.name(), + pardonDate, + pardonIssuer, + null, + "Imported as already removed/inactive", + null, + null, + null + )); + punishmentData.put("status", PunishmentStatus.PARDONED); + } + + Punishment punishment = new Punishment( + id, + typeOrdinal, + issuerName, + null, + issued, + started, + modifications, + notes, + evidence, + attachedTicketIds, + punishmentData.isEmpty() ? null : punishmentData + ); + + result.add(punishment); + } + + return result; + } + + private List parseModifications(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List items)) { + return result; + } + + for (Object item : items) { + if (!(item instanceof Map m)) { + continue; + } + + String type = validator.sanitizeString((String) m.get("type"), 100); + if (type == null) { + continue; + } + try { + PunishmentModificationType.valueOf(type); + } catch (IllegalArgumentException ignored) { + continue; + } + + Date date = validator.parseDate(m.get("date")); + if (date == null) { + continue; + } + + String sourceId = validator.sanitizeString((String) m.get("id"), 100); + String id = (sourceId != null && !sourceId.isBlank()) ? sourceId : IdGenerator.generateShortId(); + + String issuerName = validator.sanitizeString((String) m.get("issuerName"), 100); + String issuerId = validator.sanitizeString((String) m.get("issuerId"), 100); + + String reason = validator.sanitizeString((String) m.get("reason"), 1000); + if (reason == null) { + reason = ""; + } + + Long effectiveDuration = (m.get("effectiveDuration") instanceof Number n) ? n.longValue() : null; + String appealTicketId = validator.sanitizeString((String) m.get("appealTicketId"), 100); + + Map modData = null; + if (m.get("data") instanceof Map dataMap) { + modData = new HashMap<>(); + for (Map.Entry entry : dataMap.entrySet()) { + if (entry.getKey() instanceof String key) { + modData.put(key, entry.getValue()); + } + } + } + + result.add(new PunishmentModification( + id, type, date, issuerName, issuerId, reason, effectiveDuration, appealTicketId, modData)); + } + + return result; + } + + private List parseEvidence(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List items)) { + return result; + } + + for (Object item : items) { + if (item instanceof String str) { + String text = validator.sanitizeString(str, 5000); + if (text == null || text.isBlank()) { + continue; + } + result.add(new PunishmentEvidence(text, null, "text", null, null, new Date(), null, null, null)); + } else if (item instanceof Map m) { + String text = validator.sanitizeString((String) m.get("text"), 5000); + String sanitizedUrl = validator.sanitizeString((String) m.get("url"), 2000); + String url = SafeUrls.isSafe(sanitizedUrl) ? sanitizedUrl : null; + String type = validator.sanitizeString((String) m.get("type"), 100); + if (type == null || type.isBlank()) { + type = "link"; + } + String uploadedBy = validator.sanitizeString((String) m.get("uploadedBy"), 100); + String uploadedById = validator.sanitizeString((String) m.get("uploadedById"), 100); + Date uploadedAt = validator.parseDate(m.get("uploadedAt")); + if (uploadedAt == null) { + uploadedAt = new Date(); + } + String fileName = validator.sanitizeString((String) m.get("fileName"), 500); + String fileType = validator.sanitizeString((String) m.get("fileType"), 100); + Long fileSize = (m.get("fileSize") instanceof Number n) ? n.longValue() : null; + + result.add(new PunishmentEvidence( + text, url, type, uploadedBy, uploadedById, uploadedAt, fileName, fileType, fileSize)); + } + } + + return result; + } + + private Map parseData(Object data) { + if (data instanceof Map dataMap) { + Map result = new HashMap<>(); + for (Map.Entry entry : dataMap.entrySet()) { + if (entry.getKey() instanceof String key) { + result.put(key, entry.getValue()); + } + } + return result; + } + return new HashMap<>(); + } +} diff --git a/src/main/java/gg/modl/backend/migration/service/MigrationService.java b/src/main/java/gg/modl/backend/migration/service/MigrationService.java index 2203083..6f82246 100644 --- a/src/main/java/gg/modl/backend/migration/service/MigrationService.java +++ b/src/main/java/gg/modl/backend/migration/service/MigrationService.java @@ -1,236 +1,246 @@ -package gg.modl.backend.migration.service; - -import gg.modl.backend.database.mongo.repository.MigrationMongoRepository; -import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.limits.ServerLimitPolicy; -import gg.modl.backend.migration.data.MigrationStatus; -import gg.modl.backend.migration.dto.UpdateProgressRequest; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.SyncMigrationTask; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import gg.modl.backend.migration.config.MigrationConfiguration; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.Nullable; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -@Service -@RequiredArgsConstructor -@Slf4j -public class MigrationService { - private final MigrationMongoRepository migrationRepository; - private final MigrationConfiguration migrationConfiguration; - private final RealtimeEventPublisher publisher; - private final ServerLimitPolicy serverLimitPolicy; - - public record CooldownState(boolean onCooldown, @Nullable Long remainingTime) {} - - private static final List VALID_TYPES = List.of("litebans"); - private static final List VALID_STATUSES = List.of( - "idle", "building_json", "uploading_json", "processing_data", "completed", "failed" - ); - private static final long COOLDOWN_MS = 60 * 60 * 1000; - private static final long STALE_MIGRATION_MS = 30 * 60 * 1000; - private static final int MAX_MESSAGE_LENGTH = 1000; - - public Optional getLatestMigration(Server server) { - Date now = new Date(); - Date staleBefore = new Date(now.getTime() - STALE_MIGRATION_MS); - migrationRepository.failStaleMigrations(server, staleBefore, now, - "Migration timed out and was automatically cancelled."); - return migrationRepository.findLatest(server); - } - - public boolean isActiveMigrationPresent(Server server) { - return migrationRepository.existsActiveMigration(server); - } - - public CooldownState checkCooldown(Server server) { - MigrationStatus lastMigration = migrationRepository.findLatestCompletedOrFailed(server).orElse(null); - - if (lastMigration == null || lastMigration.getCompletedAt() == null) { - return new CooldownState(false, null); - } - - long timeSinceCompletion = System.currentTimeMillis() - lastMigration.getCompletedAt().getTime(); - - if (timeSinceCompletion < COOLDOWN_MS) { - return new CooldownState(true, COOLDOWN_MS - timeSinceCompletion); - } - - return new CooldownState(false, null); - } - - public Map startMigration(Server server, String migrationType) { - if (!VALID_TYPES.contains(migrationType.toLowerCase())) { - return Map.of("success", false, "error", "Invalid migration type"); - } - - Date now = new Date(); - Date staleBefore = new Date(now.getTime() - STALE_MIGRATION_MS); - migrationRepository.failStaleMigrations(server, staleBefore, now, - "Migration timed out and was automatically cancelled."); - - if (migrationRepository.existsActiveMigration(server, staleBefore)) { - return Map.of("success", false, "error", "A migration is already in progress"); - } - - CooldownState cooldown = checkCooldown(server); - if (cooldown.onCooldown()) { - return Map.of("success", false, "error", "Migration on cooldown. Please wait before starting another migration."); - } - - String taskId = UUID.randomUUID().toString(); - String type = migrationType.toLowerCase(); - - MigrationStatus status = MigrationStatus.builder() - .taskId(taskId) - .type(type) - .status("building_json") - .progress(MigrationStatus.MigrationProgress.builder() - .message("Waiting for Minecraft server to build migration file...") - .recordsProcessed(0) - .recordsSkipped(0) - .build()) - .startedAt(now) - .build(); - - migrationRepository.saveEntity(server, status); - - publisher.pushMigrationTask(server, SyncMigrationTask.newBuilder() - .setTaskId(taskId) - .setType(type) - .build()); - - return Map.of( - "success", true, - "taskId", taskId, - "message", "Migration task initiated. Waiting for Minecraft server to process." - ); - } - - public Map cancelMigration(Server server) { - MigrationStatus activeMigration = migrationRepository.findActiveMigration(server).orElse(null); - - if (activeMigration == null) { - return Map.of("success", false, "error", "No active migration to cancel"); - } - - boolean cooldownExempt = "building_json".equals(activeMigration.getStatus()); - - migrationRepository.cancelMigration(server, activeMigration.getId(), - "Cancelled by administrator", new Date(), "Migration cancelled by administrator", cooldownExempt); - - return Map.of("success", true, "message", "Migration cancelled successfully"); - } - - public Map validateFileSize(Server server, MultipartFile file) { - long fileSizeLimit = getFileSizeLimit(server); - if (file.getSize() <= fileSizeLimit) { - return null; - } - - double fileSizeMB = file.getSize() / (1024.0 * 1024.0); - double limitMB = fileSizeLimit / (1024.0 * 1024.0); - - updateProgress(server, new UpdateProgressRequest( - "failed", "Migration file exceeds size limit", 0, 0, null - )); - - return Map.of( - "error", "Migration file exceeds size limit", - "message", String.format("File size (%.2fMB) exceeds the limit of %.2fMB.", fileSizeMB, limitMB), - "fileSize", file.getSize(), - "limit", fileSizeLimit - ); - } - - public void updateProgress(Server server, UpdateProgressRequest request) { - if (request.status() == null || !VALID_STATUSES.contains(request.status())) { - throw new ValidationException("Invalid status value"); - } - - if (request.message() == null) { - throw new ValidationException("Message is required"); - } - - if (request.recordsProcessed() != null && request.recordsProcessed() < 0) { - throw new ValidationException("Invalid recordsProcessed value"); - } - - if (request.totalRecords() != null && request.totalRecords() < 0) { - throw new ValidationException("Invalid totalRecords value"); - } - - String message = clampMessage(request.message()); - - MigrationStatus activeMigration = migrationRepository.findActiveMigration(server).orElse(null); - - if (activeMigration == null) { - throw new ResourceNotFoundException("No active migration found"); - } - - Date completedAt = ("completed".equals(request.status()) || "failed".equals(request.status())) - ? new Date() : null; - - migrationRepository.updateProgress(server, activeMigration.getId(), - request.status(), message, - request.recordsProcessed(), request.recordsSkipped(), - request.totalRecords(), completedAt); - } - - private static String clampMessage(String message) { - if (message == null || message.length() <= MAX_MESSAGE_LENGTH) { - return message; - } - return message.substring(0, MAX_MESSAGE_LENGTH - 1) + "…"; - } - - public long getFileSizeLimit(Server server) { - return serverLimitPolicy.resolve(server).getMigrationFileSizeLimit(); - } - - public Path saveUploadedFile(MultipartFile file) { - try { - Path uploadPath = Paths.get(migrationConfiguration.getUploadDir()).toAbsolutePath(); - Files.createDirectories(uploadPath); - String uniqueFilename = "migration-" + UUID.randomUUID() + ".json"; - Path filePath = uploadPath.resolve(uniqueFilename); - file.transferTo(filePath.toAbsolutePath()); - return filePath; - } catch (IOException e) { - throw new ExternalServiceException("Failed to save migration file", e); - } - } - - public void requireActiveMigrationForUpload(Server server) { - if (!migrationRepository.existsActiveMigration(server)) { - throw new ResourceNotFoundException("No active migration found for upload"); - } - } - - public void discardUpload(Server server, Path filePath, String reason) { - try { - Files.deleteIfExists(filePath); - } catch (IOException e) { - log.warn("Failed to delete orphaned migration upload {}", filePath, e); - } - try { - updateProgress(server, new UpdateProgressRequest("failed", reason, 0, 0, null)); - } catch (Exception e) { - log.warn("Failed to mark migration failed after discarding upload {}", filePath, e); - } - } -} +package gg.modl.backend.migration.service; + +import gg.modl.backend.database.mongo.repository.MigrationMongoRepository; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.limits.ServerLimitPolicy; +import gg.modl.backend.migration.data.MigrationStatus; +import gg.modl.backend.migration.dto.UpdateProgressRequest; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.SyncMigrationTask; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import gg.modl.backend.migration.config.MigrationConfiguration; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MigrationService { + private final MigrationMongoRepository migrationRepository; + private final MigrationConfiguration migrationConfiguration; + private final RealtimeEventPublisher publisher; + private final ServerLimitPolicy serverLimitPolicy; + + public record CooldownState(boolean onCooldown, @Nullable Long remainingTime) {} + + public record MigrationOperationResult(boolean success, @Nullable String taskId, + @Nullable String message, @Nullable String error) { + static MigrationOperationResult failure(String error) { + return new MigrationOperationResult(false, null, null, error); + } + + static MigrationOperationResult started(String taskId, String message) { + return new MigrationOperationResult(true, taskId, message, null); + } + + static MigrationOperationResult succeeded(String message) { + return new MigrationOperationResult(true, null, message, null); + } + } + + public record FileSizeError(String error, String message, long fileSize, long limit) {} + + private static final List VALID_TYPES = List.of("litebans"); + private static final List VALID_STATUSES = List.of( + "idle", "building_json", "uploading_json", "processing_data", "completed", "failed" + ); + private static final long COOLDOWN_MS = 60 * 60 * 1000; + private static final long STALE_MIGRATION_MS = 30 * 60 * 1000; + private static final int MAX_MESSAGE_LENGTH = 1000; + + public Optional getLatestMigration(Server server) { + Date now = new Date(); + Date staleBefore = new Date(now.getTime() - STALE_MIGRATION_MS); + migrationRepository.failStaleMigrations(server, staleBefore, now, + "Migration timed out and was automatically cancelled."); + return migrationRepository.findLatest(server); + } + + public boolean isActiveMigrationPresent(Server server) { + return migrationRepository.existsActiveMigration(server); + } + + public CooldownState checkCooldown(Server server) { + MigrationStatus lastMigration = migrationRepository.findLatestCompletedOrFailed(server).orElse(null); + + if (lastMigration == null || lastMigration.getCompletedAt() == null) { + return new CooldownState(false, null); + } + + long timeSinceCompletion = System.currentTimeMillis() - lastMigration.getCompletedAt().getTime(); + + if (timeSinceCompletion < COOLDOWN_MS) { + return new CooldownState(true, COOLDOWN_MS - timeSinceCompletion); + } + + return new CooldownState(false, null); + } + + public MigrationOperationResult startMigration(Server server, String migrationType) { + if (!VALID_TYPES.contains(migrationType.toLowerCase())) { + return MigrationOperationResult.failure("Invalid migration type"); + } + + Date now = new Date(); + Date staleBefore = new Date(now.getTime() - STALE_MIGRATION_MS); + migrationRepository.failStaleMigrations(server, staleBefore, now, + "Migration timed out and was automatically cancelled."); + + if (migrationRepository.existsActiveMigration(server, staleBefore)) { + return MigrationOperationResult.failure("A migration is already in progress"); + } + + CooldownState cooldown = checkCooldown(server); + if (cooldown.onCooldown()) { + return MigrationOperationResult.failure( + "Migration on cooldown. Please wait before starting another migration."); + } + + String taskId = UUID.randomUUID().toString(); + String type = migrationType.toLowerCase(); + + MigrationStatus status = MigrationStatus.builder() + .taskId(taskId) + .type(type) + .status("building_json") + .progress(MigrationStatus.MigrationProgress.builder() + .message("Waiting for Minecraft server to build migration file...") + .recordsProcessed(0) + .recordsSkipped(0) + .build()) + .startedAt(now) + .build(); + + migrationRepository.saveEntity(server, status); + + publisher.pushMigrationTask(server, SyncMigrationTask.newBuilder() + .setTaskId(taskId) + .setType(type) + .build()); + + return MigrationOperationResult.started(taskId, + "Migration task initiated. Waiting for Minecraft server to process."); + } + + public MigrationOperationResult cancelMigration(Server server) { + MigrationStatus activeMigration = migrationRepository.findActiveMigration(server).orElse(null); + + if (activeMigration == null) { + return MigrationOperationResult.failure("No active migration to cancel"); + } + + boolean cooldownExempt = "building_json".equals(activeMigration.getStatus()); + + migrationRepository.cancelMigration(server, activeMigration.getId(), + "Cancelled by administrator", new Date(), "Migration cancelled by administrator", cooldownExempt); + + return MigrationOperationResult.succeeded("Migration cancelled successfully"); + } + + public Optional validateFileSize(Server server, MultipartFile file) { + long fileSizeLimit = getFileSizeLimit(server); + if (file.getSize() <= fileSizeLimit) { + return Optional.empty(); + } + + double fileSizeMB = file.getSize() / (1024.0 * 1024.0); + double limitMB = fileSizeLimit / (1024.0 * 1024.0); + + updateProgress(server, new UpdateProgressRequest( + "failed", "Migration file exceeds size limit", 0, 0, null + )); + + return Optional.of(new FileSizeError( + "Migration file exceeds size limit", + String.format("File size (%.2fMB) exceeds the limit of %.2fMB.", fileSizeMB, limitMB), + file.getSize(), + fileSizeLimit)); + } + + public void updateProgress(Server server, UpdateProgressRequest request) { + if (request.status() == null || !VALID_STATUSES.contains(request.status())) { + throw new ValidationException("Invalid status value"); + } + + if (request.message() == null) { + throw new ValidationException("Message is required"); + } + + if (request.recordsProcessed() != null && request.recordsProcessed() < 0) { + throw new ValidationException("Invalid recordsProcessed value"); + } + + if (request.totalRecords() != null && request.totalRecords() < 0) { + throw new ValidationException("Invalid totalRecords value"); + } + + String message = clampMessage(request.message()); + + MigrationStatus activeMigration = migrationRepository.findActiveMigration(server).orElse(null); + + if (activeMigration == null) { + throw new ResourceNotFoundException("No active migration found"); + } + + Date completedAt = ("completed".equals(request.status()) || "failed".equals(request.status())) + ? new Date() : null; + + migrationRepository.updateProgress(server, activeMigration.getId(), + request.status(), message, + request.recordsProcessed(), request.recordsSkipped(), + request.totalRecords(), completedAt); + } + + private static String clampMessage(String message) { + return message == null ? null : MigrationMessages.truncate(message, MAX_MESSAGE_LENGTH); + } + + public long getFileSizeLimit(Server server) { + return serverLimitPolicy.resolve(server).getMigrationFileSizeLimit(); + } + + public Path saveUploadedFile(MultipartFile file) { + try { + Path uploadPath = Paths.get(migrationConfiguration.getUploadDir()).toAbsolutePath(); + Files.createDirectories(uploadPath); + String uniqueFilename = "migration-" + UUID.randomUUID() + ".json"; + Path filePath = uploadPath.resolve(uniqueFilename); + file.transferTo(filePath.toAbsolutePath()); + return filePath; + } catch (IOException e) { + throw new ExternalServiceException("Failed to save migration file", e); + } + } + + public void requireActiveMigrationForUpload(Server server) { + if (!migrationRepository.existsActiveMigration(server)) { + throw new ResourceNotFoundException("No active migration found for upload"); + } + } + + public void discardUpload(Server server, Path filePath, String reason) { + try { + Files.deleteIfExists(filePath); + } catch (IOException e) { + log.warn("Failed to delete orphaned migration upload {}", filePath, e); + } + try { + updateProgress(server, new UpdateProgressRequest("failed", reason, 0, 0, null)); + } catch (Exception e) { + log.warn("Failed to mark migration failed after discarding upload {}", filePath, e); + } + } +} diff --git a/src/main/java/gg/modl/backend/migration/service/MigrationUploadService.java b/src/main/java/gg/modl/backend/migration/service/MigrationUploadService.java new file mode 100644 index 0000000..5aa43d4 --- /dev/null +++ b/src/main/java/gg/modl/backend/migration/service/MigrationUploadService.java @@ -0,0 +1,66 @@ +package gg.modl.backend.migration.service; + +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.migration.dto.UpdateProgressRequest; +import gg.modl.backend.migration.service.MigrationService.FileSizeError; +import gg.modl.backend.server.data.Server; +import java.nio.file.Path; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.core.task.TaskRejectedException; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class MigrationUploadService { + private final MigrationService migrationService; + private final MigrationProcessor migrationProcessor; + + public UploadResult beginUpload(Server server, MultipartFile file) { + if (file.isEmpty()) { + throw new ValidationException("No file uploaded"); + } + + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || !originalFilename.endsWith(".json")) { + throw new ValidationException("Only JSON files are allowed"); + } + + Optional sizeError = migrationService.validateFileSize(server, file); + if (sizeError.isPresent()) { + return new UploadResult.FileTooLarge(sizeError.get()); + } + + migrationService.requireActiveMigrationForUpload(server); + + Path filePath = migrationService.saveUploadedFile(file); + try { + migrationService.updateProgress(server, new UpdateProgressRequest( + "uploading_json", + "Migration file uploaded successfully. Starting data processing...", + 0, 0, null + )); + migrationProcessor.processFileAsync(server, filePath); + } catch (TaskRejectedException e) { + migrationService.discardUpload(server, filePath, + "Migration processing is busy. Please try again shortly."); + return new UploadResult.ProcessingBusy(); + } catch (RuntimeException e) { + migrationService.discardUpload(server, filePath, "Migration failed to start."); + return new UploadResult.StartFailed(); + } + + return new UploadResult.Success(file.getSize()); + } + + public sealed interface UploadResult { + record Success(long fileSize) implements UploadResult {} + + record FileTooLarge(FileSizeError error) implements UploadResult {} + + record ProcessingBusy() implements UploadResult {} + + record StartFailed() implements UploadResult {} + } +} diff --git a/src/main/java/gg/modl/backend/migration/validation/MigrationValidator.java b/src/main/java/gg/modl/backend/migration/validation/MigrationValidator.java index eaba0a7..171aac0 100644 --- a/src/main/java/gg/modl/backend/migration/validation/MigrationValidator.java +++ b/src/main/java/gg/modl/backend/migration/validation/MigrationValidator.java @@ -1,150 +1,214 @@ -package gg.modl.backend.migration.validation; - -import gg.modl.backend.infrastructure.validation.RegExpConstants; -import java.net.InetAddress; -import java.time.Instant; -import java.time.format.DateTimeParseException; -import java.util.Date; -import java.util.regex.Pattern; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -@Component -@Slf4j -public class MigrationValidator { - public static final int MAX_PLAYER_RECORDS = 1_000_000; - - private static final Pattern UUID_PATTERN = Pattern.compile(RegExpConstants.UUID); - private static final Pattern UUID_NO_DASHES_PATTERN = Pattern.compile("^[0-9a-fA-F]{32}$"); - - public ValidationResult validateHeader(boolean playersPresent, boolean playersIsArray, - Integer declaredPlayerCount) { - if (!playersPresent) { - return ValidationResult.error("Missing 'players' field"); - } - if (!playersIsArray) { - return ValidationResult.error("'players' field must be an array"); - } - if (declaredPlayerCount != null) { - if (declaredPlayerCount <= 0) { - return ValidationResult.error("Players array cannot be empty"); - } - if (declaredPlayerCount > MAX_PLAYER_RECORDS) { - return ValidationResult.error("Players array exceeds maximum length of 1,000,000"); - } - } - return ValidationResult.success(declaredPlayerCount == null ? 0 : declaredPlayerCount); - } - - public boolean isValidUuid(String uuid) { - if (uuid == null || uuid.isBlank()) { - return false; - } - - if (UUID_PATTERN.matcher(uuid).matches()) { - return true; - } - - if (UUID_NO_DASHES_PATTERN.matcher(uuid).matches()) { - return true; - } - - return false; - } - - public String normalizeUuid(String uuid) { - if (uuid == null) { - return null; - } - - String cleaned = uuid.replace("-", "").toLowerCase(); - - if (cleaned.length() != 32) { - return uuid; - } - - return cleaned.substring(0, 8) + "-" + - cleaned.substring(8, 12) + "-" + - cleaned.substring(12, 16) + "-" + - cleaned.substring(16, 20) + "-" + - cleaned.substring(20); - } - - public String sanitizeString(String input, int maxLength) { - if (input == null) { - return null; - } - - String trimmed = input.trim(); - if (trimmed.length() > maxLength) { - trimmed = trimmed.substring(0, maxLength); - } - - return trimmed - .replace("\u0000", "") - .replace("\r", ""); - } - - public Date parseDate(Object dateObj) { - if (dateObj == null) { - return null; - } - - if (dateObj instanceof Date) { - return (Date) dateObj; - } - - if (dateObj instanceof Number) { - long timestamp = ((Number) dateObj).longValue(); - if (timestamp > 100_000_000_000L) { - return new Date(timestamp); - } else { - return new Date(timestamp * 1000); - } - } - - if (dateObj instanceof String) { - String dateStr = (String) dateObj; - try { - return Date.from(Instant.parse(dateStr)); - } catch (DateTimeParseException e) { - try { - long timestamp = Long.parseLong(dateStr); - if (timestamp > 100_000_000_000L) { - return new Date(timestamp); - } else { - return new Date(timestamp * 1000); - } - } catch (NumberFormatException ex) { - log.warn("Unable to parse date: {}", dateStr); - return null; - } - } - } - - return null; - } - - public boolean isValidIpAddress(String ip) { - if (ip == null || ip.isBlank()) { - return false; - } - - try { - InetAddress.getByName(ip); - return true; - } catch (Exception e) { - return false; - } - } - - public record ValidationResult(boolean valid, String error, int playerCount) { - public static ValidationResult success(int playerCount) { - return new ValidationResult(true, null, playerCount); - } - - public static ValidationResult error(String error) { - return new ValidationResult(false, error, 0); - } - } -} +package gg.modl.backend.migration.validation; + +import gg.modl.backend.infrastructure.validation.RegExpConstants; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Date; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class MigrationValidator { + public static final int MAX_PLAYER_RECORDS = 1_000_000; + + private static final Pattern UUID_PATTERN = Pattern.compile(RegExpConstants.UUID); + private static final Pattern UUID_NO_DASHES_PATTERN = Pattern.compile("^[0-9a-fA-F]{32}$"); + + public ValidationResult validateHeader(boolean playersPresent, boolean playersIsArray, + Integer declaredPlayerCount) { + if (!playersPresent) { + return ValidationResult.error("Missing 'players' field"); + } + if (!playersIsArray) { + return ValidationResult.error("'players' field must be an array"); + } + if (declaredPlayerCount != null) { + if (declaredPlayerCount <= 0) { + return ValidationResult.error("Players array cannot be empty"); + } + if (declaredPlayerCount > MAX_PLAYER_RECORDS) { + return ValidationResult.error("Players array exceeds maximum length of 1,000,000"); + } + } + return ValidationResult.success(declaredPlayerCount == null ? 0 : declaredPlayerCount); + } + + public boolean isValidUuid(String uuid) { + if (uuid == null || uuid.isBlank()) { + return false; + } + + return UUID_PATTERN.matcher(uuid).matches() || UUID_NO_DASHES_PATTERN.matcher(uuid).matches(); + } + + public String normalizeUuid(String uuid) { + if (uuid == null) { + return null; + } + + String cleaned = uuid.replace("-", "").toLowerCase(); + + if (cleaned.length() != 32) { + return uuid; + } + + return cleaned.substring(0, 8) + "-" + + cleaned.substring(8, 12) + "-" + + cleaned.substring(12, 16) + "-" + + cleaned.substring(16, 20) + "-" + + cleaned.substring(20); + } + + public String sanitizeString(String input, int maxLength) { + if (input == null) { + return null; + } + + String trimmed = input.trim(); + if (trimmed.length() > maxLength) { + trimmed = trimmed.substring(0, maxLength); + } + + return trimmed + .replace("\u0000", "") + .replace("\r", ""); + } + + public Date parseDate(Object dateObj) { + if (dateObj == null) { + return null; + } + + if (dateObj instanceof Date date) { + return date; + } + + if (dateObj instanceof Number number) { + return epochToDate(number.longValue()); + } + + if (dateObj instanceof String dateStr) { + try { + return Date.from(Instant.parse(dateStr)); + } catch (DateTimeParseException e) { + try { + return epochToDate(Long.parseLong(dateStr)); + } catch (NumberFormatException ex) { + log.warn("Unable to parse date: {}", dateStr); + return null; + } + } + } + + return null; + } + + private Date epochToDate(long timestamp) { + if (timestamp > 100_000_000_000L) { + return new Date(timestamp); + } + return new Date(timestamp * 1000); + } + + public boolean isValidIpAddress(String ip) { + if (ip == null || ip.isBlank()) { + return false; + } + + return ip.indexOf(':') >= 0 ? isValidIpv6(ip) : isValidIpv4(ip); + } + + private static boolean isValidIpv4(String value) { + String[] octets = value.split("\\.", -1); + if (octets.length != 4) { + return false; + } + for (String octet : octets) { + if (!isValidIpv4Octet(octet)) { + return false; + } + } + return true; + } + + private static boolean isValidIpv4Octet(String octet) { + int length = octet.length(); + if (length < 1 || length > 3) { + return false; + } + for (int i = 0; i < length; i++) { + char c = octet.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + if (length > 1 && octet.charAt(0) == '0') { + return false; + } + return Integer.parseInt(octet) <= 255; + } + + private static boolean isValidIpv6(String value) { + int compressionIndex = value.indexOf("::"); + boolean compressed = compressionIndex >= 0; + if (compressed && value.indexOf("::", compressionIndex + 1) >= 0) { + return false; + } + + String[] sides = compressed + ? new String[]{value.substring(0, compressionIndex), value.substring(compressionIndex + 2)} + : new String[]{value}; + + int groupCount = 0; + for (int side = 0; side < sides.length; side++) { + if (sides[side].isEmpty()) { + continue; + } + String[] groups = sides[side].split(":", -1); + for (int i = 0; i < groups.length; i++) { + boolean lastGroup = side == sides.length - 1 && i == groups.length - 1; + String group = groups[i]; + if (group.indexOf('.') >= 0) { + if (!lastGroup || !isValidIpv4(group)) { + return false; + } + groupCount += 2; + } else { + if (!isValidHextet(group)) { + return false; + } + groupCount += 1; + } + } + } + + return compressed ? groupCount < 8 : groupCount == 8; + } + + private static boolean isValidHextet(String group) { + int length = group.length(); + if (length < 1 || length > 4) { + return false; + } + for (int i = 0; i < length; i++) { + char c = group.charAt(i); + boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) { + return false; + } + } + return true; + } + + public record ValidationResult(boolean valid, String error, int playerCount) { + public static ValidationResult success(int playerCount) { + return new ValidationResult(true, null, playerCount); + } + + public static ValidationResult error(String error) { + return new ValidationResult(false, error, 0); + } + } +} diff --git a/src/main/java/gg/modl/backend/player/PlayerResponseMessage.java b/src/main/java/gg/modl/backend/player/PlayerResponseMessage.java index 6a880f0..bcccf28 100644 --- a/src/main/java/gg/modl/backend/player/PlayerResponseMessage.java +++ b/src/main/java/gg/modl/backend/player/PlayerResponseMessage.java @@ -1,6 +1,6 @@ -package gg.modl.backend.player; - -public final class PlayerResponseMessage { - public static final String LOGIN_INVALID_SCHEMA = "Invalid login schema."; - public static final String LOGIN_SUCCESS = "Login saved."; -} +package gg.modl.backend.player; + +public final class PlayerResponseMessage { + public static final String LOGIN_INVALID_SCHEMA = "Invalid login schema."; + public static final String LOGIN_SUCCESS = "Login saved."; +} diff --git a/src/main/java/gg/modl/backend/player/PlayerService.java b/src/main/java/gg/modl/backend/player/PlayerService.java index 6d8697f..91c4cdd 100644 --- a/src/main/java/gg/modl/backend/player/PlayerService.java +++ b/src/main/java/gg/modl/backend/player/PlayerService.java @@ -4,6 +4,7 @@ import gg.modl.backend.player.data.IPEntry; import gg.modl.backend.player.data.NoteEntry; import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.PlayerDataView; import gg.modl.backend.player.data.UsernameEntry; import gg.modl.backend.player.data.punishment.EnforcementCategory; import gg.modl.backend.player.data.punishment.Punishment; @@ -31,6 +32,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import gg.modl.backend.infrastructure.util.IdGenerator; +import gg.modl.backend.infrastructure.util.UuidUtils; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; @@ -64,7 +66,7 @@ public List searchPlayers(Server server, String searchTerm) List players; if (isUuid(normalizedSearch)) { - players = playerRepository.findByMinecraftUuid(server, normalizeUuid(normalizedSearch)) + players = playerRepository.findByMinecraftUuid(server, UuidUtils.normalize(normalizedSearch)) .map(List::of) .orElseGet(List::of); } else { @@ -111,8 +113,7 @@ private PlayerSearchResult toPlayerSearchResult(Server server, Player player) { String status = calculatePlayerStatus(server, player); Date lastOnline = getLastOnline(player); - boolean isOnline = Boolean.TRUE.equals( - player.getData() != null ? player.getData().get("isOnline") : null); + boolean isOnline = player.data().isOnline(); return new PlayerSearchResult( player.getMinecraftUuid().toString(), @@ -128,7 +129,7 @@ private String calculatePlayerStatus(Server server, Player player) { for (Punishment punishment : punishments) { if (statusCalculator.isPunishmentActive(punishment)) { PunishmentType pt = punishmentTypeService.getPunishmentTypeByOrdinal(server, punishment.getTypeOrdinal()).orElse(null); - String category = statusCalculator.getEffectiveCategory(pt, punishment.getData()); + String category = statusCalculator.getEffectiveCategory(pt, punishment.data()); if (EnforcementCategory.BAN.name().equals(category)) { return "Banned"; } @@ -233,19 +234,11 @@ private long getLastLoginMillis(Player player) { } private Date getLastOnline(Player player) { - if (player.getData() == null) { - return null; - } - Object lastLogin = player.getData().get("lastLogin"); - if (lastLogin instanceof Date) { - return (Date) lastLogin; - } - return null; + return player.data().lastLogin(); } private boolean isOnline(Player player) { - Object isOnline = player.getData() != null ? player.getData().get("isOnline") : null; - return Boolean.TRUE.equals(isOnline); + return player.data().isOnline(); } private boolean isUuid(String value) { @@ -290,13 +283,10 @@ private PlayerDetailResponse buildPlayerDetailResponse(Server server, Player pla IPEntry latestIp = sanitizedIps.isEmpty() ? null : sanitizedIps.get(sanitizedIps.size() - 1); - String lastServer = player.getData() != null ? (String) player.getData().get("lastServer") : null; + String lastServer = player.data().lastServer(); - Object playtimeObj = player.getData() != null ? player.getData().get("totalPlaytimeSeconds") : null; - double playtimeHours = 0; - if (playtimeObj instanceof Number) { - playtimeHours = ((Number) playtimeObj).doubleValue() / 3600.0; - } + Number playtime = player.data().totalPlaytimeSeconds(); + double playtimeHours = playtime != null ? playtime.doubleValue() / 3600.0 : 0; return new PlayerDetailResponse( player.getId(), @@ -305,7 +295,7 @@ private PlayerDetailResponse buildPlayerDetailResponse(Server server, Player pla capNotes(player.getNotes()), sanitizedIps, punishmentResponses, - player.getData(), + player.data().asMap(), status.social(), status.gameplay(), status.socialPoints(), @@ -465,9 +455,6 @@ public Player addIp(Server server, UUID minecraftUuid, String ipAddress) { return player; } - /** - * @return true if this IP was newly added, false if it already existed - */ private boolean addIpToPlayer(Player player, String ipAddress, Map ipInfo) { if (ipAddress == null || ipAddress.isBlank()) { return false; @@ -532,7 +519,7 @@ public void updateIpGeoData(Server server, String minecraftUuid, String ipAddres return; } - Player player = playerRepository.findByMinecraftUuid(server, normalizeUuid(minecraftUuid)).orElse(null); + Player player = playerRepository.findByMinecraftUuid(server, UuidUtils.normalize(minecraftUuid)).orElse(null); if (player == null) { return; } @@ -567,31 +554,20 @@ private boolean updatePlayerOnLogin(Player player, String username, String ip, M private void updatePlayerDataOnLogin(Player player, String skinHash, String serverName) { Date now = new Date(); - Map data = ensureData(player); - data.put("lastLogin", now); - data.put("isOnline", true); + PlayerDataView data = player.data(); + data.setLastLogin(now); + data.setOnline(true); - if (!data.containsKey("firstJoin")) { - data.put("firstJoin", now); + if (!data.hasFirstJoin()) { + data.setFirstJoin(now); } if (skinHash != null && !skinHash.isBlank()) { - data.put("lastSkinHash", skinHash); + data.setLastSkinHash(skinHash); } if (serverName != null && !serverName.isBlank()) { - data.put("lastServer", serverName); - } - } - - private Map ensureData(Player player) { - if (player.getData() == null) { - player.setData(new HashMap<>()); + data.setLastServer(serverName); } - return player.getData(); - } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); } } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftChatLogController.java b/src/main/java/gg/modl/backend/player/controller/MinecraftChatLogController.java index d7bffda..b53aec1 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftChatLogController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftChatLogController.java @@ -15,7 +15,6 @@ import jakarta.validation.constraints.Min; import java.util.Map; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; @@ -29,7 +28,6 @@ @RestController @RequestMapping(RESTMappingV1.MINECRAFT_PLAYERS) @RequiredArgsConstructor -@Slf4j @Validated public class MinecraftChatLogController { diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationController.java b/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationController.java index dc5f5d9..3b3c621 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationController.java @@ -1,6 +1,7 @@ package gg.modl.backend.player.controller; import gg.modl.backend.player.dto.request.AcknowledgeNotificationsRequest; +import gg.modl.backend.player.dto.response.AcknowledgeResult; import gg.modl.backend.player.service.MinecraftPlayerService; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; @@ -27,7 +28,11 @@ public ResponseEntity> acknowledgeNotifications( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.acknowledgeNotifications(server, request); - return ResponseEntity.status(response.status()).body(response.body()); + AcknowledgeResult result = minecraftPlayerService.acknowledgeNotifications(server, request); + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", result.message() + )); } } \ No newline at end of file diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationV3Controller.java b/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationV3Controller.java index 56d8f29..e7ddd8a 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationV3Controller.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationV3Controller.java @@ -32,14 +32,13 @@ public ResponseEntity acknowledgeNotifications( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.acknowledgeNotifications( + minecraftPlayerService.acknowledgeNotifications( server, request.getPlayerUuid(), request.getNotificationIdsList(), request.getAcknowledgedAt().isEmpty() ? null : request.getAcknowledgedAt() ); - return ResponseEntity.status(response.status()) - .body(MinecraftPlayerProtoMapper.toSimpleResponse(response.body())); + return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(true)); } } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerController.java b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerController.java index 2c8f2c6..a136080 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerController.java @@ -1,291 +1,397 @@ -package gg.modl.backend.player.controller; - -import gg.modl.backend.player.dto.request.CreateNoteRequest; -import gg.modl.backend.player.service.MinecraftPlayerService; -import gg.modl.backend.player.service.PlayerLookupService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.validation.RegExpConstants; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import gg.modl.backend.infrastructure.validation.ValidIpAddress; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import java.util.Map; -import java.util.Objects; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.MINECRAFT_PLAYERS) -@RequiredArgsConstructor -@Slf4j -@Validated -public class MinecraftPlayerController { - private final MinecraftPlayerService minecraftPlayerService; - private final PlayerLookupService playerLookupService; - - @PostMapping("/login") - public ResponseEntity> login( - @RequestBody @Valid LoginRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.login( - server, - UUID.fromString(request.minecraftUUID()), - request.username(), - request.ip(), - request.ipInfo(), - request.skinHash(), - request.serverName() - ); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @PostMapping("/disconnect") - public ResponseEntity> disconnect( - @RequestBody @Valid DisconnectRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.disconnect(server, request.minecraftUuid(), request.sessionDurationMs())); - } - - @PostMapping("/update-server") - public ResponseEntity> updateServer( - @RequestBody @Valid UpdateServerRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.updateServer(server, request.minecraftUuid(), request.serverName())); - } - - @GetMapping("/online") - public ResponseEntity> getOnlinePlayers(HttpServletRequest httpRequest) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.getOnlinePlayers(server)); - } - - @GetMapping("/{uuid}") - public ResponseEntity> getPlayerByUuid( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestParam(required = false) Integer punishmentLimit, - @RequestParam(required = false) Integer noteLimit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByUuid(server, uuid, punishmentLimit, noteLimit); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @GetMapping - public ResponseEntity> getPlayerByQuery( - @RequestParam(required = false) @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, - @RequestParam(defaultValue = "true") boolean queryMojang, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByMinecraftUuid(server, minecraftUuid, queryMojang); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @GetMapping("/by-name") - public ResponseEntity> getPlayerByUsername( - @RequestParam String username, - @RequestParam(defaultValue = "true") boolean queryMojang, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByUsername(server, username, queryMojang); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @PostMapping("/lookup") - public ResponseEntity> lookupPlayer( - @RequestBody @Valid LookupRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.lookupPlayer(server, request.query(), request.shouldQueryMojang()); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @PostMapping("/lookup-profile") - public ResponseEntity> lookupProfile( - @RequestBody @Valid LookupRequest request, - @RequestParam(required = false) Integer punishmentLimit, - @RequestParam(required = false) Integer noteLimit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.lookupProfile(server, request.query(), request.shouldQueryMojang(), - punishmentLimit, noteLimit); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @PostMapping("/{uuid}/notes") - public ResponseEntity> createPlayerNote( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody @Valid CreateNoteRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.createNote(server, uuid, request.text(), request.issuerName(), - request.issuerId()); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @GetMapping("/{uuid}/linked-accounts") - public ResponseEntity> getLinkedAccounts( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestParam(required = false) @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) Integer page, - @RequestParam(required = false) @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) Integer limit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getLinkedAccounts(server, uuid, page, limit); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @GetMapping("/{uuid}/punishments") - public ResponseEntity> getPlayerPunishments( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, - @RequestParam(defaultValue = "7") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.getPlayerPunishments(server, uuid, page, limit); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @GetMapping("/{uuid}/notes") - public ResponseEntity> getPlayerNotes( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, - @RequestParam(defaultValue = "7") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.getPlayerNotes(server, uuid, page, limit); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @GetMapping("/{uuid}/reports") - public ResponseEntity> getPlayerReports( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.getPlayerReports(server, uuid)); - } - - @PostMapping("/submit-ip-info") - public ResponseEntity> submitIpInfo( - @RequestBody @Valid SubmitIpInfoRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.submitIpInfo( - server, - request.minecraftUUID(), - request.ip(), - request.country(), - request.region(), - request.asn(), - request.proxy(), - request.hosting() - )); - } - - @PostMapping("/pardon") - public ResponseEntity> pardonPlayer( - @RequestBody @Valid PardonPlayerRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.pardonPlayer( - server, - request.playerName(), - request.punishmentType(), - request.issuerName(), - request.issuerId(), - request.reason() - ); - - if (Objects.equals(response.get("status"), 404)) { - return ResponseEntity.status(404).body(response); - } - return ResponseEntity.ok(response); - } - - public record LoginRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUUID, - @NotBlank @Pattern(regexp = RegExpConstants.MINECRAFT_USERNAME) String username, - @NotBlank @ValidIpAddress String ip, - Map ipInfo, - @Size(max = RequestValidationLimits.ID_MAX_LENGTH) String skinHash, - @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String serverName, - String serverInstanceId - ) { - } - - public record SubmitIpInfoRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUUID, - @NotBlank @ValidIpAddress String ip, - @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String country, - @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String region, - @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String asn, - boolean proxy, - boolean hosting - ) { - } - - public record DisconnectRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, - @Min(0) long sessionDurationMs, - String serverInstanceId - ) { - } - - public record UpdateServerRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, - @NotBlank @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String serverName, - String serverInstanceId - ) { - } - - public record LookupRequest( - @NotBlank @Size(max = RequestValidationLimits.ADMIN_SEARCH_QUERY_MAX_LENGTH) String query, - Boolean queryMojang - ) { - public boolean shouldQueryMojang() { - return queryMojang == null || queryMojang; - } - } - - public record PardonPlayerRequest( - @NotBlank @Size(max = RequestValidationLimits.LOG_USERNAME_MAX_LENGTH) String playerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @Size(max = RequestValidationLimits.PUNISHMENT_TYPE_NAME_MAX_LENGTH) String punishmentType, - @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_REASON_MAX_LENGTH) String reason - ) { - } -} +package gg.modl.backend.player.controller; + +import gg.modl.backend.player.dto.request.CreateNoteRequest; +import gg.modl.backend.player.dto.response.CreateNoteResult; +import gg.modl.backend.player.dto.response.LinkedAccountsResult; +import gg.modl.backend.player.dto.response.PaginatedNotesResult; +import gg.modl.backend.player.dto.response.PaginatedPunishmentsResult; +import gg.modl.backend.player.dto.response.PardonResult; +import gg.modl.backend.player.dto.response.PlayerFetchResult; +import gg.modl.backend.player.dto.response.PlayerLookupResult; +import gg.modl.backend.player.dto.response.PlayerLoginResult; +import gg.modl.backend.player.dto.response.PlayerProfileResult; +import gg.modl.backend.player.service.MinecraftPlayerService; +import gg.modl.backend.player.service.PlayerLookupService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.validation.RegExpConstants; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.backend.infrastructure.validation.ValidIpAddress; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.MINECRAFT_PLAYERS) +@RequiredArgsConstructor +@Validated +public class MinecraftPlayerController { + private final MinecraftPlayerService minecraftPlayerService; + private final PlayerLookupService playerLookupService; + + @PostMapping("/login") + public ResponseEntity> login( + @RequestBody @Valid LoginRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PlayerLoginResult result = minecraftPlayerService.login( + server, + UUID.fromString(request.minecraftUUID()), + request.username(), + request.ip(), + request.ipInfo(), + request.skinHash(), + request.serverName() + ); + + Map body = new LinkedHashMap<>(); + body.put("status", result.status()); + body.put("activePunishments", result.activePunishments()); + body.put("pendingNotifications", result.pendingNotifications()); + if (!result.pendingIpLookups().isEmpty()) { + body.put("pendingIpLookups", result.pendingIpLookups()); + } + if (!result.pendingStatWipes().isEmpty()) { + body.put("pendingStatWipes", result.pendingStatWipes()); + } + return ResponseEntity.status(result.status()).body(body); + } + + @PostMapping("/disconnect") + public ResponseEntity> disconnect( + @RequestBody @Valid DisconnectRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + boolean success = minecraftPlayerService.disconnect(server, request.minecraftUuid(), request.sessionDurationMs()).success(); + return ResponseEntity.ok(successBody(success)); + } + + @PostMapping("/update-server") + public ResponseEntity> updateServer( + @RequestBody @Valid UpdateServerRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + boolean success = minecraftPlayerService.updateServer(server, request.minecraftUuid(), request.serverName()).success(); + return ResponseEntity.ok(successBody(success)); + } + + @GetMapping("/online") + public ResponseEntity> getOnlinePlayers(HttpServletRequest httpRequest) { + Server server = RequestUtil.getRequestServer(httpRequest); + return ResponseEntity.ok(Map.of( + "status", 200, + "players", minecraftPlayerService.getOnlinePlayers(server).players() + )); + } + + @GetMapping("/{uuid}") + public ResponseEntity> getPlayerByUuid( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestParam(required = false) Integer punishmentLimit, + @RequestParam(required = false) Integer noteLimit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return renderProfile(playerLookupService.getPlayerByUuid(server, uuid, punishmentLimit, noteLimit)); + } + + @GetMapping + public ResponseEntity> getPlayerByQuery( + @RequestParam(required = false) @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, + @RequestParam(defaultValue = "true") boolean queryMojang, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return renderFetch(playerLookupService.getPlayerByMinecraftUuid(server, minecraftUuid, queryMojang)); + } + + @GetMapping("/by-name") + public ResponseEntity> getPlayerByUsername( + @RequestParam String username, + @RequestParam(defaultValue = "true") boolean queryMojang, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return renderFetch(playerLookupService.getPlayerByUsername(server, username, queryMojang)); + } + + @PostMapping("/lookup") + public ResponseEntity> lookupPlayer( + @RequestBody @Valid LookupRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return switch (playerLookupService.lookupPlayer(server, request.query(), request.shouldQueryMojang())) { + case PlayerLookupResult.Found found -> ResponseEntity.ok(Map.of( + "status", 200, + "message", found.message(), + "data", found.data() + )); + case PlayerLookupResult.NotFound notFound -> notFound(notFound.message()); + }; + } + + @PostMapping("/lookup-profile") + public ResponseEntity> lookupProfile( + @RequestBody @Valid LookupRequest request, + @RequestParam(required = false) Integer punishmentLimit, + @RequestParam(required = false) Integer noteLimit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return renderProfile(playerLookupService.lookupProfile(server, request.query(), request.shouldQueryMojang(), + punishmentLimit, noteLimit)); + } + + @PostMapping("/{uuid}/notes") + public ResponseEntity> createPlayerNote( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestBody @Valid CreateNoteRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return switch (minecraftPlayerService.createNote(server, uuid, request.text(), request.issuerName(), + request.issuerId())) { + case CreateNoteResult.Created created -> ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", created.message() + )); + case CreateNoteResult.NotFound notFound -> notFound(notFound.message()); + }; + } + + @GetMapping("/{uuid}/linked-accounts") + public ResponseEntity> getLinkedAccounts( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestParam(required = false) @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) Integer page, + @RequestParam(required = false) @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) Integer limit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return switch (playerLookupService.getLinkedAccounts(server, uuid, page, limit)) { + case LinkedAccountsResult.Found found -> { + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("linkedAccounts", found.linkedAccounts()); + if (found.totalCount() != null) { + body.put("totalCount", found.totalCount()); + body.put("page", found.page()); + body.put("hasMore", found.hasMore()); + } + yield ResponseEntity.ok(body); + } + case LinkedAccountsResult.NotFound notFound -> notFound(notFound.message()); + }; + } + + @GetMapping("/{uuid}/punishments") + public ResponseEntity> getPlayerPunishments( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, + @RequestParam(defaultValue = "7") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return switch (minecraftPlayerService.getPlayerPunishments(server, uuid, page, limit)) { + case PaginatedPunishmentsResult.Found found -> { + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("punishments", found.punishments()); + body.put("totalCount", found.totalCount()); + body.put("page", found.page()); + body.put("hasMore", found.hasMore()); + yield ResponseEntity.ok(body); + } + case PaginatedPunishmentsResult.NotFound notFound -> notFound(notFound.message()); + }; + } + + @GetMapping("/{uuid}/notes") + public ResponseEntity> getPlayerNotes( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, + @RequestParam(defaultValue = "7") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return switch (minecraftPlayerService.getPlayerNotes(server, uuid, page, limit)) { + case PaginatedNotesResult.Found found -> { + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("notes", found.notes()); + body.put("totalCount", found.totalCount()); + body.put("page", found.page()); + body.put("hasMore", found.hasMore()); + yield ResponseEntity.ok(body); + } + case PaginatedNotesResult.NotFound notFound -> notFound(notFound.message()); + }; + } + + @GetMapping("/{uuid}/reports") + public ResponseEntity> getPlayerReports( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return ResponseEntity.ok(Map.of( + "status", 200, + "reports", minecraftPlayerService.getPlayerReports(server, uuid).reports() + )); + } + + @PostMapping("/submit-ip-info") + public ResponseEntity> submitIpInfo( + @RequestBody @Valid SubmitIpInfoRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + boolean success = minecraftPlayerService.submitIpInfo( + server, + request.minecraftUUID(), + request.ip(), + request.country(), + request.region(), + request.asn(), + request.proxy(), + request.hosting() + ).success(); + return ResponseEntity.ok(successBody(success)); + } + + @PostMapping("/pardon") + public ResponseEntity> pardonPlayer( + @RequestBody @Valid PardonPlayerRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return switch (minecraftPlayerService.pardonPlayer( + server, + request.playerName(), + request.punishmentType(), + request.issuerName(), + request.issuerId(), + request.reason() + )) { + case PardonResult.Pardoned pardoned -> ResponseEntity.ok(Map.of( + "status", 200, + "success", pardoned.success(), + "pardonedCount", pardoned.pardonedCount(), + "message", pardoned.message() + )); + case PardonResult.PlayerNotFound notFound -> notFound(notFound.message()); + }; + } + + private ResponseEntity> renderProfile(PlayerProfileResult result) { + return switch (result) { + case PlayerProfileResult.Found found -> ResponseEntity.ok(Map.of( + "status", 200, + "profile", found.profile() + )); + case PlayerProfileResult.NotFound notFound -> notFound(notFound.message()); + }; + } + + private ResponseEntity> renderFetch(PlayerFetchResult result) { + return switch (result) { + case PlayerFetchResult.Found found -> ResponseEntity.ok(Map.of( + "status", 200, + "message", found.message(), + "player", found.player() + )); + case PlayerFetchResult.NotFound notFound -> notFound(notFound.message()); + case PlayerFetchResult.InvalidRequest invalid -> ResponseEntity.status(400).body(Map.of( + "status", 400, + "message", invalid.message() + )); + }; + } + + private ResponseEntity> notFound(String message) { + return ResponseEntity.status(404).body(Map.of( + "status", 404, + "message", message + )); + } + + private Map successBody(boolean success) { + return Map.of("status", 200, "success", success); + } + + public record LoginRequest( + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUUID, + @NotBlank @Pattern(regexp = RegExpConstants.MINECRAFT_USERNAME) String username, + @NotBlank @ValidIpAddress String ip, + Map ipInfo, + @Size(max = RequestValidationLimits.ID_MAX_LENGTH) String skinHash, + @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String serverName, + String serverInstanceId + ) { + } + + public record SubmitIpInfoRequest( + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUUID, + @NotBlank @ValidIpAddress String ip, + @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String country, + @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String region, + @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String asn, + boolean proxy, + boolean hosting + ) { + } + + public record DisconnectRequest( + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, + @Min(0) long sessionDurationMs, + String serverInstanceId + ) { + } + + public record UpdateServerRequest( + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, + @NotBlank @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String serverName, + String serverInstanceId + ) { + } + + public record LookupRequest( + @NotBlank @Size(max = RequestValidationLimits.ADMIN_SEARCH_QUERY_MAX_LENGTH) String query, + Boolean queryMojang + ) { + public boolean shouldQueryMojang() { + return queryMojang == null || queryMojang; + } + } + + public record PardonPlayerRequest( + @NotBlank @Size(max = RequestValidationLimits.LOG_USERNAME_MAX_LENGTH) String playerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @Size(max = RequestValidationLimits.PUNISHMENT_TYPE_NAME_MAX_LENGTH) String punishmentType, + @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_REASON_MAX_LENGTH) String reason + ) { + } +} diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerProtoMapper.java b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerProtoMapper.java index 2dc1837..0465d79 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerProtoMapper.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerProtoMapper.java @@ -5,10 +5,10 @@ import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.intValue; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.legacyStruct; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.list; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.listOf; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.listOfMaps; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.longValue; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.map; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalBoolean; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalInt; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalLong; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalString; @@ -16,6 +16,18 @@ import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; +import gg.modl.backend.player.dto.response.CreateNoteResult; +import gg.modl.backend.player.dto.response.LinkedAccountsResult; +import gg.modl.backend.player.dto.response.OnlinePlayersResult; +import gg.modl.backend.player.dto.response.PaginatedNotesResult; +import gg.modl.backend.player.dto.response.PaginatedPunishmentsResult; +import gg.modl.backend.player.dto.response.PardonResult; +import gg.modl.backend.player.dto.response.PlayerFetchResult; +import gg.modl.backend.player.dto.response.PlayerLookupResult; +import gg.modl.backend.player.dto.response.PlayerLoginResult; +import gg.modl.backend.player.dto.response.PlayerReportsResult; +import gg.modl.backend.player.dto.response.PunishmentView; +import gg.modl.backend.player.dto.response.SimplePunishmentView; import gg.modl.proto.modl.v1.Account; import gg.modl.proto.modl.v1.IPEntry; import gg.modl.proto.modl.v1.LinkedAccountsResponse; @@ -48,164 +60,180 @@ public final class MinecraftPlayerProtoMapper { private MinecraftPlayerProtoMapper() { } - static PlayerLoginResponse toPlayerLoginResponse(Map body) { + static PlayerLoginResponse toPlayerLoginResponse(PlayerLoginResult result) { PlayerLoginResponse.Builder response = PlayerLoginResponse.newBuilder() - .setStatus(intValue(body.get("status"))); + .setStatus(result.status()); - listOfMaps(body.get("activePunishments")).stream() + result.activePunishments().stream() .map(MinecraftPlayerProtoMapper::toSimplePunishment) .forEach(response::addActivePunishments); - listOfMaps(body.get("pendingNotifications")).stream() + result.pendingNotifications().stream() .map(ProtoMapperSupport::legacyStruct) .forEach(response::addPendingNotifications); - list(body.get("pendingIpLookups")).stream() - .map(Objects::toString) - .forEach(response::addPendingIpLookups); + result.pendingIpLookups().forEach(response::addPendingIpLookups); - listOfMaps(body.get("pendingStatWipes")).stream() + result.pendingStatWipes().stream() .map(MinecraftPlayerProtoMapper::toPendingStatWipe) .forEach(response::addPendingStatWipes); return response.build(); } - static SimpleResponse toSimpleResponse(Map body) { + static SimpleResponse toSimpleResponse(boolean success) { return SimpleResponse.newBuilder() - .setSuccess(booleanValue(body.get("success"))) + .setSuccess(success) .build(); } - static OnlinePlayersResponse toOnlinePlayersResponse(Map body) { + static OnlinePlayersResponse toOnlinePlayersResponse(OnlinePlayersResult result) { OnlinePlayersResponse.Builder response = OnlinePlayersResponse.newBuilder() - .setStatus(intValue(body.get("status"))); + .setStatus(200); - listOfMaps(body.get("players")).stream() + result.players().stream() .map(MinecraftPlayerProtoMapper::toOnlinePlayer) .forEach(response::addPlayers); return response.build(); } - static PlayerProfileResponse toPlayerProfileResponse(Map body) { - Map profile = map(body.get("profile")); + static PlayerProfileResponse toPlayerProfileResponse(Map profile) { PlayerProfileResponse.Builder response = PlayerProfileResponse.newBuilder() - .setStatus(intValue(body.get("status"))) + .setStatus(200) .setProfile(toAccount(profile)); - setOptionalInt(response::setPunishmentCount, nestedOrTopLevel(profile, body, "punishmentCount")); - setOptionalInt(response::setNoteCount, nestedOrTopLevel(profile, body, "noteCount")); + setOptionalInt(response::setPunishmentCount, profile.get("punishmentCount")); + setOptionalInt(response::setNoteCount, profile.get("noteCount")); return response.build(); } - static PlayerGetResponse toPlayerGetResponse(Map body) { + static PlayerGetResponse toPlayerGetResponse(PlayerFetchResult.Found found) { return PlayerGetResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setMessage(stringValue(body.get("message"))) - .setPlayer(toAccount(map(body.get("player")))) + .setStatus(200) + .setMessage(stringValue(found.message())) + .setPlayer(toAccount(found.player())) .build(); } - static PlayerNameResponse toPlayerNameResponse(Map body) { + static PlayerNameResponse toPlayerNameResponse(PlayerFetchResult.Found found) { return PlayerNameResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setMessage(stringValue(body.get("message"))) - .setPlayer(toAccount(map(body.get("player")))) + .setStatus(200) + .setMessage(stringValue(found.message())) + .setPlayer(toAccount(found.player())) .build(); } - static PlayerLookupResponse toPlayerLookupResponse(Map body) { + static PlayerLookupResponse toPlayerLookupResponse(PlayerLookupResult.Found found) { return PlayerLookupResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setMessage(stringValue(body.get("message"))) - .setData(toPlayerLookupData(map(body.get("data")))) + .setStatus(200) + .setMessage(stringValue(found.message())) + .setData(toPlayerLookupData(found.data())) .build(); } - static PlayerNoteCreateResponse toPlayerNoteCreateResponse(Map body) { - PlayerNoteCreateResponse.Builder builder = PlayerNoteCreateResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setMessage(stringValue(body.get("message"))); - setBooleanIfPresent(builder, "setSuccess", body.get("success")); - return builder.build(); + static PlayerNoteCreateResponse toPlayerNoteCreateResponse(CreateNoteResult result) { + return switch (result) { + case CreateNoteResult.Created created -> PlayerNoteCreateResponse.newBuilder() + .setStatus(200) + .setMessage(stringValue(created.message())) + .setSuccess(true) + .build(); + case CreateNoteResult.NotFound notFound -> PlayerNoteCreateResponse.newBuilder() + .setStatus(404) + .setMessage(stringValue(notFound.message())) + .setSuccess(false) + .build(); + }; } - static LinkedAccountsResponse toLinkedAccountsResponse(Map body) { + static LinkedAccountsResponse toLinkedAccountsResponse(LinkedAccountsResult.Found found) { LinkedAccountsResponse.Builder response = LinkedAccountsResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setPage(intValue(body.get("page"))) - .setHasMore(booleanValue(body.get("hasMore"))); + .setStatus(200) + .setPage(intValue(found.page())) + .setHasMore(booleanValue(found.hasMore())); - setOptionalInt(response::setTotalCount, body.get("totalCount")); - listOfMaps(body.get("linkedAccounts")).stream() + setOptionalInt(response::setTotalCount, found.totalCount()); + found.linkedAccounts().stream() .map(MinecraftPlayerProtoMapper::toAccount) .forEach(response::addLinkedAccounts); return response.build(); } - static PaginatedPunishmentsResponse toPaginatedPunishmentsResponse(Map body) { + static PaginatedPunishmentsResponse toPaginatedPunishmentsResponse(PaginatedPunishmentsResult.Found found) { PaginatedPunishmentsResponse.Builder response = PaginatedPunishmentsResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setTotalCount(intValue(body.get("totalCount"))) - .setPage(intValue(body.get("page"))) - .setHasMore(booleanValue(body.get("hasMore"))); + .setStatus(200) + .setTotalCount(found.totalCount()) + .setPage(found.page()) + .setHasMore(found.hasMore()); - listOfMaps(body.get("punishments")).stream() + found.punishments().stream() .map(MinecraftPlayerProtoMapper::toPunishmentListEntry) .forEach(response::addPunishments); return response.build(); } - static PaginatedNotesResponse toPaginatedNotesResponse(Map body) { + static PaginatedNotesResponse toPaginatedNotesResponse(PaginatedNotesResult.Found found) { PaginatedNotesResponse.Builder response = PaginatedNotesResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setTotalCount(intValue(body.get("totalCount"))) - .setPage(intValue(body.get("page"))) - .setHasMore(booleanValue(body.get("hasMore"))); + .setStatus(200) + .setTotalCount(found.totalCount()) + .setPage(found.page()) + .setHasMore(found.hasMore()); - listOfMaps(body.get("notes")).stream() + found.notes().stream() .map(MinecraftPlayerProtoMapper::toNoteEntry) .forEach(response::addNotes); return response.build(); } - static ReportsResponse toReportsResponse(Map body) { + static ReportsResponse toReportsResponse(PlayerReportsResult result) { ReportsResponse.Builder response = ReportsResponse.newBuilder() - .setStatus(intValue(body.get("status"))); + .setStatus(200); - listOfMaps(body.get("reports")).stream() + result.reports().stream() .map(MinecraftPlayerProtoMapper::toReportEntry) .forEach(response::addReports); return response.build(); } - static PardonResponse toPardonResponse(Map body) { - return PardonResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setSuccess(booleanValue(body.get("success"))) - .setPardonedCount(intValue(body.get("pardonedCount"))) - .setMessage(stringValue(body.get("message"))) - .build(); - } + static PardonResponse toPardonResponse(PardonResult result) { + return switch (result) { + case PardonResult.Pardoned pardoned -> PardonResponse.newBuilder() + .setStatus(200) + .setSuccess(pardoned.success()) + .setPardonedCount(pardoned.pardonedCount()) + .setMessage(stringValue(pardoned.message())) + .build(); + case PardonResult.PlayerNotFound notFound -> PardonResponse.newBuilder() + .setStatus(404) + .setSuccess(false) + .setPardonedCount(0) + .setMessage(stringValue(notFound.message())) + .build(); + }; + } + + public static SimplePunishment toSimplePunishment(SimplePunishmentView view) { + if (view == null) { + return SimplePunishment.getDefaultInstance(); + } - public static SimplePunishment toSimplePunishment(Map punishment) { SimplePunishment.Builder builder = SimplePunishment.newBuilder() - .setType(stringValue(punishment.get("type"))) - .setDescription(stringValue(punishment.get("description"))) - .setId(stringValue(punishment.get("id"))) - .setStarted(booleanValue(punishment.get("started"))) - .setOrdinal(intValue(punishment.get("ordinal"))); - - setOptionalString(builder::setCategory, punishment.get("category")); - setOptionalLong(builder::setExpiration, punishment.get("expiration")); - setOptionalString(builder::setIssuerName, punishment.get("issuerName")); - setOptionalLong(builder::setIssuedAt, punishment.get("issuedAt")); - setOptionalString(builder::setPlayerDescription, punishment.get("playerDescription")); + .setType(stringValue(view.type())) + .setDescription(stringValue(view.description())) + .setId(stringValue(view.id())) + .setStarted(view.started()) + .setOrdinal(view.ordinal()); + + setOptionalString(builder::setCategory, view.category()); + setOptionalLong(builder::setExpiration, view.expiration()); + setOptionalString(builder::setIssuerName, view.issuerName()); + setOptionalLong(builder::setIssuedAt, view.issuedAt()); + setOptionalString(builder::setPlayerDescription, view.playerDescription()); return builder.build(); } @@ -244,7 +272,7 @@ private static Account toAccount(Map account) { .map(MinecraftPlayerProtoMapper::toIpEntry) .forEach(builder::addIpAddresses); - listOfMaps(account.get("punishments")).stream() + listOf(account.get("punishments"), PunishmentView.class).stream() .map(MinecraftPlayerProtoMapper::toPunishmentResponse) .forEach(builder::addPunishments); @@ -273,7 +301,10 @@ private static NoteEntry toNoteEntry(Map note) { .setDate(dateAwareString(note.get("date"))) .setIssuerName(stringValue(note.get("issuerName"))) .setIssuerId(stringValue(note.get("issuerId"))); - setStringIfPresent(builder, "setId", note.get("id")); + Object id = note.get("id"); + if (id != null) { + builder.setId(Objects.toString(id)); + } return builder.build(); } @@ -295,63 +326,48 @@ private static IPEntry toIpEntry(Map ip) { return builder.build(); } - private static PunishmentResponse toPunishmentResponse(Map punishment) { + private static PunishmentResponse toPunishmentResponse(PunishmentView punishment) { PunishmentResponse.Builder builder = PunishmentResponse.newBuilder() - .setId(stringValue(punishment.get("id"))) - .setType(stringValue(punishment.get("type"))) - .setTypeOrdinal(intValue(punishment.get("typeOrdinal"))) - .setIssuerName(stringValue(punishment.get("issuerName"))) - .setIssued(longValue(punishment.get("issued"))) - .setIsAppealable(booleanValue(punishment.get("isAppealable"))) - .setActive(booleanValue(punishment.get("active"))); - - setOptionalLong(builder::setStarted, punishment.get("started")); - setOptionalString(builder::setReason, punishment.get("reason")); - setOptionalString(builder::setSeverity, punishment.get("severity")); - setOptionalString(builder::setStatus, punishment.get("status")); - setOptionalLong(builder::setExpires, punishment.get("expires")); - setOptionalString(builder::setPlayerUuid, punishment.get("playerUuid")); - setOptionalString(builder::setPlayerUsername, punishment.get("playerUsername")); - setOptionalBoolean(builder::setAltBlocking, punishment.get("altBlocking")); - setOptionalBoolean(builder::setStatWiping, punishment.get("statWiping")); - setOptionalString(builder::setEffectiveCategory, punishment.get("effectiveCategory")); - - list(punishment.get("attachedTicketIds")).stream() - .map(Objects::toString) - .forEach(builder::addAttachedTicketIds); + .setId(stringValue(punishment.id())) + .setType(stringValue(punishment.type())) + .setTypeOrdinal(punishment.typeOrdinal()) + .setIssuerName(stringValue(punishment.issuerName())) + .setIssued(longValue(punishment.issued())); + + setOptionalLong(builder::setStarted, punishment.started()); + setOptionalString(builder::setPlayerUuid, punishment.playerUuid()); + + punishment.attachedTicketIds().forEach(builder::addAttachedTicketIds); return builder.build(); } - private static PunishmentListEntry toPunishmentListEntry(Map punishment) { + private static PunishmentListEntry toPunishmentListEntry(PunishmentView punishment) { PunishmentListEntry.Builder builder = PunishmentListEntry.newBuilder() - .setId(stringValue(punishment.get("id"))) - .setIssuerName(stringValue(punishment.get("issuerName"))) - .setIssued(longValue(punishment.get("issued"))) - .setType(stringValue(punishment.get("type"))); + .setId(stringValue(punishment.id())) + .setIssuerName(stringValue(punishment.issuerName())) + .setIssued(longValue(punishment.issued())) + .setType(stringValue(punishment.type())); - setOptionalLong(builder::setStarted, punishment.get("started")); - setOptionalInt(builder::setTypeOrdinal, punishment.get("typeOrdinal")); + setOptionalLong(builder::setStarted, punishment.started()); + builder.setTypeOrdinal(punishment.typeOrdinal()); - listOfMaps(punishment.get("modifications")).stream() + punishment.modifications().stream() .map(MinecraftPlayerProtoMapper::toPunishmentModification) .forEach(builder::addModifications); - listOfMaps(punishment.get("notes")).stream() + punishment.notes().stream() .map(MinecraftPlayerProtoMapper::toPunishmentNote) .forEach(builder::addNotes); - listOfMaps(punishment.get("evidence")).stream() + punishment.evidence().stream() .map(MinecraftPlayerProtoMapper::toPunishmentEvidence) .forEach(builder::addEvidence); - list(punishment.get("attachedTicketIds")).stream() - .map(Objects::toString) - .forEach(builder::addAttachedTicketIds); + punishment.attachedTicketIds().forEach(builder::addAttachedTicketIds); - Object data = punishment.get("data"); - if (data instanceof Map dataMap) { - builder.setData(legacyStruct(stringObjectMap(dataMap))); + if (punishment.data() != null) { + builder.setData(legacyStruct(punishment.data())); } return builder.build(); @@ -493,29 +509,4 @@ private static PlayerLookupResponse.PlayerLookupRecentTicket toPlayerLookupRecen .setLastUpdated(dateAwareString(ticket.get("lastUpdated"))) .build(); } - - private static Object nestedOrTopLevel(Map nested, Map topLevel, String key) { - return nested.containsKey(key) ? nested.get(key) : topLevel.get(key); - } - - private static void setStringIfPresent(Object target, String methodName, Object value) { - if (value == null) { - return; - } - try { - target.getClass().getMethod(methodName, String.class).invoke(target, Objects.toString(value)); - } catch (NoSuchMethodException ignored) { - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Failed to set protobuf field with " + methodName, e); - } - } - - private static void setBooleanIfPresent(Object target, String methodName, Object value) { - try { - target.getClass().getMethod(methodName, boolean.class).invoke(target, booleanValue(value)); - } catch (NoSuchMethodException ignored) { - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Failed to set protobuf field with " + methodName, e); - } - } } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerV3Controller.java b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerV3Controller.java index f129129..56a39d8 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerV3Controller.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerV3Controller.java @@ -7,6 +7,14 @@ import gg.modl.backend.infrastructure.rest.RESTMappingV3; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.backend.player.dto.response.CreateNoteResult; +import gg.modl.backend.player.dto.response.LinkedAccountsResult; +import gg.modl.backend.player.dto.response.PaginatedNotesResult; +import gg.modl.backend.player.dto.response.PaginatedPunishmentsResult; +import gg.modl.backend.player.dto.response.PlayerFetchResult; +import gg.modl.backend.player.dto.response.PlayerLookupResult; +import gg.modl.backend.player.dto.response.PlayerLoginResult; +import gg.modl.backend.player.dto.response.PlayerProfileResult; import gg.modl.backend.player.service.MinecraftPlayerService; import gg.modl.backend.player.service.PlayerLookupService; import gg.modl.backend.server.data.Server; @@ -37,7 +45,6 @@ import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -68,7 +75,7 @@ public ResponseEntity login( ? ProtoMapperSupport.structToMap(request.getIpInfo()) : null; - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.login( + PlayerLoginResult result = minecraftPlayerService.login( server, UUID.fromString(request.getMinecraftUuid()), request.getUsername(), @@ -78,8 +85,8 @@ public ResponseEntity login( request.hasServerName() ? request.getServerName() : null ); - return ResponseEntity.status(response.status()) - .body(MinecraftPlayerProtoMapper.toPlayerLoginResponse(response.body())); + return ResponseEntity.status(result.status()) + .body(MinecraftPlayerProtoMapper.toPlayerLoginResponse(result)); } @PostMapping( @@ -92,13 +99,13 @@ public ResponseEntity disconnect( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.disconnect( + boolean success = minecraftPlayerService.disconnect( server, request.getMinecraftUuid(), request.getSessionDurationMs() - ); + ).success(); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(response)); + return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(success)); } @PostMapping( @@ -111,13 +118,13 @@ public ResponseEntity updateServer( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.updateServer( + boolean success = minecraftPlayerService.updateServer( server, request.getMinecraftUuid(), request.getServerName() - ); + ).success(); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(response)); + return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(success)); } @PostMapping( @@ -130,7 +137,7 @@ public ResponseEntity submitIpInfo( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.submitIpInfo( + boolean success = minecraftPlayerService.submitIpInfo( server, request.getMinecraftUuid(), request.getIp(), @@ -139,9 +146,9 @@ public ResponseEntity submitIpInfo( request.getAsn(), request.getProxy(), request.getHosting() - ); + ).success(); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(response)); + return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(success)); } @GetMapping( @@ -150,8 +157,7 @@ public ResponseEntity submitIpInfo( ) public ResponseEntity getOnlinePlayers(HttpServletRequest httpRequest) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.getOnlinePlayers(server); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toOnlinePlayersResponse(response)); + return ResponseEntity.ok(MinecraftPlayerProtoMapper.toOnlinePlayersResponse(minecraftPlayerService.getOnlinePlayers(server))); } @GetMapping( @@ -165,9 +171,11 @@ public ResponseEntity getPlayerByUuid( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByUuid(server, uuid, punishmentLimit, noteLimit); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerProfileResponse(response.body())); + return switch (playerLookupService.getPlayerByUuid(server, uuid, punishmentLimit, noteLimit)) { + case PlayerProfileResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerProfileResponse(found.profile())); + case PlayerProfileResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + }; } @GetMapping( @@ -180,9 +188,12 @@ public ResponseEntity getPlayerByQuery( ) { requireNotBlank(minecraftUuid, "minecraftUuid parameter required"); Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByMinecraftUuid(server, minecraftUuid, queryMojang); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerGetResponse(response.body())); + return switch (playerLookupService.getPlayerByMinecraftUuid(server, minecraftUuid, queryMojang)) { + case PlayerFetchResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerGetResponse(found)); + case PlayerFetchResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + case PlayerFetchResult.InvalidRequest invalid -> throw new ValidationException(invalid.message()); + }; } @GetMapping( @@ -196,9 +207,12 @@ public ResponseEntity getPlayerByUsername( ) { requireNotBlank(username, "username parameter required"); Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByUsername(server, username, queryMojang); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerNameResponse(response.body())); + return switch (playerLookupService.getPlayerByUsername(server, username, queryMojang)) { + case PlayerFetchResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerNameResponse(found)); + case PlayerFetchResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + case PlayerFetchResult.InvalidRequest invalid -> throw new ValidationException(invalid.message()); + }; } @PostMapping( @@ -212,13 +226,11 @@ public ResponseEntity lookupPlayer( ) { requireNotBlank(request.getQuery(), "query is required"); Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.lookupPlayer( - server, - request.getQuery(), - shouldQueryMojang(request) - ); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerLookupResponse(response.body())); + return switch (playerLookupService.lookupPlayer(server, request.getQuery(), shouldQueryMojang(request))) { + case PlayerLookupResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerLookupResponse(found)); + case PlayerLookupResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + }; } @PostMapping( @@ -234,15 +246,17 @@ public ResponseEntity lookupProfile( ) { requireNotBlank(request.getQuery(), "query is required"); Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.lookupProfile( + return switch (playerLookupService.lookupProfile( server, request.getQuery(), shouldQueryMojang(request), punishmentLimit, noteLimit - ); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerProfileResponse(response.body())); + )) { + case PlayerProfileResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerProfileResponse(found.profile())); + case PlayerProfileResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + }; } @PostMapping( @@ -256,15 +270,15 @@ public ResponseEntity createPlayerNote( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.createNote( + CreateNoteResult result = minecraftPlayerService.createNote( server, uuid, request.getText(), request.hasIssuerName() ? request.getIssuerName() : null, request.hasIssuerId() ? request.getIssuerId() : null ); - return ResponseEntity.status(response.status()) - .body(MinecraftPlayerProtoMapper.toPlayerNoteCreateResponse(response.body())); + PlayerNoteCreateResponse response = MinecraftPlayerProtoMapper.toPlayerNoteCreateResponse(result); + return ResponseEntity.status(response.getStatus()).body(response); } @GetMapping( @@ -278,9 +292,11 @@ public ResponseEntity getLinkedAccounts( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getLinkedAccounts(server, uuid, page, limit); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toLinkedAccountsResponse(response.body())); + return switch (playerLookupService.getLinkedAccounts(server, uuid, page, limit)) { + case LinkedAccountsResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toLinkedAccountsResponse(found)); + case LinkedAccountsResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + }; } @GetMapping( @@ -294,9 +310,11 @@ public ResponseEntity getPlayerPunishments( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.getPlayerPunishments(server, uuid, page, limit); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPaginatedPunishmentsResponse(response.body())); + return switch (minecraftPlayerService.getPlayerPunishments(server, uuid, page, limit)) { + case PaginatedPunishmentsResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPaginatedPunishmentsResponse(found)); + case PaginatedPunishmentsResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + }; } @GetMapping( @@ -310,9 +328,11 @@ public ResponseEntity getPlayerNotes( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.getPlayerNotes(server, uuid, page, limit); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPaginatedNotesResponse(response.body())); + return switch (minecraftPlayerService.getPlayerNotes(server, uuid, page, limit)) { + case PaginatedNotesResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPaginatedNotesResponse(found)); + case PaginatedNotesResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + }; } @GetMapping( @@ -324,8 +344,7 @@ public ResponseEntity getPlayerReports( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.getPlayerReports(server, uuid); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toReportsResponse(response)); + return ResponseEntity.ok(MinecraftPlayerProtoMapper.toReportsResponse(minecraftPlayerService.getPlayerReports(server, uuid))); } @PostMapping( @@ -338,48 +357,24 @@ public ResponseEntity pardonPlayer( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.pardonPlayer( + PardonResponse response = MinecraftPlayerProtoMapper.toPardonResponse(minecraftPlayerService.pardonPlayer( server, request.getPlayerName(), request.hasPunishmentType() ? request.getPunishmentType() : null, request.hasIssuerName() ? request.getIssuerName() : null, request.hasIssuerId() ? request.getIssuerId() : null, request.hasReason() ? request.getReason() : null - ); - HttpStatus status = intStatus(response) == 404 ? HttpStatus.NOT_FOUND : HttpStatus.OK; - return ResponseEntity.status(status) - .body(MinecraftPlayerProtoMapper.toPardonResponse(response)); + )); + return ResponseEntity.status(response.getStatus()).body(response); } private static boolean shouldQueryMojang(PlayerLookupRequest request) { return !request.hasQueryMojang() || request.getQueryMojang(); } - private static int intStatus(Map response) { - Object status = response.get("status"); - if (status instanceof Number number) { - return number.intValue(); - } - return 200; - } - private static void requireNotBlank(String value, String message) { if (value == null || value.isBlank()) { throw new ValidationException(message); } } - - private static void requireSuccess(MinecraftPlayerService.ServiceResponse response) { - if (response.status() == HttpStatus.OK) { - return; - } - Object message = response.body().get("message"); - if (response.status() == HttpStatus.NOT_FOUND) { - throw new ResourceNotFoundException(message == null ? "Not found" : message.toString()); - } - if (response.status() == HttpStatus.BAD_REQUEST) { - throw new ValidationException(message == null ? "Invalid request" : message.toString()); - } - throw new IllegalStateException(message == null ? "Unexpected player lookup response" : message.toString()); - } } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentController.java b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentController.java index e3c0bfa..ea3f2f2 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentController.java @@ -1,463 +1,449 @@ -package gg.modl.backend.player.controller; - -import com.fasterxml.jackson.annotation.JsonProperty; -import gg.modl.backend.player.dto.request.CreateUploadTokenRequest; -import gg.modl.backend.player.dto.request.ModifyPunishmentTicketsRequest; +package gg.modl.backend.player.controller; + +import gg.modl.backend.player.dto.request.CreateUploadTokenRequest; +import gg.modl.backend.player.dto.request.ModifyPunishmentTicketsRequest; import gg.modl.backend.player.dto.response.PunishmentPreviewView; -import gg.modl.backend.player.service.PunishmentEvidenceService; -import gg.modl.backend.player.service.PunishmentLifecycleService; -import gg.modl.backend.player.service.PunishmentMutationService; -import gg.modl.backend.player.service.PunishmentQueryService; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.validation.RegExpConstants; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.MINECRAFT_PUNISHMENTS) -@RequiredArgsConstructor -@Validated -public class MinecraftPunishmentController { - private final PunishmentQueryService punishmentQueryService; - private final PunishmentLifecycleService punishmentLifecycleService; - private final PunishmentEvidenceService punishmentEvidenceService; - private final PunishmentMutationService punishmentMutationService; - - @PostMapping("/create") - public ResponseEntity createPunishment( - @RequestBody @Valid MinecraftCreatePunishmentRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - punishmentLifecycleService.createMinecraftPunishment(server, request); - return ResponseEntity.ok().build(); - } - - @PostMapping("/dynamic") - public ResponseEntity> createPunishmentDynamic( - @RequestBody @Valid MinecraftCreatePunishmentRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - String punishmentId = punishmentLifecycleService.createMinecraftPunishment(server, request); - return ResponseEntity.ok(Map.of( - "status", 200, - "message", "Punishment created", - "punishmentId", punishmentId - )); - } - - @GetMapping("/{punishmentId}") - public ResponseEntity> getPunishmentById( - @PathVariable String punishmentId, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - Map punishment = punishmentQueryService.getMinecraftPunishmentById(server, punishmentId).orElse(null); - if (punishment == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", "Punishment not found" - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "punishment", punishment - )); - } - - @PostMapping("/{punishmentId}/upload-token") - public ResponseEntity> createUploadToken( - @PathVariable String punishmentId, - @RequestBody @Valid CreateUploadTokenRequest body, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - String token = punishmentQueryService.createEvidenceUploadToken(server, punishmentId, body.issuerName()).orElse(null); - if (token == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", "Punishment not found" - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "token", token - )); - } - - @GetMapping("/recent") - public ResponseEntity> getRecentPunishments( - @RequestParam(defaultValue = "48") @Min(1) @Max(8760) int hours, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(Map.of( - "status", 200, - "punishments", punishmentQueryService.getRecentPunishments(server, hours) - )); - } - - @GetMapping("/preview") +import gg.modl.backend.player.service.PunishmentEvidenceService; +import gg.modl.backend.player.service.PunishmentLifecycleService; +import gg.modl.backend.player.service.PunishmentMutationService; +import gg.modl.backend.player.service.PunishmentQueryService; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; +import gg.modl.backend.player.dto.response.PunishmentView; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.validation.RegExpConstants; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.backend.player.dto.request.MinecraftCreatePunishmentRequest; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.MINECRAFT_PUNISHMENTS) +@RequiredArgsConstructor +@Validated +public class MinecraftPunishmentController { + private final PunishmentQueryService punishmentQueryService; + private final PunishmentLifecycleService punishmentLifecycleService; + private final PunishmentEvidenceService punishmentEvidenceService; + private final PunishmentMutationService punishmentMutationService; + + @PostMapping("/create") + public ResponseEntity createPunishment( + @RequestBody @Valid MinecraftCreatePunishmentRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + punishmentLifecycleService.createMinecraftPunishment(server, request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/dynamic") + public ResponseEntity> createPunishmentDynamic( + @RequestBody @Valid MinecraftCreatePunishmentRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + String punishmentId = punishmentLifecycleService.createMinecraftPunishment(server, request); + return ResponseEntity.ok(Map.of( + "status", 200, + "message", "Punishment created", + "punishmentId", punishmentId + )); + } + + @GetMapping("/{punishmentId}") + public ResponseEntity> getPunishmentById( + @PathVariable String punishmentId, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentView punishment = punishmentQueryService.getMinecraftPunishmentById(server, punishmentId).orElse(null); + if (punishment == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", "Punishment not found" + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "punishment", punishment + )); + } + + @PostMapping("/{punishmentId}/upload-token") + public ResponseEntity> createUploadToken( + @PathVariable String punishmentId, + @RequestBody @Valid CreateUploadTokenRequest body, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + String token = punishmentQueryService.createEvidenceUploadToken(server, punishmentId, body.issuerName()).orElse(null); + if (token == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", "Punishment not found" + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "token", token + )); + } + + @GetMapping("/recent") + public ResponseEntity> getRecentPunishments( + @RequestParam(defaultValue = "48") @Min(1) @Max(8760) int hours, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return ResponseEntity.ok(Map.of( + "status", 200, + "punishments", punishmentQueryService.getRecentPunishments(server, hours) + )); + } + + @GetMapping("/preview") public ResponseEntity previewPunishment( - @RequestParam String playerUuid, - @RequestParam int typeOrdinal, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(punishmentQueryService.previewPunishment(server, playerUuid, typeOrdinal)); - } - - @PostMapping("/acknowledge") - public ResponseEntity> acknowledgePunishment( - @RequestBody @Valid AcknowledgeRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentLifecycleService.acknowledgePunishment( - server, - UUID.fromString(request.playerUuid()), - request.punishmentId() - ); - - return switch (result.status()) { - case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( - "status", 400, - "message", result.message() - )); - case NO_OP, SUCCESS -> ResponseEntity.ok(Map.of( - "status", 200, - "message", result.message() - )); - }; - } - - @PostMapping("/{punishmentId}/pardon") - public ResponseEntity> pardonPunishment( - @PathVariable String punishmentId, - @RequestBody @Valid PardonRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentLifecycleService.pardonPunishment( - server, - punishmentId, - request.issuerName(), - request.issuerId(), - request.reason() - ); - - return switch (result.status()) { - case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( - "status", 400, - "message", result.message() - )); - case NO_OP -> ResponseEntity.ok(Map.of( - "status", 200, - "success", false, - "pardonedCount", 0, - "message", result.message() - )); - case SUCCESS -> ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "pardonedCount", 1, - "message", result.message() - )); - }; - } - - @PostMapping("/{punishmentId}/note") - public ResponseEntity> addNote( - @PathVariable String punishmentId, - @RequestBody @Valid AddNoteRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentEvidenceService.addPunishmentNote( - server, - punishmentId, - request.note(), - request.issuerName(), - request.issuerId() - ); - - if (result.status() == PunishmentOperationStatus.NOT_FOUND) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", result.message() - )); - } - - @PostMapping("/{punishmentId}/evidence") - public ResponseEntity> addEvidence( - @PathVariable String punishmentId, - @RequestBody @Valid AddEvidenceRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentEvidenceService.addEvidence( - server, - punishmentId, - request.evidenceUrl(), - request.issuerName(), - request.issuerId() - ); - - if (result.status() == PunishmentOperationStatus.NOT_FOUND) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", result.message() - )); - } - - @PostMapping("/{punishmentId}/duration") - public ResponseEntity> changeDuration( - @PathVariable String punishmentId, - @RequestBody @Valid ChangeDurationRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentMutationService.changeDuration( - server, - punishmentId, - request.newDuration(), - request.issuerName(), - request.issuerId() - ); - - if (result.status() == PunishmentOperationStatus.NOT_FOUND) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", result.message() - )); - } - - @PostMapping("/{punishmentId}/toggle") - public ResponseEntity> toggleOption( - @PathVariable String punishmentId, - @RequestBody @Valid ToggleOptionRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentMutationService.toggleOption( - server, - punishmentId, - request.option(), - request.enabled(), - request.issuerName(), - request.issuerId() - ); - - return switch (result.status()) { - case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( - "status", 400, - "message", result.message() - )); - case NO_OP, SUCCESS -> ResponseEntity.ok(Map.of( - "status", 200, - "success", result.success(), - "message", result.message() - )); - }; - } - - @PostMapping("/{punishmentId}/stat-wipe-acknowledge") - public ResponseEntity> acknowledgeStatWipe( - @PathVariable String punishmentId, - @RequestBody @Valid StatWipeAcknowledgeRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentMutationService.acknowledgeStatWipe(server, punishmentId); - - return switch (result.status()) { - case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( - "status", 400, - "message", result.message() - )); - case NO_OP -> ResponseEntity.ok(Map.of( - "status", 200, - "message", result.message() - )); - case SUCCESS -> ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", result.message() - )); - }; - } - - @PostMapping("/{punishmentId}/tickets") - public ResponseEntity> modifyPunishmentTickets( - @PathVariable String punishmentId, - @RequestBody @Valid ModifyTicketsRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentMutationService.modifyPunishmentTickets( - server, - punishmentId, - new ModifyPunishmentTicketsRequest( - request.addTicketIds(), - request.removeTicketIds(), - request.modifyAssociatedTickets(), - request.issuerName(), - request.issuerId() - ) - ); - - if (result.status() == PunishmentOperationStatus.NOT_FOUND) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", result.message() - )); - } - - public record MinecraftCreatePunishmentRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String targetUuid, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @JsonProperty("type_ordinal") @Min(0) int typeOrdinal, - @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_REASON_MAX_LENGTH) String reason, - @Min(0) Long duration, - @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_DATA_MAX_ENTRIES) Map data, - @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_NOTES_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.PLAYER_NOTE_TEXT_MAX_LENGTH) String> notes, - @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_TICKETS_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.ID_MAX_LENGTH) String> attachedTicketIds, - @Size(max = RequestValidationLimits.PLAYER_SEVERITY_MAX_LENGTH) String severity, - @Size(max = RequestValidationLimits.PLAYER_STATUS_MAX_LENGTH) String status - ) { - } - - public record AcknowledgeRequest( - @NotBlank @Size(max = RequestValidationLimits.ID_MAX_LENGTH) String punishmentId, - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String playerUuid, - @Size(max = RequestValidationLimits.TIMESTAMP_MAX_LENGTH) String executedAt, - boolean success, - @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_REASON_MAX_LENGTH) String errorMessage - ) { - } - - public record PardonRequest( - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_REASON_MAX_LENGTH) String reason, - @Size(max = RequestValidationLimits.PUNISHMENT_TYPE_NAME_MAX_LENGTH) String expectedType - ) { - } - - public record AddNoteRequest( - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @NotBlank @Size(max = RequestValidationLimits.PLAYER_NOTE_TEXT_MAX_LENGTH) String note - ) { - } - - public record AddEvidenceRequest( - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @NotBlank @Size(max = RequestValidationLimits.EVIDENCE_URL_MAX_LENGTH) String evidenceUrl - ) { - } - - public record ChangeDurationRequest( - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @Min(0) Long newDuration - ) { - } - - public record ToggleOptionRequest( - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @NotBlank @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_TYPE_MAX_LENGTH) String option, - boolean enabled - ) { - } - - public record StatWipeAcknowledgeRequest( - @NotBlank @Size(max = RequestValidationLimits.ID_MAX_LENGTH) String punishmentId, - @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String serverName, - boolean success - ) { - } - - public record ModifyTicketsRequest( - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_TICKETS_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.ID_MAX_LENGTH) String> addTicketIds, - @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_TICKETS_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.ID_MAX_LENGTH) String> removeTicketIds, - boolean modifyAssociatedTickets - ) { - } -} + @RequestParam String playerUuid, + @RequestParam int typeOrdinal, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return ResponseEntity.ok(punishmentQueryService.previewPunishment(server, playerUuid, typeOrdinal)); + } + + @PostMapping("/acknowledge") + public ResponseEntity> acknowledgePunishment( + @RequestBody @Valid AcknowledgeRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentLifecycleService.acknowledgePunishment( + server, + UUID.fromString(request.playerUuid()), + request.punishmentId() + ); + + return switch (result.status()) { + case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( + "status", 400, + "message", result.message() + )); + case NO_OP, SUCCESS -> ResponseEntity.ok(Map.of( + "status", 200, + "message", result.message() + )); + }; + } + + @PostMapping("/{punishmentId}/pardon") + public ResponseEntity> pardonPunishment( + @PathVariable String punishmentId, + @RequestBody @Valid PardonRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentLifecycleService.pardonPunishment( + server, + punishmentId, + request.issuerName(), + request.issuerId(), + request.reason() + ); + + return switch (result.status()) { + case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( + "status", 400, + "message", result.message() + )); + case NO_OP -> ResponseEntity.ok(Map.of( + "status", 200, + "success", false, + "pardonedCount", 0, + "message", result.message() + )); + case SUCCESS -> ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "pardonedCount", 1, + "message", result.message() + )); + }; + } + + @PostMapping("/{punishmentId}/note") + public ResponseEntity> addNote( + @PathVariable String punishmentId, + @RequestBody @Valid AddNoteRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentEvidenceService.addPunishmentNote( + server, + punishmentId, + request.note(), + request.issuerName(), + request.issuerId() + ); + + if (result.status() == PunishmentOperationStatus.NOT_FOUND) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", result.message() + )); + } + + @PostMapping("/{punishmentId}/evidence") + public ResponseEntity> addEvidence( + @PathVariable String punishmentId, + @RequestBody @Valid AddEvidenceRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentEvidenceService.addEvidence( + server, + punishmentId, + request.evidenceUrl(), + request.issuerName(), + request.issuerId() + ); + + if (result.status() == PunishmentOperationStatus.NOT_FOUND) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", result.message() + )); + } + + @PostMapping("/{punishmentId}/duration") + public ResponseEntity> changeDuration( + @PathVariable String punishmentId, + @RequestBody @Valid ChangeDurationRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentMutationService.changeDuration( + server, + punishmentId, + request.newDuration(), + request.issuerName(), + request.issuerId() + ); + + if (result.status() == PunishmentOperationStatus.NOT_FOUND) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", result.message() + )); + } + + @PostMapping("/{punishmentId}/toggle") + public ResponseEntity> toggleOption( + @PathVariable String punishmentId, + @RequestBody @Valid ToggleOptionRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentMutationService.toggleOption( + server, + punishmentId, + request.option(), + request.enabled(), + request.issuerName(), + request.issuerId() + ); + + return switch (result.status()) { + case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( + "status", 400, + "message", result.message() + )); + case NO_OP, SUCCESS -> ResponseEntity.ok(Map.of( + "status", 200, + "success", result.success(), + "message", result.message() + )); + }; + } + + @PostMapping("/{punishmentId}/stat-wipe-acknowledge") + public ResponseEntity> acknowledgeStatWipe( + @PathVariable String punishmentId, + @RequestBody @Valid StatWipeAcknowledgeRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentMutationService.acknowledgeStatWipe(server, punishmentId); + + return switch (result.status()) { + case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( + "status", 400, + "message", result.message() + )); + case NO_OP -> ResponseEntity.ok(Map.of( + "status", 200, + "message", result.message() + )); + case SUCCESS -> ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", result.message() + )); + }; + } + + @PostMapping("/{punishmentId}/tickets") + public ResponseEntity> modifyPunishmentTickets( + @PathVariable String punishmentId, + @RequestBody @Valid ModifyTicketsRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentMutationService.modifyPunishmentTickets( + server, + punishmentId, + new ModifyPunishmentTicketsRequest( + request.addTicketIds(), + request.removeTicketIds(), + request.modifyAssociatedTickets(), + request.issuerName(), + request.issuerId() + ) + ); + + if (result.status() == PunishmentOperationStatus.NOT_FOUND) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", result.message() + )); + } + + public record AcknowledgeRequest( + @NotBlank @Size(max = RequestValidationLimits.ID_MAX_LENGTH) String punishmentId, + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String playerUuid, + @Size(max = RequestValidationLimits.TIMESTAMP_MAX_LENGTH) String executedAt, + boolean success, + @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_REASON_MAX_LENGTH) String errorMessage + ) { + } + + public record PardonRequest( + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_REASON_MAX_LENGTH) String reason, + @Size(max = RequestValidationLimits.PUNISHMENT_TYPE_NAME_MAX_LENGTH) String expectedType + ) { + } + + public record AddNoteRequest( + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @NotBlank @Size(max = RequestValidationLimits.PLAYER_NOTE_TEXT_MAX_LENGTH) String note + ) { + } + + public record AddEvidenceRequest( + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @NotBlank @Size(max = RequestValidationLimits.EVIDENCE_URL_MAX_LENGTH) String evidenceUrl + ) { + } + + public record ChangeDurationRequest( + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @Min(0) Long newDuration + ) { + } + + public record ToggleOptionRequest( + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @NotBlank @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_TYPE_MAX_LENGTH) String option, + boolean enabled + ) { + } + + public record StatWipeAcknowledgeRequest( + @NotBlank @Size(max = RequestValidationLimits.ID_MAX_LENGTH) String punishmentId, + @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String serverName, + boolean success + ) { + } + + public record ModifyTicketsRequest( + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_TICKETS_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.ID_MAX_LENGTH) String> addTicketIds, + @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_TICKETS_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.ID_MAX_LENGTH) String> removeTicketIds, + boolean modifyAssociatedTickets + ) { + } +} diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentProtoMapper.java b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentProtoMapper.java index e1ed65a..5cdd74e 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentProtoMapper.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentProtoMapper.java @@ -1,97 +1,85 @@ package gg.modl.backend.player.controller; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.dateAwareString; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.intValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.list; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.listOfMaps; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.longValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalInt; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalLong; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringObjectMap; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; -import com.google.protobuf.Struct; import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; +import gg.modl.backend.player.dto.request.MinecraftCreatePunishmentRequest; +import gg.modl.backend.player.dto.response.PunishmentView; import gg.modl.proto.modl.v1.CreatePunishmentRequest; import gg.modl.proto.modl.v1.PunishmentDetailResponse; import gg.modl.proto.modl.v1.RecentPunishmentsResponse; -import java.util.Map; -import java.util.Objects; final class MinecraftPunishmentProtoMapper { private MinecraftPunishmentProtoMapper() { } - static PunishmentDetailResponse.PunishmentDetailEntry toPunishmentDetail(Map punishment) { + static PunishmentDetailResponse.PunishmentDetailEntry toPunishmentDetail(PunishmentView punishment) { PunishmentDetailResponse.PunishmentDetailEntry.Builder builder = PunishmentDetailResponse.PunishmentDetailEntry.newBuilder() - .setPlayerName(stringValue(punishment.get("playerName"))) - .setPlayerUuid(stringValue(punishment.get("playerUuid"))) - .setId(stringValue(punishment.get("id"))) - .setIssuerName(stringValue(punishment.get("issuerName"))) - .setIssued(dateAwareString(punishment.get("issued"))) - .setStarted(dateAwareString(punishment.get("started"))) - .setType(stringValue(punishment.get("type"))) - .setTypeOrdinal(intValue(punishment.get("typeOrdinal"))); + .setPlayerName(stringValue(punishment.playerName())) + .setPlayerUuid(stringValue(punishment.playerUuid())) + .setId(stringValue(punishment.id())) + .setIssuerName(stringValue(punishment.issuerName())) + .setIssued(dateAwareString(punishment.issued())) + .setStarted(dateAwareString(punishment.started())) + .setType(stringValue(punishment.type())) + .setTypeOrdinal(punishment.typeOrdinal()); - list(punishment.get("attachedTicketIds")).stream() - .map(Objects::toString) - .forEach(builder::addAttachedTicketIds); + punishment.attachedTicketIds().forEach(builder::addAttachedTicketIds); - Map data = mapValue(punishment.get("data")); - if (data != null) { - builder.setData(ProtoMapperSupport.legacyStruct(data)); + if (punishment.data() != null) { + builder.setData(ProtoMapperSupport.legacyStruct(punishment.data())); } - listOfMaps(punishment.get("modifications")).stream() + punishment.modifications().stream() .map(ProtoMapperSupport::legacyStruct) .forEach(builder::addModifications); - listOfMaps(punishment.get("notes")).stream() + punishment.notes().stream() .map(ProtoMapperSupport::legacyStruct) .forEach(builder::addNotes); - listOfMaps(punishment.get("evidence")).stream() + punishment.evidence().stream() .map(ProtoMapperSupport::legacyStruct) .forEach(builder::addEvidence); return builder.build(); } - static RecentPunishmentsResponse.RecentPunishment toRecentPunishment(Map punishment) { + static RecentPunishmentsResponse.RecentPunishment toRecentPunishment(PunishmentView punishment) { RecentPunishmentsResponse.RecentPunishment.Builder builder = RecentPunishmentsResponse.RecentPunishment.newBuilder() - .setPlayerName(stringValue(punishment.get("playerName"))) - .setPlayerUuid(stringValue(punishment.get("playerUuid"))) - .setId(stringValue(punishment.get("id"))) - .setIssuerName(stringValue(punishment.get("issuerName"))) - .setIssued(longValue(punishment.get("issued"))) - .setType(stringValue(punishment.get("type"))); + .setPlayerName(stringValue(punishment.playerName())) + .setPlayerUuid(stringValue(punishment.playerUuid())) + .setId(stringValue(punishment.id())) + .setIssuerName(stringValue(punishment.issuerName())) + .setIssued(longValue(punishment.issued())) + .setType(stringValue(punishment.type())); - setOptionalLong(builder::setStarted, punishment.get("started")); - setOptionalInt(builder::setTypeOrdinal, punishment.get("typeOrdinal")); - listOfMaps(punishment.get("modifications")).stream() + setOptionalLong(builder::setStarted, punishment.started()); + builder.setTypeOrdinal(punishment.typeOrdinal()); + punishment.modifications().stream() .map(MinecraftPlayerProtoMapper::toPunishmentModification) .forEach(builder::addModifications); - listOfMaps(punishment.get("notes")).stream() + punishment.notes().stream() .map(MinecraftPlayerProtoMapper::toPunishmentNote) .forEach(builder::addNotes); - listOfMaps(punishment.get("evidence")).stream() + punishment.evidence().stream() .map(MinecraftPlayerProtoMapper::toPunishmentEvidence) .forEach(builder::addEvidence); - list(punishment.get("attachedTicketIds")).stream() - .map(Objects::toString) - .forEach(builder::addAttachedTicketIds); + punishment.attachedTicketIds().forEach(builder::addAttachedTicketIds); - Map data = mapValue(punishment.get("data")); - if (data != null) { - builder.setData(ProtoMapperSupport.legacyStruct(data)); + if (punishment.data() != null) { + builder.setData(ProtoMapperSupport.legacyStruct(punishment.data())); } return builder.build(); } - static MinecraftPunishmentController.MinecraftCreatePunishmentRequest toLegacyCreatePunishmentRequest( + static MinecraftCreatePunishmentRequest toLegacyCreatePunishmentRequest( CreatePunishmentRequest request ) { - return new MinecraftPunishmentController.MinecraftCreatePunishmentRequest( + return new MinecraftCreatePunishmentRequest( request.getTargetUuid(), request.hasIssuerName() ? request.getIssuerName() : null, request.hasIssuerId() ? request.getIssuerId() : null, @@ -105,14 +93,4 @@ static MinecraftPunishmentController.MinecraftCreatePunishmentRequest toLegacyCr request.hasStatus() ? request.getStatus() : null ); } - - private static Map mapValue(Object value) { - if (value instanceof Struct struct) { - return ProtoMapperSupport.structToMap(struct); - } - if (value instanceof Map map) { - return stringObjectMap(map); - } - return null; - } } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentV3Controller.java b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentV3Controller.java index 12cf442..499178a 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentV3Controller.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentV3Controller.java @@ -1,5 +1,7 @@ package gg.modl.backend.player.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.emptyToNull; + import com.google.protobuf.Empty; import gg.modl.backend.infrastructure.proto.ProtobufMediaTypes; import gg.modl.backend.infrastructure.rest.RESTMappingV3; @@ -13,6 +15,7 @@ import gg.modl.backend.player.service.PunishmentQueryService; import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; +import gg.modl.backend.player.dto.response.PunishmentView; import gg.modl.backend.server.data.Server; import gg.modl.proto.modl.v1.AddPunishmentEvidenceRequest; import gg.modl.proto.modl.v1.AddPunishmentEvidenceResponse; @@ -42,7 +45,6 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -61,6 +63,8 @@ @RequiredArgsConstructor @Validated public class MinecraftPunishmentV3Controller { + private static final int RECENT_PUNISHMENTS_MAX_HOURS = 8760; + private final PunishmentLifecycleService punishmentLifecycleService; private final PunishmentMutationService punishmentMutationService; private final PunishmentEvidenceService punishmentEvidenceService; @@ -80,7 +84,7 @@ public ResponseEntity previewPunishment( @GetMapping(value = "/recent", produces = ProtobufMediaTypes.APPLICATION_X_PROTOBUF_VALUE) public ResponseEntity getRecentPunishments( - @RequestParam(defaultValue = "48") @Min(1) @Max(8760) int hours, + @RequestParam(defaultValue = "48") @Min(1) @Max(RECENT_PUNISHMENTS_MAX_HOURS) int hours, HttpServletRequest httpRequest ) { validateRecentHours(hours); @@ -100,7 +104,7 @@ public ResponseEntity getPunishmentById( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map punishment = punishmentQueryService + PunishmentView punishment = punishmentQueryService .getMinecraftPunishmentById(server, punishmentId) .orElse(null); @@ -485,8 +489,8 @@ private ResponseEntity uploadTokenResponse( } private void validateRecentHours(int hours) { - if (hours < 1 || hours > 8760) { - throw new ValidationException("hours must be between 1 and 8760"); + if (hours < 1 || hours > RECENT_PUNISHMENTS_MAX_HOURS) { + throw new ValidationException("hours must be between 1 and " + RECENT_PUNISHMENTS_MAX_HOURS); } } @@ -531,10 +535,6 @@ private void validateCreatePunishmentData(CreatePunishmentRequest request) { } } - private String emptyToNull(String value) { - return value == null || value.isEmpty() ? null : value; - } - private ResponseEntity statWipeResponse( HttpStatus httpStatus, int bodyStatus, diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftStartupController.java b/src/main/java/gg/modl/backend/player/controller/MinecraftStartupController.java index 86c5c56..90265f3 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftStartupController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftStartupController.java @@ -1,5 +1,6 @@ package gg.modl.backend.player.controller; +import gg.modl.backend.player.dto.request.StartupRequest; import gg.modl.backend.player.service.MinecraftStartupService; import gg.modl.backend.infrastructure.rest.RESTMappingV2; import gg.modl.backend.infrastructure.rest.RequestUtil; @@ -8,7 +9,6 @@ import jakarta.validation.Valid; import java.util.Map; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -18,7 +18,6 @@ @RestController @RequestMapping(RESTMappingV2.PREFIX_MINECRAFT) @RequiredArgsConstructor -@Slf4j public class MinecraftStartupController { private final MinecraftStartupService minecraftStartupService; @@ -32,12 +31,4 @@ public ResponseEntity> startup( return ResponseEntity.ok(minecraftStartupService.handleStartup(server, request, clientIp)); } - public record StartupRequest( - String serverVersion, - String platformType, - String pluginVersion, - int maxPlayers, - String serverName - ) { - } } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncController.java b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncController.java index 22179e1..43b0702 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncController.java @@ -1,6 +1,7 @@ package gg.modl.backend.player.controller; import gg.modl.backend.player.service.MinecraftSyncService; +import gg.modl.backend.player.dto.response.SyncResult; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.server.data.Server; @@ -15,9 +16,7 @@ import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -27,43 +26,24 @@ @RestController @RequestMapping(RESTMappingV1.MINECRAFT_PLAYERS) @RequiredArgsConstructor -@Slf4j public class MinecraftSyncController { private final MinecraftSyncService minecraftSyncService; @PostMapping("/sync") - public ResponseEntity> sync( + public ResponseEntity sync( @RequestBody @Valid SyncRequest syncRequest, HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftSyncService.ServerStatusInput serverStatusInput = syncRequest.serverStatus() != null - ? new MinecraftSyncService.ServerStatusInput( - syncRequest.serverStatus().onlinePlayerCount(), - syncRequest.serverStatus().maxPlayers(), - syncRequest.serverStatus().serverVersion(), - syncRequest.serverStatus().platformType(), - syncRequest.serverStatus().pluginVersion() - ) - : null; String clientIp = RequestUtil.getClientIp(httpRequest); return ResponseEntity.ok(minecraftSyncService.sync( server, syncRequest.lastSyncTimestamp(), - syncRequest.onlinePlayers() == null ? List.of() : syncRequest.onlinePlayers() - .stream() - .map(player -> new MinecraftSyncService.OnlinePlayerInput(player.uuid(), player.username(), player.ipAddress())) - .toList(), + SyncRequestMapper.toOnlinePlayers(syncRequest.onlinePlayers()), syncRequest.serverName(), - syncRequest.chatLogs() == null ? List.of() : syncRequest.chatLogs() - .stream() - .map(log -> new MinecraftSyncService.ChatLogInput(log.uuid(), log.username(), log.message(), log.timestamp(), log.server())) - .toList(), - syncRequest.commandLogs() == null ? List.of() : syncRequest.commandLogs() - .stream() - .map(log -> new MinecraftSyncService.CommandLogInput(log.uuid(), log.username(), log.command(), log.timestamp(), log.server())) - .toList(), - serverStatusInput, + SyncRequestMapper.toChatLogs(syncRequest.chatLogs()), + SyncRequestMapper.toCommandLogs(syncRequest.commandLogs()), + SyncRequestMapper.toServerStatus(syncRequest.serverStatus()), clientIp )); } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncProtoMapper.java b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncProtoMapper.java index 841ff71..9507b50 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncProtoMapper.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncProtoMapper.java @@ -20,24 +20,24 @@ static List toOnlinePlayers(SyncRequest static List toChatLogs(SyncRequest request) { return request.getChatLogsList().stream() - .map(log -> new MinecraftSyncService.ChatLogInput( - log.getUuid(), - log.getUsername(), - log.getMessage(), - log.getTimestamp(), - log.getServer() + .map(entry -> new MinecraftSyncService.ChatLogInput( + entry.getUuid(), + entry.getUsername(), + entry.getMessage(), + entry.getTimestamp(), + entry.getServer() )) .toList(); } static List toCommandLogs(SyncRequest request) { return request.getCommandLogsList().stream() - .map(log -> new MinecraftSyncService.CommandLogInput( - log.getUuid(), - log.getUsername(), - log.getCommand(), - log.getTimestamp(), - log.getServer() + .map(entry -> new MinecraftSyncService.CommandLogInput( + entry.getUuid(), + entry.getUsername(), + entry.getCommand(), + entry.getTimestamp(), + entry.getServer() )) .toList(); } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV2Controller.java b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV2Controller.java index 99a773a..8178f3b 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV2Controller.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV2Controller.java @@ -4,6 +4,7 @@ import gg.modl.backend.player.controller.MinecraftSyncController.CommandLogEntry; import gg.modl.backend.player.controller.MinecraftSyncController.OnlinePlayer; import gg.modl.backend.player.service.MinecraftSyncService; +import gg.modl.backend.player.dto.response.SyncResult; import gg.modl.backend.infrastructure.rest.RESTMappingV2; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.infrastructure.validation.RequestValidationLimits; @@ -12,9 +13,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Size; import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -24,12 +23,11 @@ @RestController @RequestMapping(RESTMappingV2.MINECRAFT_PLAYERS) @RequiredArgsConstructor -@Slf4j public class MinecraftSyncV2Controller { private final MinecraftSyncService minecraftSyncService; @PostMapping("/sync") - public ResponseEntity> sync( + public ResponseEntity sync( @RequestBody @Valid V2SyncRequest syncRequest, HttpServletRequest httpRequest ) { @@ -38,19 +36,10 @@ public ResponseEntity> sync( return ResponseEntity.ok(minecraftSyncService.syncV2( server, syncRequest.lastSyncTimestamp(), - syncRequest.onlinePlayers() == null ? List.of() : syncRequest.onlinePlayers() - .stream() - .map(player -> new MinecraftSyncService.OnlinePlayerInput(player.uuid(), player.username(), player.ipAddress())) - .toList(), + SyncRequestMapper.toOnlinePlayers(syncRequest.onlinePlayers()), syncRequest.serverName(), - syncRequest.chatLogs() == null ? List.of() : syncRequest.chatLogs() - .stream() - .map(entry -> new MinecraftSyncService.ChatLogInput(entry.uuid(), entry.username(), entry.message(), entry.timestamp(), entry.server())) - .toList(), - syncRequest.commandLogs() == null ? List.of() : syncRequest.commandLogs() - .stream() - .map(entry -> new MinecraftSyncService.CommandLogInput(entry.uuid(), entry.username(), entry.command(), entry.timestamp(), entry.server())) - .toList(), + SyncRequestMapper.toChatLogs(syncRequest.chatLogs()), + SyncRequestMapper.toCommandLogs(syncRequest.commandLogs()), clientIp )); } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV3Controller.java b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV3Controller.java index 5ca8d08..4a20718 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV3Controller.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV3Controller.java @@ -7,6 +7,7 @@ import gg.modl.backend.infrastructure.validation.RequestValidationLimits; import gg.modl.backend.player.service.MinecraftSyncService; import gg.modl.backend.player.service.SyncProtoFactory; +import gg.modl.backend.player.dto.response.SyncResult; import gg.modl.backend.server.data.Server; import gg.modl.proto.modl.v1.SimpleResponse; import gg.modl.proto.modl.v1.SyncRequest; @@ -14,7 +15,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.time.Instant; -import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -41,7 +41,7 @@ public ResponseEntity sync( Server server = RequestUtil.getRequestServer(httpRequest); requireLogBatchesWithinLimit(request); String clientIp = RequestUtil.getClientIp(httpRequest); - Map response = minecraftSyncService.sync( + SyncResult response = minecraftSyncService.sync( server, request.getLastSyncTimestamp(), MinecraftSyncProtoMapper.toOnlinePlayers(request), diff --git a/src/main/java/gg/modl/backend/player/controller/PanelPlayerController.java b/src/main/java/gg/modl/backend/player/controller/PanelPlayerController.java index 050409b..b3169d5 100644 --- a/src/main/java/gg/modl/backend/player/controller/PanelPlayerController.java +++ b/src/main/java/gg/modl/backend/player/controller/PanelPlayerController.java @@ -1,321 +1,333 @@ -package gg.modl.backend.player.controller; - -import gg.modl.backend.player.PlayerService; -import gg.modl.backend.player.dto.response.LinkedAccountResponse; -import gg.modl.backend.player.dto.response.PunishmentPreviewView; -import gg.modl.backend.player.dto.response.PunishmentResponse; -import gg.modl.backend.player.dto.response.PunishmentSearchResult; -import gg.modl.backend.player.service.AccountLinkingService; -import gg.modl.backend.player.service.PunishmentEvidenceService; -import gg.modl.backend.player.service.PunishmentLifecycleService; -import gg.modl.backend.player.service.PunishmentMutationService; -import gg.modl.backend.player.service.PunishmentQueryService; -import gg.modl.backend.replay.service.ReplayService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.validation.RegExpConstants; -import gg.modl.proto.modl.v1.ActivePunishmentsResponse; -import gg.modl.proto.modl.v1.PanelAddEvidenceRequest; -import gg.modl.proto.modl.v1.PanelAddModificationRequest; -import gg.modl.proto.modl.v1.PanelAddPunishmentNoteRequest; -import gg.modl.proto.modl.v1.PanelCreatePunishmentRequest; -import gg.modl.proto.modl.v1.PanelFindAndLinkAccountsResponse; -import gg.modl.proto.modl.v1.PanelLinkedAccountsResponse; -import gg.modl.proto.modl.v1.PanelLinkedBansResponse; -import gg.modl.proto.modl.v1.PlayerDetailResponse; -import gg.modl.proto.modl.v1.PlayerReplaysResponse; -import gg.modl.proto.modl.v1.PlayerSearchResultsResponse; -import gg.modl.proto.modl.v1.SimpleResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_PLAYERS) -@RequiredArgsConstructor -@Validated -public class PanelPlayerController { - private static final SimpleResponse SUCCESS = SimpleResponse.newBuilder().setSuccess(true).build(); - - private final PlayerService playerService; - private final PunishmentQueryService punishmentQueryService; - private final PunishmentLifecycleService punishmentLifecycleService; - private final PunishmentEvidenceService punishmentEvidenceService; - private final PunishmentMutationService punishmentMutationService; - private final AccountLinkingService accountLinkingService; - private final ReplayService replayService; - - @GetMapping - public ResponseEntity searchPlayers( - @RequestParam @Size(min = 2) String search, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerSearchResults( - playerService.searchPlayers(server, search))); - } - - @GetMapping("/{uuid}") - public ResponseEntity getPlayer( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerDetail( - playerService.getPlayerDetails(server, UUID.fromString(uuid)))); - } - - @PostMapping - public ResponseEntity createPlayer( - @RequestBody gg.modl.proto.modl.v1.CreatePlayerRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - playerService.createPlayer( - server, - UUID.fromString(createRequest.getMinecraftUuid()), - createRequest.getUsername() - ); - - return ResponseEntity.status(HttpStatus.CREATED).body(SUCCESS); - } - - @PostMapping("/{uuid}/usernames") - public ResponseEntity addUsername( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody gg.modl.proto.modl.v1.AddPlayerUsernameRequest addRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - playerService.addUsername(server, UUID.fromString(uuid), addRequest.getUsername()); - return ResponseEntity.ok(SUCCESS); - } - - @PostMapping("/{uuid}/notes") - public ResponseEntity addNote( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody gg.modl.proto.modl.v1.CreatePlayerNoteRequest addRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - playerService.addNote( - server, - UUID.fromString(uuid), - addRequest.getText(), - PanelPlayerProtoMapper.emptyToNull(addRequest.getIssuerName()), - PanelPlayerProtoMapper.emptyToNull(addRequest.getIssuerId()) - ); - return ResponseEntity.ok(SUCCESS); - } - - @PostMapping("/{uuid}/ips") - public ResponseEntity addIp( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody gg.modl.proto.modl.v1.AddPlayerIpRequest addRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - playerService.addIp(server, UUID.fromString(uuid), addRequest.getIpAddress()); - return ResponseEntity.ok(SUCCESS); - } - - @PostMapping("/{uuid}/punishments") - public ResponseEntity createPunishment( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody PanelCreatePunishmentRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String email = RequestUtil.getSessionEmail(request); - - punishmentLifecycleService.validatePunishmentPermission(server, email, createRequest.getTypeOrdinal()); - punishmentLifecycleService.createPunishment( - server, - UUID.fromString(uuid), - PanelPlayerProtoMapper.fromCreatePunishment(createRequest) - ); - return ResponseEntity.ok(SUCCESS); - } - - @PostMapping("/{uuid}/punishments/{punishmentId}/modifications") - public ResponseEntity addModification( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @PathVariable String punishmentId, - @RequestBody PanelAddModificationRequest modRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - punishmentMutationService.addModification( - server, - UUID.fromString(uuid), - punishmentId, - PanelPlayerProtoMapper.fromAddModification(modRequest) - ); - return ResponseEntity.ok(SUCCESS); - } - - @GetMapping("/{uuid}/punishments/active") - public ResponseEntity getActivePunishments( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List punishments = punishmentQueryService.getActivePunishments( - server, - UUID.fromString(uuid) - ); - - return ResponseEntity.ok(PanelPlayerProtoMapper.toActivePunishments(punishments)); - } - - @GetMapping("/{uuid}/punishments/preview") - public ResponseEntity previewPunishment( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestParam int typeOrdinal, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - PunishmentPreviewView preview = punishmentQueryService.previewPunishment(server, uuid, typeOrdinal); - return ResponseEntity.ok(PunishmentPreviewProtoMapper.toProto(preview)); - } - - @GetMapping("/punishments/{punishmentId}") - public ResponseEntity getPunishmentById( - @PathVariable String punishmentId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelPlayerProtoMapper.toPunishment( - punishmentQueryService.getPunishmentById(server, punishmentId))); - } - - @GetMapping("/punishments/search") - public ResponseEntity> searchPunishments( - @RequestParam @Size(min = 2) String q, - @RequestParam(defaultValue = "false") boolean activeOnly, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(punishmentQueryService.searchPunishments(server, q, activeOnly)); - } - - @PostMapping("/{uuid}/punishments/{punishmentId}/notes") - public ResponseEntity addPunishmentNote( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @PathVariable String punishmentId, - @RequestBody PanelAddPunishmentNoteRequest noteRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - punishmentEvidenceService.addPunishmentNote( - server, - UUID.fromString(uuid), - punishmentId, - noteRequest.getText(), - PanelPlayerProtoMapper.emptyToNull(noteRequest.getIssuerName()), - PanelPlayerProtoMapper.emptyToNull(noteRequest.getIssuerId()) - ); - return ResponseEntity.ok(SUCCESS); - } - - @PostMapping("/{uuid}/punishments/{punishmentId}/evidence") - public ResponseEntity addEvidence( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @PathVariable String punishmentId, - @RequestBody PanelAddEvidenceRequest evidenceRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - punishmentEvidenceService.addEvidence( - server, - UUID.fromString(uuid), - punishmentId, - PanelPlayerProtoMapper.fromAddEvidence(evidenceRequest) - ); - return ResponseEntity.ok(SUCCESS); - } - - @PostMapping("/{uuid}/punishments/{punishmentId}/tickets") - public ResponseEntity modifyPunishmentTickets( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @PathVariable String punishmentId, - @RequestBody gg.modl.proto.modl.v1.ModifyPunishmentTicketsRequest ticketRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - punishmentMutationService.modifyPunishmentTickets( - server, - UUID.fromString(uuid), - punishmentId, - PanelPlayerProtoMapper.fromModifyTickets(ticketRequest) - ); - return ResponseEntity.ok(SUCCESS); - } - - @GetMapping("/{uuid}/linked") - public ResponseEntity getLinkedAccounts( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List linkedAccounts = accountLinkingService.getLinkedAccounts( - server, - UUID.fromString(uuid) - ); - - return ResponseEntity.ok(PanelPlayerProtoMapper.toLinkedAccounts(linkedAccounts)); - } - - @GetMapping("/{uuid}/replays") - public ResponseEntity getPlayerReplays( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerReplays( - replayService.listPlayerReplays(server, uuid))); - } - - @GetMapping("/punishments/{punishmentId}/linked-bans") - public ResponseEntity getLinkedBans( - @PathVariable String punishmentId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List> linkedBans = punishmentQueryService.getLinkedBansForParent(server, punishmentId); - return ResponseEntity.ok(PanelPlayerProtoMapper.toLinkedBans(linkedBans)); - } - - @PostMapping("/{uuid}/find-linked") - public ResponseEntity findAndLinkAccounts( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - AccountLinkingService.LinkingResult result = accountLinkingService.findAndLinkAccounts( - server, - UUID.fromString(uuid) - ); - - return ResponseEntity.ok(PanelPlayerProtoMapper.toFindAndLinkResult( - result.success(), - result.message(), - result.linkedAccountsFound() - )); - } -} +package gg.modl.backend.player.controller; + +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.emptyToNull; + +import gg.modl.backend.player.PlayerService; +import gg.modl.backend.player.dto.response.LinkedAccountResponse; +import gg.modl.backend.player.dto.response.PunishmentPreviewView; +import gg.modl.backend.player.dto.response.PunishmentResponse; +import gg.modl.backend.player.dto.response.PunishmentSearchResult; +import gg.modl.backend.player.service.AccountLinkingService; +import gg.modl.backend.player.dto.response.LinkedBanView; +import gg.modl.backend.player.service.PunishmentEvidenceService; +import gg.modl.backend.player.service.PunishmentLifecycleService; +import gg.modl.backend.player.service.PunishmentMutationService; +import gg.modl.backend.player.service.PunishmentQueryService; +import gg.modl.backend.replay.service.PlayerReplayListingService; +import gg.modl.backend.infrastructure.authorization.PanelAccessRule; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.validation.RegExpConstants; +import gg.modl.proto.modl.v1.ActivePunishmentsResponse; +import gg.modl.proto.modl.v1.AddPlayerIpRequest; +import gg.modl.proto.modl.v1.AddPlayerUsernameRequest; +import gg.modl.proto.modl.v1.CreatePlayerNoteRequest; +import gg.modl.proto.modl.v1.CreatePlayerRequest; +import gg.modl.proto.modl.v1.ModifyPunishmentTicketsRequest; +import gg.modl.proto.modl.v1.PanelAddEvidenceRequest; +import gg.modl.proto.modl.v1.PanelAddModificationRequest; +import gg.modl.proto.modl.v1.PanelAddPunishmentNoteRequest; +import gg.modl.proto.modl.v1.PanelCreatePunishmentRequest; +import gg.modl.proto.modl.v1.PanelFindAndLinkAccountsResponse; +import gg.modl.proto.modl.v1.PanelLinkedAccountsResponse; +import gg.modl.proto.modl.v1.PanelLinkedBansResponse; +import gg.modl.proto.modl.v1.PlayerDetailResponse; +import gg.modl.proto.modl.v1.PlayerReplaysResponse; +import gg.modl.proto.modl.v1.PlayerSearchResultsResponse; +import gg.modl.proto.modl.v1.PunishmentPreviewResponse; +import gg.modl.proto.modl.v1.SimpleResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_PLAYERS) +@RequiresPanelPermission(rule = PanelAccessRule.PLAYER_ACCESS) +@RequiredArgsConstructor +@Validated +public class PanelPlayerController { + private static final SimpleResponse SUCCESS = SimpleResponse.newBuilder().setSuccess(true).build(); + + private final PlayerService playerService; + private final PunishmentQueryService punishmentQueryService; + private final PunishmentLifecycleService punishmentLifecycleService; + private final PunishmentEvidenceService punishmentEvidenceService; + private final PunishmentMutationService punishmentMutationService; + private final AccountLinkingService accountLinkingService; + private final PlayerReplayListingService playerReplayListingService; + + @GetMapping + public ResponseEntity searchPlayers( + @RequestParam @Size(min = 2) String search, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerSearchResults( + playerService.searchPlayers(server, search))); + } + + @GetMapping("/{uuid}") + public ResponseEntity getPlayer( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerDetail( + playerService.getPlayerDetails(server, UUID.fromString(uuid)))); + } + + @PostMapping + public ResponseEntity createPlayer( + @RequestBody CreatePlayerRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + playerService.createPlayer( + server, + UUID.fromString(createRequest.getMinecraftUuid()), + createRequest.getUsername() + ); + + return ResponseEntity.status(HttpStatus.CREATED).body(SUCCESS); + } + + @PostMapping("/{uuid}/usernames") + public ResponseEntity addUsername( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestBody AddPlayerUsernameRequest addRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + playerService.addUsername(server, UUID.fromString(uuid), addRequest.getUsername()); + return ResponseEntity.ok(SUCCESS); + } + + @PostMapping("/{uuid}/notes") + public ResponseEntity addNote( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestBody CreatePlayerNoteRequest addRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + playerService.addNote( + server, + UUID.fromString(uuid), + addRequest.getText(), + emptyToNull(addRequest.getIssuerName()), + emptyToNull(addRequest.getIssuerId()) + ); + return ResponseEntity.ok(SUCCESS); + } + + @PostMapping("/{uuid}/ips") + public ResponseEntity addIp( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestBody AddPlayerIpRequest addRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + playerService.addIp(server, UUID.fromString(uuid), addRequest.getIpAddress()); + return ResponseEntity.ok(SUCCESS); + } + + @PostMapping("/{uuid}/punishments") + @RequiresPanelPermission(rule = PanelAccessRule.PERMIT_ALL) + public ResponseEntity createPunishment( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestBody PanelCreatePunishmentRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String email = RequestUtil.getSessionEmail(request); + + punishmentLifecycleService.validatePunishmentPermission(server, email, createRequest.getTypeOrdinal()); + punishmentLifecycleService.createPunishment( + server, + UUID.fromString(uuid), + PanelPlayerProtoMapper.fromCreatePunishment(createRequest) + ); + return ResponseEntity.ok(SUCCESS); + } + + @PostMapping("/{uuid}/punishments/{punishmentId}/modifications") + public ResponseEntity addModification( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @PathVariable String punishmentId, + @RequestBody PanelAddModificationRequest modRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + punishmentMutationService.addModification( + server, + UUID.fromString(uuid), + punishmentId, + PanelPlayerProtoMapper.fromAddModification(modRequest) + ); + return ResponseEntity.ok(SUCCESS); + } + + @GetMapping("/{uuid}/punishments/active") + public ResponseEntity getActivePunishments( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List punishments = punishmentQueryService.getActivePunishments( + server, + UUID.fromString(uuid) + ); + + return ResponseEntity.ok(PanelPlayerProtoMapper.toActivePunishments(punishments)); + } + + @GetMapping("/{uuid}/punishments/preview") + public ResponseEntity previewPunishment( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestParam int typeOrdinal, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + PunishmentPreviewView preview = punishmentQueryService.previewPunishment(server, uuid, typeOrdinal); + return ResponseEntity.ok(PunishmentPreviewProtoMapper.toProto(preview)); + } + + @GetMapping("/punishments/{punishmentId}") + public ResponseEntity getPunishmentById( + @PathVariable String punishmentId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelPlayerProtoMapper.toPunishment( + punishmentQueryService.getPunishmentById(server, punishmentId))); + } + + @GetMapping("/punishments/search") + public ResponseEntity> searchPunishments( + @RequestParam @Size(min = 2) String q, + @RequestParam(defaultValue = "false") boolean activeOnly, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(punishmentQueryService.searchPunishments(server, q, activeOnly)); + } + + @PostMapping("/{uuid}/punishments/{punishmentId}/notes") + public ResponseEntity addPunishmentNote( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @PathVariable String punishmentId, + @RequestBody PanelAddPunishmentNoteRequest noteRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + punishmentEvidenceService.addPunishmentNote( + server, + UUID.fromString(uuid), + punishmentId, + noteRequest.getText(), + emptyToNull(noteRequest.getIssuerName()), + emptyToNull(noteRequest.getIssuerId()) + ); + return ResponseEntity.ok(SUCCESS); + } + + @PostMapping("/{uuid}/punishments/{punishmentId}/evidence") + public ResponseEntity addEvidence( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @PathVariable String punishmentId, + @RequestBody PanelAddEvidenceRequest evidenceRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + punishmentEvidenceService.addEvidence( + server, + UUID.fromString(uuid), + punishmentId, + PanelPlayerProtoMapper.fromAddEvidence(evidenceRequest) + ); + return ResponseEntity.ok(SUCCESS); + } + + @PostMapping("/{uuid}/punishments/{punishmentId}/tickets") + public ResponseEntity modifyPunishmentTickets( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @PathVariable String punishmentId, + @RequestBody ModifyPunishmentTicketsRequest ticketRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + punishmentMutationService.modifyPunishmentTickets( + server, + UUID.fromString(uuid), + punishmentId, + PanelPlayerProtoMapper.fromModifyTickets(ticketRequest) + ); + return ResponseEntity.ok(SUCCESS); + } + + @GetMapping("/{uuid}/linked") + public ResponseEntity getLinkedAccounts( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List linkedAccounts = accountLinkingService.getLinkedAccounts( + server, + UUID.fromString(uuid) + ); + + return ResponseEntity.ok(PanelPlayerProtoMapper.toLinkedAccounts(linkedAccounts)); + } + + @GetMapping("/{uuid}/replays") + public ResponseEntity getPlayerReplays( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerReplays( + playerReplayListingService.listPlayerReplays(server, uuid))); + } + + @GetMapping("/punishments/{punishmentId}/linked-bans") + public ResponseEntity getLinkedBans( + @PathVariable String punishmentId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List linkedBans = punishmentQueryService.getLinkedBansForParent(server, punishmentId); + return ResponseEntity.ok(PanelPlayerProtoMapper.toLinkedBans(linkedBans)); + } + + @PostMapping("/{uuid}/find-linked") + public ResponseEntity findAndLinkAccounts( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + AccountLinkingService.LinkingResult result = accountLinkingService.findAndLinkAccounts( + server, + UUID.fromString(uuid) + ); + + return ResponseEntity.ok(PanelPlayerProtoMapper.toFindAndLinkResult( + result.success(), + result.message(), + result.linkedAccountsFound() + )); + } +} diff --git a/src/main/java/gg/modl/backend/player/controller/PanelPlayerProtoMapper.java b/src/main/java/gg/modl/backend/player/controller/PanelPlayerProtoMapper.java index 1a97fcf..51be25e 100644 --- a/src/main/java/gg/modl/backend/player/controller/PanelPlayerProtoMapper.java +++ b/src/main/java/gg/modl/backend/player/controller/PanelPlayerProtoMapper.java @@ -1,5 +1,8 @@ package gg.modl.backend.player.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.emptyToNull; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.nullToEmpty; + import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; import gg.modl.backend.player.data.IPEntry; import gg.modl.backend.player.data.NoteEntry; @@ -17,6 +20,7 @@ import gg.modl.backend.player.dto.request.CreatePunishmentRequest; import gg.modl.backend.player.dto.request.ModifyPunishmentTicketsRequest; import gg.modl.backend.player.dto.response.PunishmentResponse; +import gg.modl.backend.player.dto.response.LinkedBanView; import gg.modl.backend.replay.dto.PlayerReplayResponse; import gg.modl.proto.modl.v1.ActivePunishmentsResponse; import gg.modl.proto.modl.v1.BackendNoteEntry; @@ -32,7 +36,6 @@ import java.time.Instant; import java.util.Date; import java.util.List; -import java.util.Map; import java.util.Objects; final class PanelPlayerProtoMapper { @@ -149,14 +152,14 @@ static PanelLinkedAccountsResponse toLinkedAccounts(List return builder.build(); } - static PanelLinkedBansResponse toLinkedBans(List> linkedBans) { + static PanelLinkedBansResponse toLinkedBans(List linkedBans) { PanelLinkedBansResponse.Builder builder = PanelLinkedBansResponse.newBuilder(); - for (Map entry : linkedBans) { + for (LinkedBanView entry : linkedBans) { builder.addLinkedBans(PanelLinkedBanEntry.newBuilder() - .setPunishmentId(string(entry.get("punishmentId"))) - .setPlayerUuid(string(entry.get("playerUuid"))) - .setPlayerName(string(entry.get("playerName"))) - .setActive(Boolean.TRUE.equals(entry.get("active"))) + .setPunishmentId(string(entry.punishmentId())) + .setPlayerUuid(string(entry.playerUuid())) + .setPlayerName(string(entry.playerName())) + .setActive(entry.active()) .build()); } return builder.build(); @@ -394,12 +397,4 @@ private static String epochString(Date date) { private static String string(Object value) { return value == null ? "" : Objects.toString(value); } - - private static String nullToEmpty(String value) { - return value == null ? "" : value; - } - - static String emptyToNull(String value) { - return value == null || value.isEmpty() ? null : value; - } } diff --git a/src/main/java/gg/modl/backend/player/controller/PublicPlayerController.java b/src/main/java/gg/modl/backend/player/controller/PublicPlayerController.java index 8f5bcf8..fffcd3b 100644 --- a/src/main/java/gg/modl/backend/player/controller/PublicPlayerController.java +++ b/src/main/java/gg/modl/backend/player/controller/PublicPlayerController.java @@ -2,7 +2,7 @@ import gg.modl.backend.player.external.CrafatarProxyService; import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import java.util.UUID; +import gg.modl.backend.infrastructure.util.UuidUtils; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -26,7 +26,7 @@ public ResponseEntity proxyAvatar( @RequestParam(defaultValue = "32") int size, @RequestParam(defaultValue = "true") boolean overlay ) { - String normalized = normalizeUuid(uuid); + String normalized = UuidUtils.dashed(uuid); if (normalized == null) { return ResponseEntity.badRequest().build(); } @@ -40,19 +40,4 @@ public ResponseEntity proxyAvatar( .contentType(MediaType.IMAGE_PNG) .body(avatar); } - - private static String normalizeUuid(String raw) { - if (raw == null) { - return null; - } - String s = raw.trim(); - if (s.matches("^[0-9a-fA-F]{32}$")) { - s = s.replaceFirst("(.{8})(.{4})(.{4})(.{4})(.{12})", "$1-$2-$3-$4-$5"); - } - try { - return UUID.fromString(s).toString(); - } catch (IllegalArgumentException e) { - return null; - } - } } diff --git a/src/main/java/gg/modl/backend/player/controller/PunishmentPreviewProtoMapper.java b/src/main/java/gg/modl/backend/player/controller/PunishmentPreviewProtoMapper.java index 45eb500..0f2d729 100644 --- a/src/main/java/gg/modl/backend/player/controller/PunishmentPreviewProtoMapper.java +++ b/src/main/java/gg/modl/backend/player/controller/PunishmentPreviewProtoMapper.java @@ -1,9 +1,10 @@ package gg.modl.backend.player.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalString; + import gg.modl.backend.player.dto.response.PunishmentPreviewView; import gg.modl.backend.player.dto.response.PunishmentSeverityPreviewView; import gg.modl.proto.modl.v1.PunishmentPreviewResponse; -import java.util.function.Consumer; public final class PunishmentPreviewProtoMapper { private PunishmentPreviewProtoMapper() { @@ -21,11 +22,11 @@ public static PunishmentPreviewResponse toProto(PunishmentPreviewView preview) { .setSocialPoints(preview.getSocialPoints()) .setGameplayPoints(preview.getGameplayPoints()); - setIfNotNull(builder::setMessage, preview.getMessage()); - setIfNotNull(builder::setSocialStatus, preview.getSocialStatus()); - setIfNotNull(builder::setGameplayStatus, preview.getGameplayStatus()); - setIfNotNull(builder::setOffenderStatus, preview.getOffenderStatus()); - setIfNotNull(builder::setCategory, preview.getCategory()); + setOptionalString(builder::setMessage, preview.getMessage()); + setOptionalString(builder::setSocialStatus, preview.getSocialStatus()); + setOptionalString(builder::setGameplayStatus, preview.getGameplayStatus()); + setOptionalString(builder::setOffenderStatus, preview.getOffenderStatus()); + setOptionalString(builder::setCategory, preview.getCategory()); if (preview.getLenient() != null) { builder.setLenient(toProto(preview.getLenient())); @@ -52,18 +53,12 @@ private static PunishmentPreviewResponse.SeverityPreview toProto(PunishmentSever .setNewSocialPoints(preview.getNewSocialPoints()) .setNewGameplayPoints(preview.getNewGameplayPoints()); - setIfNotNull(builder::setSeverity, preview.getSeverity()); - setIfNotNull(builder::setDurationFormatted, preview.getDurationFormatted()); - setIfNotNull(builder::setPunishmentType, preview.getPunishmentType()); - setIfNotNull(builder::setNewSocialStatus, preview.getNewSocialStatus()); - setIfNotNull(builder::setNewGameplayStatus, preview.getNewGameplayStatus()); + setOptionalString(builder::setSeverity, preview.getSeverity()); + setOptionalString(builder::setDurationFormatted, preview.getDurationFormatted()); + setOptionalString(builder::setPunishmentType, preview.getPunishmentType()); + setOptionalString(builder::setNewSocialStatus, preview.getNewSocialStatus()); + setOptionalString(builder::setNewGameplayStatus, preview.getNewGameplayStatus()); return builder.build(); } - - private static void setIfNotNull(Consumer setter, String value) { - if (value != null) { - setter.accept(value); - } - } } diff --git a/src/main/java/gg/modl/backend/player/controller/SyncRequestMapper.java b/src/main/java/gg/modl/backend/player/controller/SyncRequestMapper.java new file mode 100644 index 0000000..6c67c82 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/controller/SyncRequestMapper.java @@ -0,0 +1,53 @@ +package gg.modl.backend.player.controller; + +import gg.modl.backend.player.controller.MinecraftSyncController.ChatLogEntry; +import gg.modl.backend.player.controller.MinecraftSyncController.CommandLogEntry; +import gg.modl.backend.player.controller.MinecraftSyncController.OnlinePlayer; +import gg.modl.backend.player.controller.MinecraftSyncController.ServerStatus; +import gg.modl.backend.player.service.MinecraftSyncService; +import java.util.List; + +final class SyncRequestMapper { + private SyncRequestMapper() { + } + + static List toOnlinePlayers(List players) { + if (players == null) { + return List.of(); + } + return players.stream() + .map(player -> new MinecraftSyncService.OnlinePlayerInput(player.uuid(), player.username(), player.ipAddress())) + .toList(); + } + + static List toChatLogs(List entries) { + if (entries == null) { + return List.of(); + } + return entries.stream() + .map(entry -> new MinecraftSyncService.ChatLogInput(entry.uuid(), entry.username(), entry.message(), entry.timestamp(), entry.server())) + .toList(); + } + + static List toCommandLogs(List entries) { + if (entries == null) { + return List.of(); + } + return entries.stream() + .map(entry -> new MinecraftSyncService.CommandLogInput(entry.uuid(), entry.username(), entry.command(), entry.timestamp(), entry.server())) + .toList(); + } + + static MinecraftSyncService.ServerStatusInput toServerStatus(ServerStatus serverStatus) { + if (serverStatus == null) { + return null; + } + return new MinecraftSyncService.ServerStatusInput( + serverStatus.onlinePlayerCount(), + serverStatus.maxPlayers(), + serverStatus.serverVersion(), + serverStatus.platformType(), + serverStatus.pluginVersion() + ); + } +} diff --git a/src/main/java/gg/modl/backend/player/data/IPEntry.java b/src/main/java/gg/modl/backend/player/data/IPEntry.java index af7baf5..8dc674e 100644 --- a/src/main/java/gg/modl/backend/player/data/IPEntry.java +++ b/src/main/java/gg/modl/backend/player/data/IPEntry.java @@ -1,39 +1,39 @@ -package gg.modl.backend.player.data; - -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class IPEntry { - @Nullable - private String ipAddress; - - @Nullable - private String country; - - @Nullable - private String region; - - @Nullable - private String asn; - - private boolean proxy; - - private boolean hosting; - - @NotNull - private Date firstLogin; - - @Builder.Default - private List logins = new ArrayList<>(); -} +package gg.modl.backend.player.data; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class IPEntry { + @Nullable + private String ipAddress; + + @Nullable + private String country; + + @Nullable + private String region; + + @Nullable + private String asn; + + private boolean proxy; + + private boolean hosting; + + @NotNull + private Date firstLogin; + + @Builder.Default + private List logins = new ArrayList<>(); +} diff --git a/src/main/java/gg/modl/backend/player/data/NoteEntry.java b/src/main/java/gg/modl/backend/player/data/NoteEntry.java index 3f8e789..72aa6a8 100644 --- a/src/main/java/gg/modl/backend/player/data/NoteEntry.java +++ b/src/main/java/gg/modl/backend/player/data/NoteEntry.java @@ -1,24 +1,24 @@ -package gg.modl.backend.player.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.mongodb.core.mapping.Field; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class NoteEntry { - @Field("id") - private String id; - - private String text; - private Date date; - private String issuerName; - private String issuerId; -} +package gg.modl.backend.player.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Field; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class NoteEntry { + @Field("id") + private String id; + + private String text; + private Date date; + private String issuerName; + private String issuerId; +} diff --git a/src/main/java/gg/modl/backend/player/data/Player.java b/src/main/java/gg/modl/backend/player/data/Player.java index 15d798a..abebaab 100644 --- a/src/main/java/gg/modl/backend/player/data/Player.java +++ b/src/main/java/gg/modl/backend/player/data/Player.java @@ -10,10 +10,13 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; @@ -88,5 +91,15 @@ public class Player { @Field(name = "data") @Builder.Default + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) private Map data = new HashMap<>(); + + public PlayerDataView data() { + return PlayerDataView.ownedBy(data, map -> data = map); + } + + public void replaceData(Map replacement) { + this.data = replacement; + } } diff --git a/src/main/java/gg/modl/backend/player/data/PlayerDataKeys.java b/src/main/java/gg/modl/backend/player/data/PlayerDataKeys.java new file mode 100644 index 0000000..1b678e0 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/data/PlayerDataKeys.java @@ -0,0 +1,21 @@ +package gg.modl.backend.player.data; + +public final class PlayerDataKeys { + public static final String IS_ONLINE = "isOnline"; + public static final String LAST_LOGIN = "lastLogin"; + public static final String LAST_SERVER = "lastServer"; + public static final String TOTAL_PLAYTIME_SECONDS = "totalPlaytimeSeconds"; + public static final String FIRST_JOIN = "firstJoin"; + public static final String LAST_SKIN_HASH = "lastSkinHash"; + public static final String PENDING_NOTIFICATIONS = "pendingNotifications"; + public static final String LINKED_ACCOUNTS = "linkedAccounts"; + public static final String LAST_LINKED_UPDATE = "lastLinkedUpdate"; + public static final String COUNTRY = "country"; + public static final String REGION = "region"; + public static final String ASN = "asn"; + public static final String PROXY = "proxy"; + public static final String HOSTING = "hosting"; + + private PlayerDataKeys() { + } +} diff --git a/src/main/java/gg/modl/backend/player/data/PlayerDataView.java b/src/main/java/gg/modl/backend/player/data/PlayerDataView.java new file mode 100644 index 0000000..69dd55c --- /dev/null +++ b/src/main/java/gg/modl/backend/player/data/PlayerDataView.java @@ -0,0 +1,134 @@ +package gg.modl.backend.player.data; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import org.jetbrains.annotations.Nullable; + +public final class PlayerDataView { + @Nullable + private Map data; + @Nullable + private final Consumer> installer; + + private PlayerDataView(@Nullable Map data, @Nullable Consumer> installer) { + this.data = data; + this.installer = installer; + } + + static PlayerDataView ownedBy(@Nullable Map data, Consumer> installer) { + return new PlayerDataView(data, installer); + } + + @Nullable + public Map asMap() { + return data; + } + + private Map mutable() { + if (data == null) { + data = new HashMap<>(); + if (installer != null) { + installer.accept(data); + } + } + return data; + } + + public boolean isOnline() { + return data != null && Boolean.TRUE.equals(data.get(PlayerDataKeys.IS_ONLINE)); + } + + public void setOnline(boolean online) { + mutable().put(PlayerDataKeys.IS_ONLINE, online); + } + + @Nullable + public Date lastLogin() { + return data != null && data.get(PlayerDataKeys.LAST_LOGIN) instanceof Date value ? value : null; + } + + public void setLastLogin(Date lastLogin) { + mutable().put(PlayerDataKeys.LAST_LOGIN, lastLogin); + } + + @Nullable + public String lastServer() { + return data != null && data.get(PlayerDataKeys.LAST_SERVER) instanceof String value ? value : null; + } + + public void setLastServer(String lastServer) { + mutable().put(PlayerDataKeys.LAST_SERVER, lastServer); + } + + @Nullable + public Number totalPlaytimeSeconds() { + return data != null && data.get(PlayerDataKeys.TOTAL_PLAYTIME_SECONDS) instanceof Number value ? value : null; + } + + @Nullable + public String lastSkinHash() { + return data != null && data.get(PlayerDataKeys.LAST_SKIN_HASH) instanceof String value ? value : null; + } + + public void setLastSkinHash(String lastSkinHash) { + mutable().put(PlayerDataKeys.LAST_SKIN_HASH, lastSkinHash); + } + + @Nullable + public Date firstJoin() { + return data != null && data.get(PlayerDataKeys.FIRST_JOIN) instanceof Date value ? value : null; + } + + public boolean hasFirstJoin() { + return data != null && data.containsKey(PlayerDataKeys.FIRST_JOIN); + } + + public void setFirstJoin(Date firstJoin) { + mutable().put(PlayerDataKeys.FIRST_JOIN, firstJoin); + } + + @Nullable + public Date lastLinkedUpdate() { + return data != null && data.get(PlayerDataKeys.LAST_LINKED_UPDATE) instanceof Date value ? value : null; + } + + public boolean hasLinkedAccounts() { + return data != null && data.containsKey(PlayerDataKeys.LINKED_ACCOUNTS); + } + + @Nullable + public Object linkedAccountsValue() { + return data != null ? data.get(PlayerDataKeys.LINKED_ACCOUNTS) : null; + } + + public List linkedAccountUuids() { + if (data == null || !(data.get(PlayerDataKeys.LINKED_ACCOUNTS) instanceof List list)) { + return List.of(); + } + return list.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + } + + public List> pendingNotifications() { + if (data == null || !(data.get(PlayerDataKeys.PENDING_NOTIFICATIONS) instanceof List list)) { + return List.of(); + } + return list.stream() + .filter(Map.class::isInstance) + .map(entry -> { + @SuppressWarnings("unchecked") + Map notification = (Map) entry; + return notification; + }) + .toList(); + } + + public void setPendingNotifications(List> pendingNotifications) { + mutable().put(PlayerDataKeys.PENDING_NOTIFICATIONS, pendingNotifications); + } +} diff --git a/src/main/java/gg/modl/backend/player/data/UsernameEntry.java b/src/main/java/gg/modl/backend/player/data/UsernameEntry.java index ccd200a..58fc224 100644 --- a/src/main/java/gg/modl/backend/player/data/UsernameEntry.java +++ b/src/main/java/gg/modl/backend/player/data/UsernameEntry.java @@ -1,7 +1,7 @@ -package gg.modl.backend.player.data; - - -import java.util.Date; -import org.jetbrains.annotations.NotNull; - +package gg.modl.backend.player.data; + + +import java.util.Date; +import org.jetbrains.annotations.NotNull; + public record UsernameEntry(@NotNull String username, @NotNull Date date) {} \ No newline at end of file diff --git a/src/main/java/gg/modl/backend/player/data/punishment/EnforcementCategory.java b/src/main/java/gg/modl/backend/player/data/punishment/EnforcementCategory.java index c84f313..08fa2ea 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/EnforcementCategory.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/EnforcementCategory.java @@ -1,5 +1,5 @@ package gg.modl.backend.player.data.punishment; public enum EnforcementCategory { - BAN, MUTE; + BAN, MUTE } diff --git a/src/main/java/gg/modl/backend/player/data/punishment/Punishment.java b/src/main/java/gg/modl/backend/player/data/punishment/Punishment.java index c95065b..fd4b5fa 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/Punishment.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/Punishment.java @@ -1,73 +1,103 @@ -package gg.modl.backend.player.data.punishment; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; -import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.data.mongodb.core.mapping.Field; -import org.springframework.data.mongodb.core.mapping.FieldType; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@GenerateMongoFields -@MongoFieldAliases({ - @MongoFieldAlias(name = "DATA_LINKED_BAN_ID", path = "data.linkedBanId"), - @MongoFieldAlias(name = "DATA_LINKED_BAN_PARENT_UUID", path = "data.linkedBanParentUuid"), - @MongoFieldAlias(name = "DATA_STATUS", path = "data.status"), - @MongoFieldAlias(name = "DATA_REASON", path = "data.reason"), - @MongoFieldAlias(name = "DATA_DURATION", path = "data.duration"), - @MongoFieldAlias(name = "DATA_SEVERITY", path = "data.severity"), - @MongoFieldAlias(name = "DATA_ALT_BLOCKING", path = "data.altBlocking"), - @MongoFieldAlias(name = "DATA_WIPE_AFTER_EXPIRY", path = "data.wipeAfterExpiry"), - @MongoFieldAlias(name = "DATA_STAT_WIPE_COMPLETED", path = "data.statWipeCompleted"), - @MongoFieldAlias(name = "DATA_BLOCKED_NAME", path = "data.blockedName"), - @MongoFieldAlias(name = "DATA_BLOCKED_SKIN", path = "data.blockedSkin"), - @MongoFieldAlias(name = "DATA_OFFENSE_LEVEL", path = "data.offenseLevel") -}) -public class Punishment { - public static final int LINKED_BAN_TYPE_ORDINAL = 4; - - @NotNull - @Field(value = "id", targetType = FieldType.STRING) - private String id; - - @Field("typeOrdinal") - private int typeOrdinal; - - @Nullable - private String issuerName; - - @Nullable - private String issuerId; - - @NotNull - private Date issued; - - @Nullable - private Date started; - - @NotNull - private List modifications = new ArrayList<>(); - - @NotNull - private List notes = new ArrayList<>(); - - @NotNull - private List evidence = new ArrayList<>(); - - @NotNull - private List attachedTicketIds = new ArrayList<>(); - - @Nullable - private Map data = new HashMap<>(); -} +package gg.modl.backend.player.data.punishment; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; +import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.mapping.FieldType; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@GenerateMongoFields +@MongoFieldAliases({ + @MongoFieldAlias(name = "DATA_LINKED_BAN_ID", path = "data.linkedBanId"), + @MongoFieldAlias(name = "DATA_LINKED_BAN_PARENT_UUID", path = "data.linkedBanParentUuid"), + @MongoFieldAlias(name = "DATA_STATUS", path = "data.status"), + @MongoFieldAlias(name = "DATA_REASON", path = "data.reason"), + @MongoFieldAlias(name = "DATA_DURATION", path = "data.duration"), + @MongoFieldAlias(name = "DATA_SEVERITY", path = "data.severity"), + @MongoFieldAlias(name = "DATA_ALT_BLOCKING", path = "data.altBlocking"), + @MongoFieldAlias(name = "DATA_WIPE_AFTER_EXPIRY", path = "data.wipeAfterExpiry"), + @MongoFieldAlias(name = "DATA_STAT_WIPE_COMPLETED", path = "data.statWipeCompleted"), + @MongoFieldAlias(name = "DATA_BLOCKED_NAME", path = "data.blockedName"), + @MongoFieldAlias(name = "DATA_BLOCKED_SKIN", path = "data.blockedSkin"), + @MongoFieldAlias(name = "DATA_OFFENSE_LEVEL", path = "data.offenseLevel") +}) +public class Punishment { + public static final int LINKED_BAN_TYPE_ORDINAL = 4; + + @NotNull + @Field(value = "id", targetType = FieldType.STRING) + private String id; + + @Field("typeOrdinal") + private int typeOrdinal; + + @Nullable + private String issuerName; + + @Nullable + private String issuerId; + + @NotNull + private Date issued; + + @Nullable + private Date started; + + @NotNull + private List modifications = new ArrayList<>(); + + @NotNull + private List notes = new ArrayList<>(); + + @NotNull + private List evidence = new ArrayList<>(); + + @NotNull + private List attachedTicketIds = new ArrayList<>(); + + @Nullable + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private Map data = new HashMap<>(); + + public PunishmentDataView data() { + return PunishmentDataView.ownedBy(data, map -> data = map); + } + + public void replaceData(Map replacement) { + this.data = replacement; + } + + public boolean isPardoned() { + return modifications.stream() + .anyMatch(modification -> PunishmentModificationType.isPardon(modification.type())); + } + + public boolean isUnstarted() { + if (!data().isUnstarted()) { + return false; + } + return modifications.stream() + .noneMatch(modification -> PunishmentModificationType.isPardon(modification.type())); + } + + public boolean hasUnstartedStatus() { + return data().isUnstarted(); + } +} diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentData.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentData.java index e01f605..80a004c 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentData.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentData.java @@ -1,78 +1,20 @@ package gg.modl.backend.player.data.punishment; -import java.util.Map; -import org.jetbrains.annotations.Nullable; - public final class PunishmentData { - private PunishmentData() {} - - @Nullable - public static String getStatus(@Nullable Map data) { - return data != null && data.get("status") instanceof String s ? s : null; - } - - @Nullable - public static Long getDuration(@Nullable Map data) { - if (data == null) return null; - Object v = data.get("duration"); - if (v instanceof Long l) return l; - if (v instanceof Number n) return n.longValue(); - return null; - } - - @Nullable - public static String getReason(@Nullable Map data) { - return data != null && data.get("reason") instanceof String s ? s : null; - } - - @Nullable - public static String getSeverity(@Nullable Map data) { - return data != null && data.get("severity") instanceof String s ? s : null; - } - - @Nullable - public static String getOffenseLevel(@Nullable Map data) { - return data != null && data.get("offenseLevel") instanceof String s ? s : null; - } - - @Nullable - public static String getEnforcementCategory(@Nullable Map data) { - return data != null && data.get("enforcementCategory") instanceof String s ? s : null; - } + public static final String STATUS = "status"; + public static final String DURATION = "duration"; + public static final String REASON = "reason"; + public static final String SEVERITY = "severity"; + public static final String OFFENSE_LEVEL = "offenseLevel"; + public static final String ENFORCEMENT_CATEGORY = "enforcementCategory"; + public static final String ALT_BLOCKING = "altBlocking"; + public static final String WIPE_AFTER_EXPIRY = "wipeAfterExpiry"; + public static final String STAT_WIPE_COMPLETED = "statWipeCompleted"; + public static final String PENDING_ACKNOWLEDGEMENT = "pendingAcknowledgement"; + public static final String LINKED_BAN_ID = "linkedBanId"; + public static final String LINKED_BAN_PARENT_UUID = "linkedBanParentUuid"; + public static final String BLOCKED_NAME = "blockedName"; + public static final String BLOCKED_SKIN = "blockedSkin"; - public static boolean isAltBlocking(@Nullable Map data) { - return Boolean.TRUE.equals(data != null ? data.get("altBlocking") : null); - } - - public static boolean isWipeAfterExpiry(@Nullable Map data) { - return Boolean.TRUE.equals(data != null ? data.get("wipeAfterExpiry") : null); - } - - public static boolean isStatWipeCompleted(@Nullable Map data) { - return Boolean.TRUE.equals(data != null ? data.get("statWipeCompleted") : null); - } - - public static boolean isPendingAcknowledgement(@Nullable Map data) { - return Boolean.TRUE.equals(data != null ? data.get("pendingAcknowledgement") : null); - } - - @Nullable - public static String getLinkedBanId(@Nullable Map data) { - return data != null && data.get("linkedBanId") instanceof String s ? s : null; - } - - @Nullable - public static String getLinkedBanParentUuid(@Nullable Map data) { - return data != null && data.get("linkedBanParentUuid") instanceof String s ? s : null; - } - - @Nullable - public static String getBlockedName(@Nullable Map data) { - return data != null && data.get("blockedName") instanceof String s ? s : null; - } - - @Nullable - public static String getBlockedSkin(@Nullable Map data) { - return data != null && data.get("blockedSkin") instanceof String s ? s : null; - } + private PunishmentData() {} } diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentDataView.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentDataView.java new file mode 100644 index 0000000..25cf821 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentDataView.java @@ -0,0 +1,190 @@ +package gg.modl.backend.player.data.punishment; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import org.jetbrains.annotations.Nullable; + +public final class PunishmentDataView { + @Nullable + private Map data; + @Nullable + private final Consumer> installer; + + private PunishmentDataView(@Nullable Map data, @Nullable Consumer> installer) { + this.data = data; + this.installer = installer; + } + + static PunishmentDataView ownedBy(@Nullable Map data, Consumer> installer) { + return new PunishmentDataView(data, installer); + } + + public static PunishmentDataView ofMap(Map data) { + return new PunishmentDataView(data, null); + } + + @Nullable + public Map asMap() { + return data; + } + + private Map mutable() { + if (data == null) { + data = new HashMap<>(); + if (installer != null) { + installer.accept(data); + } + } + return data; + } + + @Nullable + public String status() { + return data != null && data.get(PunishmentData.STATUS) instanceof String value ? value : null; + } + + public boolean hasStatus() { + return data != null && data.containsKey(PunishmentData.STATUS); + } + + public boolean isUnstarted() { + return PunishmentStatus.UNSTARTED.equals(status()); + } + + public void setStatus(String status) { + mutable().put(PunishmentData.STATUS, status); + } + + public void removeStatus() { + if (data != null) { + data.remove(PunishmentData.STATUS); + } + } + + @Nullable + public Long duration() { + if (data == null) { + return null; + } + Object value = data.get(PunishmentData.DURATION); + if (value instanceof Long longValue) { + return longValue; + } + if (value instanceof Number number) { + return number.longValue(); + } + return null; + } + + public void setDuration(long duration) { + mutable().put(PunishmentData.DURATION, duration); + } + + @Nullable + public String reason() { + return data != null && data.get(PunishmentData.REASON) instanceof String value ? value : null; + } + + public void setReason(String reason) { + mutable().put(PunishmentData.REASON, reason); + } + + @Nullable + public String severity() { + return data != null && data.get(PunishmentData.SEVERITY) instanceof String value ? value : null; + } + + public void setSeverity(String severity) { + mutable().put(PunishmentData.SEVERITY, severity); + } + + @Nullable + public String offenseLevel() { + return data != null && data.get(PunishmentData.OFFENSE_LEVEL) instanceof String value ? value : null; + } + + public boolean hasOffenseLevel() { + return data != null && data.containsKey(PunishmentData.OFFENSE_LEVEL); + } + + public void setOffenseLevel(String offenseLevel) { + mutable().put(PunishmentData.OFFENSE_LEVEL, offenseLevel); + } + + @Nullable + public String enforcementCategory() { + return data != null && data.get(PunishmentData.ENFORCEMENT_CATEGORY) instanceof String value ? value : null; + } + + public void setEnforcementCategory(String enforcementCategory) { + mutable().put(PunishmentData.ENFORCEMENT_CATEGORY, enforcementCategory); + } + + public boolean altBlocking() { + return data != null && Boolean.TRUE.equals(data.get(PunishmentData.ALT_BLOCKING)); + } + + public void setAltBlocking(boolean altBlocking) { + mutable().put(PunishmentData.ALT_BLOCKING, altBlocking); + } + + public boolean wipeAfterExpiry() { + return data != null && Boolean.TRUE.equals(data.get(PunishmentData.WIPE_AFTER_EXPIRY)); + } + + public void setWipeAfterExpiry(boolean wipeAfterExpiry) { + mutable().put(PunishmentData.WIPE_AFTER_EXPIRY, wipeAfterExpiry); + } + + public boolean statWipeCompleted() { + return data != null && Boolean.TRUE.equals(data.get(PunishmentData.STAT_WIPE_COMPLETED)); + } + + public void setPendingAcknowledgement(boolean pendingAcknowledgement) { + mutable().put(PunishmentData.PENDING_ACKNOWLEDGEMENT, pendingAcknowledgement); + } + + public boolean removePendingAcknowledgement() { + return data != null && Boolean.TRUE.equals(data.remove(PunishmentData.PENDING_ACKNOWLEDGEMENT)); + } + + @Nullable + public String linkedBanId() { + return data != null && data.get(PunishmentData.LINKED_BAN_ID) instanceof String value ? value : null; + } + + public void setLinkedBanId(String linkedBanId) { + mutable().put(PunishmentData.LINKED_BAN_ID, linkedBanId); + } + + public void setLinkedBanParentUuid(String linkedBanParentUuid) { + mutable().put(PunishmentData.LINKED_BAN_PARENT_UUID, linkedBanParentUuid); + } + + @Nullable + public String blockedName() { + return data != null && data.get(PunishmentData.BLOCKED_NAME) instanceof String value ? value : null; + } + + public boolean hasBlockedName() { + return data != null && data.containsKey(PunishmentData.BLOCKED_NAME); + } + + public void setBlockedName(String blockedName) { + mutable().put(PunishmentData.BLOCKED_NAME, blockedName); + } + + @Nullable + public String blockedSkin() { + return data != null && data.get(PunishmentData.BLOCKED_SKIN) instanceof String value ? value : null; + } + + public boolean hasBlockedSkin() { + return data != null && data.containsKey(PunishmentData.BLOCKED_SKIN); + } + + public void setBlockedSkin(String blockedSkin) { + mutable().put(PunishmentData.BLOCKED_SKIN, blockedSkin); + } +} diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentEvidence.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentEvidence.java index 0a1109c..2a0807b 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentEvidence.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentEvidence.java @@ -1,18 +1,18 @@ -package gg.modl.backend.player.data.punishment; - -import java.util.Date; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -public record PunishmentEvidence( - @Nullable String text, - @Nullable String url, - @NotNull String type, - @Nullable String uploadedBy, - @Nullable String uploadedById, - @NotNull Date uploadedAt, - @Nullable String fileName, - @Nullable String fileType, - @Nullable Long fileSize -) { -} +package gg.modl.backend.player.data.punishment; + +import java.util.Date; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public record PunishmentEvidence( + @Nullable String text, + @Nullable String url, + @NotNull String type, + @Nullable String uploadedBy, + @Nullable String uploadedById, + @NotNull Date uploadedAt, + @Nullable String fileName, + @Nullable String fileType, + @Nullable Long fileSize +) { +} diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModification.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModification.java index 813a4bc..5d058dc 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModification.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModification.java @@ -1,20 +1,20 @@ -package gg.modl.backend.player.data.punishment; - -import java.util.Date; -import java.util.Map; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.data.mongodb.core.mapping.Field; - -public record PunishmentModification( - @NotNull @Field("id") String id, - @NotNull String type, - @NotNull Date date, - @Nullable String issuerName, - @Nullable String issuerId, - @NotNull String reason, - @Nullable Long effectiveDuration, - @Nullable String appealTicketId, - @Nullable Map data -) { -} +package gg.modl.backend.player.data.punishment; + +import java.util.Date; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.data.mongodb.core.mapping.Field; + +public record PunishmentModification( + @NotNull @Field("id") String id, + @NotNull String type, + @NotNull Date date, + @Nullable String issuerName, + @Nullable String issuerId, + @NotNull String reason, + @Nullable Long effectiveDuration, + @Nullable String appealTicketId, + @Nullable Map data +) { +} diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModificationType.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModificationType.java index 24247f5..020645b 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModificationType.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModificationType.java @@ -17,15 +17,18 @@ public boolean isPardon() { return this == MANUAL_PARDON || this == APPEAL_ACCEPT || this == SYSTEM_PARDON; } + public boolean isDurationChange() { + return this == MANUAL_DURATION_CHANGE || this == APPEAL_DURATION_CHANGE; + } + public static boolean isPardon(String type) { - return MANUAL_PARDON.name().equals(type) - || APPEAL_ACCEPT.name().equals(type) - || SYSTEM_PARDON.name().equals(type); + PunishmentModificationType value = fromName(type); + return value != null && value.isPardon(); } public static boolean isDurationChange(String type) { - return MANUAL_DURATION_CHANGE.name().equals(type) - || APPEAL_DURATION_CHANGE.name().equals(type); + PunishmentModificationType value = fromName(type); + return value != null && value.isDurationChange(); } public AppealWorkflowStatus appealOutcome() { diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentNote.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentNote.java index 93de723..2cf083e 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentNote.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentNote.java @@ -1,15 +1,15 @@ -package gg.modl.backend.player.data.punishment; - -import java.util.Date; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.data.mongodb.core.mapping.Field; - -public record PunishmentNote( - @NotNull @Field("id") String id, - @NotNull String text, - @NotNull Date date, - @Nullable String issuerName, - @Nullable String issuerId -) { -} +package gg.modl.backend.player.data.punishment; + +import java.util.Date; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.data.mongodb.core.mapping.Field; + +public record PunishmentNote( + @NotNull @Field("id") String id, + @NotNull String text, + @NotNull Date date, + @Nullable String issuerName, + @Nullable String issuerId +) { +} diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentToggleOption.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentToggleOption.java new file mode 100644 index 0000000..7d082e8 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentToggleOption.java @@ -0,0 +1,44 @@ +package gg.modl.backend.player.data.punishment; + +import java.util.Locale; +import java.util.function.BiConsumer; +import org.jetbrains.annotations.Nullable; + +public enum PunishmentToggleOption { + ALT_BLOCKING(PunishmentData.ALT_BLOCKING, "alt-blocking", PunishmentDataView::setAltBlocking), + STAT_WIPE(PunishmentData.WIPE_AFTER_EXPIRY, "stat wipe", PunishmentDataView::setWipeAfterExpiry); + + private final String dataKey; + private final String displayName; + private final BiConsumer mutator; + + PunishmentToggleOption(String dataKey, String displayName, BiConsumer mutator) { + this.dataKey = dataKey; + this.displayName = displayName; + this.mutator = mutator; + } + + public String dataKey() { + return dataKey; + } + + public String displayName() { + return displayName; + } + + public void apply(PunishmentDataView data, boolean enabled) { + mutator.accept(data, enabled); + } + + @Nullable + public static PunishmentToggleOption from(String option) { + if (option == null || option.isBlank()) { + return null; + } + try { + return PunishmentToggleOption.valueOf(option.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + return null; + } + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/request/AddIpRequest.java b/src/main/java/gg/modl/backend/player/dto/request/AddIpRequest.java deleted file mode 100644 index a6988a3..0000000 --- a/src/main/java/gg/modl/backend/player/dto/request/AddIpRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package gg.modl.backend.player.dto.request; - -import gg.modl.backend.infrastructure.validation.ValidIpAddress; -import jakarta.validation.constraints.NotBlank; - -public record AddIpRequest( - @NotBlank @ValidIpAddress String ipAddress -) { -} diff --git a/src/main/java/gg/modl/backend/player/dto/request/CreatePlayerRequest.java b/src/main/java/gg/modl/backend/player/dto/request/CreatePlayerRequest.java deleted file mode 100644 index 53a504c..0000000 --- a/src/main/java/gg/modl/backend/player/dto/request/CreatePlayerRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -package gg.modl.backend.player.dto.request; - -import gg.modl.backend.infrastructure.validation.RegExpConstants; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; - -public record CreatePlayerRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, - @NotBlank @Pattern(regexp = RegExpConstants.MINECRAFT_USERNAME) String username -) { -} diff --git a/src/main/java/gg/modl/backend/player/dto/request/CreateUploadTokenRequest.java b/src/main/java/gg/modl/backend/player/dto/request/CreateUploadTokenRequest.java index 32132f2..4d403c0 100644 --- a/src/main/java/gg/modl/backend/player/dto/request/CreateUploadTokenRequest.java +++ b/src/main/java/gg/modl/backend/player/dto/request/CreateUploadTokenRequest.java @@ -2,7 +2,7 @@ import gg.modl.backend.infrastructure.validation.RequestValidationLimits; import jakarta.validation.constraints.Size; -import org.springframework.lang.Nullable; +import org.jetbrains.annotations.Nullable; public record CreateUploadTokenRequest( @Nullable @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, diff --git a/src/main/java/gg/modl/backend/player/dto/request/MinecraftCreatePunishmentRequest.java b/src/main/java/gg/modl/backend/player/dto/request/MinecraftCreatePunishmentRequest.java new file mode 100644 index 0000000..07ed7dc --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/request/MinecraftCreatePunishmentRequest.java @@ -0,0 +1,26 @@ +package gg.modl.backend.player.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import gg.modl.backend.infrastructure.validation.RegExpConstants; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.Map; + +public record MinecraftCreatePunishmentRequest( + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String targetUuid, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @JsonProperty("type_ordinal") @Min(0) int typeOrdinal, + @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_REASON_MAX_LENGTH) String reason, + @Min(0) Long duration, + @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_DATA_MAX_ENTRIES) Map data, + @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_NOTES_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.PLAYER_NOTE_TEXT_MAX_LENGTH) String> notes, + @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_TICKETS_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.ID_MAX_LENGTH) String> attachedTicketIds, + @Size(max = RequestValidationLimits.PLAYER_SEVERITY_MAX_LENGTH) String severity, + @Size(max = RequestValidationLimits.PLAYER_STATUS_MAX_LENGTH) String status +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/request/StartupRequest.java b/src/main/java/gg/modl/backend/player/dto/request/StartupRequest.java new file mode 100644 index 0000000..a1acc75 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/request/StartupRequest.java @@ -0,0 +1,10 @@ +package gg.modl.backend.player.dto.request; + +public record StartupRequest( + String serverVersion, + String platformType, + String pluginVersion, + int maxPlayers, + String serverName +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/AcknowledgeResult.java b/src/main/java/gg/modl/backend/player/dto/response/AcknowledgeResult.java new file mode 100644 index 0000000..15ea7e4 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/AcknowledgeResult.java @@ -0,0 +1,4 @@ +package gg.modl.backend.player.dto.response; + +public record AcknowledgeResult(String message) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/AppealEligibility.java b/src/main/java/gg/modl/backend/player/dto/response/AppealEligibility.java new file mode 100644 index 0000000..d1a56fe --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/AppealEligibility.java @@ -0,0 +1,10 @@ +package gg.modl.backend.player.dto.response; + +public sealed interface AppealEligibility permits AppealEligibility.Eligible, AppealEligibility.NotStarted { + + record Eligible(AppealInfoView info) implements AppealEligibility { + } + + record NotStarted(String message) implements AppealEligibility { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/AppealInfoView.java b/src/main/java/gg/modl/backend/player/dto/response/AppealInfoView.java new file mode 100644 index 0000000..c40d35b --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/AppealInfoView.java @@ -0,0 +1,18 @@ +package gg.modl.backend.player.dto.response; + +import java.util.Date; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +public record AppealInfoView( + String id, + String type, + @Nullable Date issued, + @Nullable Date expires, + boolean active, + boolean appealable, + String playerUuid, + @Nullable Map existingAppeal, + @Nullable Map appealForm +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/CreateNoteResult.java b/src/main/java/gg/modl/backend/player/dto/response/CreateNoteResult.java new file mode 100644 index 0000000..975f54a --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/CreateNoteResult.java @@ -0,0 +1,10 @@ +package gg.modl.backend.player.dto.response; + +public sealed interface CreateNoteResult permits CreateNoteResult.Created, CreateNoteResult.NotFound { + + record Created(String message) implements CreateNoteResult { + } + + record NotFound(String message) implements CreateNoteResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountResponse.java b/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountResponse.java index 75ea83b..c7dcd5f 100644 --- a/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountResponse.java +++ b/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountResponse.java @@ -1,13 +1,13 @@ -package gg.modl.backend.player.dto.response; - -import java.util.Date; -import org.jetbrains.annotations.Nullable; - -public record LinkedAccountResponse( - String minecraftUuid, - String username, - int activeBans, - int activeMutes, - @Nullable Date lastLinkedUpdate -) { -} +package gg.modl.backend.player.dto.response; + +import java.util.Date; +import org.jetbrains.annotations.Nullable; + +public record LinkedAccountResponse( + String minecraftUuid, + String username, + int activeBans, + int activeMutes, + @Nullable Date lastLinkedUpdate +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountsResult.java b/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountsResult.java new file mode 100644 index 0000000..b9b23c6 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountsResult.java @@ -0,0 +1,18 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import java.util.Map; + +public sealed interface LinkedAccountsResult permits LinkedAccountsResult.Found, LinkedAccountsResult.NotFound { + + record Found( + List> linkedAccounts, + Integer totalCount, + Integer page, + Boolean hasMore + ) implements LinkedAccountsResult { + } + + record NotFound(String message) implements LinkedAccountsResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/LinkedBanView.java b/src/main/java/gg/modl/backend/player/dto/response/LinkedBanView.java new file mode 100644 index 0000000..fbd9892 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/LinkedBanView.java @@ -0,0 +1,9 @@ +package gg.modl.backend.player.dto.response; + +public record LinkedBanView( + String punishmentId, + String playerUuid, + String playerName, + boolean active +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/OnlinePlayersResult.java b/src/main/java/gg/modl/backend/player/dto/response/OnlinePlayersResult.java new file mode 100644 index 0000000..a83e034 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/OnlinePlayersResult.java @@ -0,0 +1,7 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import java.util.Map; + +public record OnlinePlayersResult(List> players) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PaginatedNotesResult.java b/src/main/java/gg/modl/backend/player/dto/response/PaginatedNotesResult.java new file mode 100644 index 0000000..0329b7a --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PaginatedNotesResult.java @@ -0,0 +1,18 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import java.util.Map; + +public sealed interface PaginatedNotesResult permits PaginatedNotesResult.Found, PaginatedNotesResult.NotFound { + + record Found( + List> notes, + int totalCount, + int page, + boolean hasMore + ) implements PaginatedNotesResult { + } + + record NotFound(String message) implements PaginatedNotesResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PaginatedPunishmentsResult.java b/src/main/java/gg/modl/backend/player/dto/response/PaginatedPunishmentsResult.java new file mode 100644 index 0000000..6255cdf --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PaginatedPunishmentsResult.java @@ -0,0 +1,18 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; + +public sealed interface PaginatedPunishmentsResult + permits PaginatedPunishmentsResult.Found, PaginatedPunishmentsResult.NotFound { + + record Found( + List punishments, + int totalCount, + int page, + boolean hasMore + ) implements PaginatedPunishmentsResult { + } + + record NotFound(String message) implements PaginatedPunishmentsResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PardonResult.java b/src/main/java/gg/modl/backend/player/dto/response/PardonResult.java new file mode 100644 index 0000000..6b736c7 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PardonResult.java @@ -0,0 +1,10 @@ +package gg.modl.backend.player.dto.response; + +public sealed interface PardonResult permits PardonResult.Pardoned, PardonResult.PlayerNotFound { + + record Pardoned(boolean success, int pardonedCount, String message) implements PardonResult { + } + + record PlayerNotFound(String message) implements PardonResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerDetailResponse.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerDetailResponse.java index c786de2..8027d6d 100644 --- a/src/main/java/gg/modl/backend/player/dto/response/PlayerDetailResponse.java +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerDetailResponse.java @@ -1,26 +1,26 @@ -package gg.modl.backend.player.dto.response; - -import gg.modl.backend.player.data.IPEntry; -import gg.modl.backend.player.data.NoteEntry; -import gg.modl.backend.player.data.UsernameEntry; -import java.util.List; -import java.util.Map; -import org.jetbrains.annotations.Nullable; - -public record PlayerDetailResponse( - String id, - String minecraftUuid, - List usernames, - List notes, - List ipAddresses, - List punishments, - Map data, - String social, - String gameplay, - int socialPoints, - int gameplayPoints, - @Nullable IPEntry latestIPData, - @Nullable String lastServer, - double playtimeHours -) { -} +package gg.modl.backend.player.dto.response; + +import gg.modl.backend.player.data.IPEntry; +import gg.modl.backend.player.data.NoteEntry; +import gg.modl.backend.player.data.UsernameEntry; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +public record PlayerDetailResponse( + String id, + String minecraftUuid, + List usernames, + List notes, + List ipAddresses, + List punishments, + Map data, + String social, + String gameplay, + int socialPoints, + int gameplayPoints, + @Nullable IPEntry latestIPData, + @Nullable String lastServer, + double playtimeHours +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerFetchResult.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerFetchResult.java new file mode 100644 index 0000000..0945f20 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerFetchResult.java @@ -0,0 +1,16 @@ +package gg.modl.backend.player.dto.response; + +import java.util.Map; + +public sealed interface PlayerFetchResult + permits PlayerFetchResult.Found, PlayerFetchResult.NotFound, PlayerFetchResult.InvalidRequest { + + record Found(String message, Map player) implements PlayerFetchResult { + } + + record NotFound(String message) implements PlayerFetchResult { + } + + record InvalidRequest(String message) implements PlayerFetchResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerLoginResult.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerLoginResult.java new file mode 100644 index 0000000..118e03a --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerLoginResult.java @@ -0,0 +1,12 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import java.util.Map; + +public record PlayerLoginResult( + int status, + List activePunishments, + List> pendingNotifications, + List pendingIpLookups, + List> pendingStatWipes +) {} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerLookupResult.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerLookupResult.java new file mode 100644 index 0000000..0dad94c --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerLookupResult.java @@ -0,0 +1,12 @@ +package gg.modl.backend.player.dto.response; + +import java.util.Map; + +public sealed interface PlayerLookupResult permits PlayerLookupResult.Found, PlayerLookupResult.NotFound { + + record Found(String message, Map data) implements PlayerLookupResult { + } + + record NotFound(String message) implements PlayerLookupResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerProfileResult.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerProfileResult.java new file mode 100644 index 0000000..7be4b49 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerProfileResult.java @@ -0,0 +1,12 @@ +package gg.modl.backend.player.dto.response; + +import java.util.Map; + +public sealed interface PlayerProfileResult permits PlayerProfileResult.Found, PlayerProfileResult.NotFound { + + record Found(Map profile) implements PlayerProfileResult { + } + + record NotFound(String message) implements PlayerProfileResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerReportsResult.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerReportsResult.java new file mode 100644 index 0000000..b683051 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerReportsResult.java @@ -0,0 +1,7 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import java.util.Map; + +public record PlayerReportsResult(List> reports) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerSearchResult.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerSearchResult.java index 7907ea9..cfa942f 100644 --- a/src/main/java/gg/modl/backend/player/dto/response/PlayerSearchResult.java +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerSearchResult.java @@ -1,12 +1,12 @@ -package gg.modl.backend.player.dto.response; - -import java.util.Date; - -public record PlayerSearchResult( - String uuid, - String username, - String status, - Date lastOnline, - boolean isOnline -) { -} +package gg.modl.backend.player.dto.response; + +import java.util.Date; + +public record PlayerSearchResult( + String uuid, + String username, + String status, + Date lastOnline, + boolean isOnline +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PunishmentPreviewResponse.java b/src/main/java/gg/modl/backend/player/dto/response/PunishmentPreviewResponse.java index 5429631..e407e1f 100644 --- a/src/main/java/gg/modl/backend/player/dto/response/PunishmentPreviewResponse.java +++ b/src/main/java/gg/modl/backend/player/dto/response/PunishmentPreviewResponse.java @@ -3,10 +3,6 @@ import lombok.Builder; import lombok.Data; -/** - * Response containing punishment preview calculations. - * Shows what the punishment would be before issuing. - */ @Data @Builder public class PunishmentPreviewResponse implements PunishmentPreviewView { @@ -14,24 +10,19 @@ public class PunishmentPreviewResponse implements PunishmentPreviewView { private final boolean success; private final String message; - // Player's current status private final String socialStatus; private final String gameplayStatus; private final int socialPoints; private final int gameplayPoints; - // Offender status - private final String offenderStatus; // "low", "medium", or "high" + private final String offenderStatus; - // Punishment preview for each severity private final SeverityPreview lenient; private final SeverityPreview regular; private final SeverityPreview aggravated; - // For single-severity punishments private final SeverityPreview singleSeverity; - // Punishment type info private final boolean singleSeverityPunishment; private final boolean permanentUntilUsernameChange; private final boolean permanentUntilSkinChange; @@ -50,14 +41,13 @@ public static PunishmentPreviewResponse error(String message) { @Data @Builder public static class SeverityPreview implements PunishmentSeverityPreviewView { - private final String severity; // "lenient", "regular", "aggravated" + private final String severity; private final int points; private final long durationMs; private final String durationFormatted; - private final String punishmentType; // "ban", "mute", "kick" + private final String punishmentType; private final boolean permanent; - // New status after this punishment private final String newSocialStatus; private final String newGameplayStatus; private final int newSocialPoints; diff --git a/src/main/java/gg/modl/backend/player/dto/response/PunishmentSearchResult.java b/src/main/java/gg/modl/backend/player/dto/response/PunishmentSearchResult.java index 63af9c2..b2174a5 100644 --- a/src/main/java/gg/modl/backend/player/dto/response/PunishmentSearchResult.java +++ b/src/main/java/gg/modl/backend/player/dto/response/PunishmentSearchResult.java @@ -1,12 +1,12 @@ -package gg.modl.backend.player.dto.response; - -import java.util.Date; - -public record PunishmentSearchResult( - String id, - String playerName, - int typeOrdinal, - String status, - Date issued -) { -} +package gg.modl.backend.player.dto.response; + +import java.util.Date; + +public record PunishmentSearchResult( + String id, + String playerName, + int typeOrdinal, + String status, + Date issued +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PunishmentView.java b/src/main/java/gg/modl/backend/player/dto/response/PunishmentView.java new file mode 100644 index 0000000..b8e3d93 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PunishmentView.java @@ -0,0 +1,27 @@ +package gg.modl.backend.player.dto.response; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +public record PunishmentView( + String id, + String issuerName, + @Nullable Date issued, + @Nullable Date started, + int typeOrdinal, + String type, + List> modifications, + List> notes, + List> evidence, + List attachedTicketIds, + Map data, + @Nullable String playerUuid, + @Nullable String playerName +) { + public PunishmentView withPlayer(@Nullable String playerUuid, @Nullable String playerName) { + return new PunishmentView(id, issuerName, issued, started, typeOrdinal, type, + modifications, notes, evidence, attachedTicketIds, data, playerUuid, playerName); + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/SimpleActionResult.java b/src/main/java/gg/modl/backend/player/dto/response/SimpleActionResult.java new file mode 100644 index 0000000..7a2fcb7 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/SimpleActionResult.java @@ -0,0 +1,4 @@ +package gg.modl.backend.player.dto.response; + +public record SimpleActionResult(boolean success) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/SimplePunishmentView.java b/src/main/java/gg/modl/backend/player/dto/response/SimplePunishmentView.java new file mode 100644 index 0000000..dfa3535 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/SimplePunishmentView.java @@ -0,0 +1,27 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import org.jetbrains.annotations.Nullable; + +public record SimplePunishmentView( + String id, + String type, + String category, + int typeOrdinal, + int ordinal, + boolean started, + @Nullable Long expiration, + String description, + String issuerName, + long issuedAt, + @Nullable String playerDescription, + List modifications +) { + public record Modification( + String type, + @Nullable Long timestamp, + long effectiveDuration, + String issuerName + ) { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/SyncDataView.java b/src/main/java/gg/modl/backend/player/dto/response/SyncDataView.java new file mode 100644 index 0000000..0edfe37 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/SyncDataView.java @@ -0,0 +1,20 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +public record SyncDataView( + List pendingPunishments, + List recentlyStartedPunishments, + List recentlyModifiedPunishments, + List> playerNotifications, + List> staffNotifications, + List> activeStaffMembers, + List> pendingStatWipes, + @Nullable Long staffPermissionsUpdatedAt, + @Nullable Long punishmentTypesUpdatedAt, + @Nullable List> staff2faVerifications, + @Nullable Map migrationTask +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/SyncPunishmentEntry.java b/src/main/java/gg/modl/backend/player/dto/response/SyncPunishmentEntry.java new file mode 100644 index 0000000..a7392bc --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/SyncPunishmentEntry.java @@ -0,0 +1,8 @@ +package gg.modl.backend.player.dto.response; + +public record SyncPunishmentEntry( + String minecraftUuid, + String username, + SimplePunishmentView punishment +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/SyncResult.java b/src/main/java/gg/modl/backend/player/dto/response/SyncResult.java new file mode 100644 index 0000000..5dba1f9 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/SyncResult.java @@ -0,0 +1,7 @@ +package gg.modl.backend.player.dto.response; + +public record SyncResult( + String timestamp, + SyncDataView data +) { +} diff --git a/src/main/java/gg/modl/backend/player/service/AccountLinkingService.java b/src/main/java/gg/modl/backend/player/service/AccountLinkingService.java index b567d53..8989a52 100644 --- a/src/main/java/gg/modl/backend/player/service/AccountLinkingService.java +++ b/src/main/java/gg/modl/backend/player/service/AccountLinkingService.java @@ -3,6 +3,7 @@ import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; import gg.modl.backend.player.data.IPEntry; import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.punishment.EnforcementCategory; import gg.modl.backend.player.data.punishment.Punishment; import gg.modl.backend.player.dto.response.LinkedAccountResponse; import gg.modl.backend.server.data.Server; @@ -12,7 +13,6 @@ import java.util.Date; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -35,14 +35,8 @@ public List getLinkedAccounts(Server server, UUID playerU return new ArrayList<>(); } - Map data = player.getData(); - if (data == null || !data.containsKey("linkedAccounts")) { - return new ArrayList<>(); - } - - @SuppressWarnings("unchecked") - List linkedUuids = (List) data.get("linkedAccounts"); - if (linkedUuids == null || linkedUuids.isEmpty()) { + List linkedUuids = player.data().linkedAccountUuids(); + if (linkedUuids.isEmpty()) { return new ArrayList<>(); } @@ -81,22 +75,16 @@ private LinkedAccountResponse buildLinkedAccountResponse(Server server, Player p for (Punishment punishment : player.getPunishments()) { if (statusCalculator.isPunishmentActive(punishment)) { PunishmentType punishmentType = punishmentTypeService.getPunishmentTypeByOrdinal(server, punishment.getTypeOrdinal()).orElse(null); - String category = statusCalculator.getEffectiveCategory(punishmentType, punishment.getData()); - if ("BAN".equals(category)) { + String category = statusCalculator.getEffectiveCategory(punishmentType, punishment.data()); + if (EnforcementCategory.BAN.name().equals(category)) { activeBans++; - } else if ("MUTE".equals(category)) { + } else if (EnforcementCategory.MUTE.name().equals(category)) { activeMutes++; } } } - Date lastLinkedUpdate = null; - if (player.getData() != null) { - Object lastUpdate = player.getData().get("lastLinkedUpdate"); - if (lastUpdate instanceof Date date) { - lastLinkedUpdate = date; - } - } + Date lastLinkedUpdate = player.data().lastLinkedUpdate(); return new LinkedAccountResponse( player.getMinecraftUuid().toString(), diff --git a/src/main/java/gg/modl/backend/player/service/DuplicatePlayerMerger.java b/src/main/java/gg/modl/backend/player/service/DuplicatePlayerMerger.java index 2b788ac..6488c19 100644 --- a/src/main/java/gg/modl/backend/player/service/DuplicatePlayerMerger.java +++ b/src/main/java/gg/modl/backend/player/service/DuplicatePlayerMerger.java @@ -42,7 +42,7 @@ private void mergeInto(Player primary, Player other) { primary.setIpAddresses(mergeBy(primary.getIpAddresses(), other.getIpAddresses(), IPEntry::getIpAddress)); primary.setNotes(mergeBy(primary.getNotes(), other.getNotes(), NoteEntry::getId)); primary.setPunishments(mergeBy(primary.getPunishments(), other.getPunishments(), Punishment::getId)); - primary.setData(mergeData(primary.getData(), other.getData())); + primary.replaceData(mergeData(primary.data().asMap(), other.data().asMap())); } private List mergeBy(List primary, List other, Function identity) { diff --git a/src/main/java/gg/modl/backend/player/service/IssuerNameResolver.java b/src/main/java/gg/modl/backend/player/service/IssuerNameResolver.java index 7adb0d0..17ac1e8 100644 --- a/src/main/java/gg/modl/backend/player/service/IssuerNameResolver.java +++ b/src/main/java/gg/modl/backend/player/service/IssuerNameResolver.java @@ -1,8 +1,13 @@ package gg.modl.backend.player.service; import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.punishment.Punishment; import gg.modl.backend.server.data.Server; import gg.modl.backend.staff.data.Staff; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import lombok.RequiredArgsConstructor; @@ -29,20 +34,32 @@ public String resolve(@Nullable String issuerId, @Nullable String issuerName, Se return "Console"; } - public String resolve(@Nullable String issuerId, @Nullable String issuerName, Map resolvedMap) { - if (issuerId != null && resolvedMap.containsKey(issuerId)) { - return resolvedMap.get(issuerId); - } - if (issuerName != null) { - return issuerName; - } - return issuerId != null ? "Unknown Staff" : "Console"; - } - public Map batchResolve(Set issuerIds, Server server) { if (issuerIds == null || issuerIds.isEmpty()) { return Map.of(); } return staffRepository.findUsernamesByIds(server, issuerIds); } + + public Map resolveForPunishments(Server server, Collection punishments) { + Set ids = new HashSet<>(); + for (Punishment punishment : punishments) { + ids.addAll(PunishmentQueryService.collectIssuerIds(punishment)); + } + return batchResolve(ids, server); + } + + public Map resolveForPlayers(Server server, Collection players) { + Set ids = new HashSet<>(); + for (Player player : players) { + List punishments = player.getPunishments(); + if (punishments == null) { + continue; + } + for (Punishment punishment : punishments) { + ids.addAll(PunishmentQueryService.collectIssuerIds(punishment)); + } + } + return batchResolve(ids, server); + } } diff --git a/src/main/java/gg/modl/backend/player/service/MinecraftChatLogService.java b/src/main/java/gg/modl/backend/player/service/MinecraftChatLogService.java index b54bcf9..d493f2e 100644 --- a/src/main/java/gg/modl/backend/player/service/MinecraftChatLogService.java +++ b/src/main/java/gg/modl/backend/player/service/MinecraftChatLogService.java @@ -4,9 +4,9 @@ import gg.modl.backend.database.mongo.repository.CommandLogMongoRepository; import gg.modl.backend.player.data.log.ChatLogDocument; import gg.modl.backend.player.data.log.CommandLogDocument; +import gg.modl.backend.infrastructure.util.UuidUtils; import gg.modl.backend.server.data.Server; import java.util.List; -import java.util.Locale; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -24,7 +24,7 @@ public void submitChatLogs(Server server, List entries) { chatLogRepository.insertAll(server, entries.stream() .map(entry -> ChatLogDocument.builder() - .uuid(normalizeUuid(entry.uuid())) + .uuid(UuidUtils.normalize(entry.uuid())) .username(entry.username()) .message(entry.message()) .timestamp(entry.timestamp()) @@ -40,7 +40,7 @@ public void submitCommandLogs(Server server, List entries) { commandLogRepository.insertAll(server, entries.stream() .map(entry -> CommandLogDocument.builder() - .uuid(normalizeUuid(entry.uuid())) + .uuid(UuidUtils.normalize(entry.uuid())) .username(entry.username()) .command(entry.command()) .timestamp(entry.timestamp()) @@ -50,7 +50,7 @@ public void submitCommandLogs(Server server, List entries) { } public List getChatLogs(Server server, String uuid, int limit) { - return chatLogRepository.findByUuidRecent(server, normalizeUuid(uuid), Math.min(limit, MAX_FETCH_LIMIT)) + return chatLogRepository.findByUuidRecent(server, UuidUtils.normalize(uuid), Math.min(limit, MAX_FETCH_LIMIT)) .stream() .map(entry -> new ChatLogEntryView( entry.getUuid(), @@ -63,7 +63,7 @@ public List getChatLogs(Server server, String uuid, int limit) } public List getCommandLogs(Server server, String uuid, int limit) { - return commandLogRepository.findByUuidRecent(server, normalizeUuid(uuid), Math.min(limit, MAX_FETCH_LIMIT)) + return commandLogRepository.findByUuidRecent(server, UuidUtils.normalize(uuid), Math.min(limit, MAX_FETCH_LIMIT)) .stream() .map(entry -> new CommandLogEntryView( entry.getUuid(), @@ -86,8 +86,4 @@ public record ChatLogEntryView(String uuid, String username, String message, lon public record CommandLogEntryView(String uuid, String username, String command, long timestamp, String server) { } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } } \ No newline at end of file diff --git a/src/main/java/gg/modl/backend/player/service/MinecraftPlayerService.java b/src/main/java/gg/modl/backend/player/service/MinecraftPlayerService.java index cabfcf0..9dbee1c 100644 --- a/src/main/java/gg/modl/backend/player/service/MinecraftPlayerService.java +++ b/src/main/java/gg/modl/backend/player/service/MinecraftPlayerService.java @@ -1,7 +1,6 @@ package gg.modl.backend.player.service; import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; import gg.modl.backend.database.mongo.repository.TicketMongoRepository; import gg.modl.backend.player.PlayerService; import gg.modl.backend.player.data.NoteEntry; @@ -9,32 +8,39 @@ import gg.modl.backend.player.data.UsernameEntry; import gg.modl.backend.player.data.punishment.Punishment; import gg.modl.backend.player.data.punishment.EnforcementCategory; -import gg.modl.backend.player.data.punishment.PunishmentModificationType; -import gg.modl.backend.player.data.punishment.PunishmentData; -import gg.modl.backend.player.data.punishment.PunishmentStatus; +import gg.modl.backend.player.data.punishment.PunishmentDataView; import gg.modl.backend.player.dto.request.AcknowledgeNotificationsRequest; +import gg.modl.backend.player.dto.response.AcknowledgeResult; +import gg.modl.backend.player.dto.response.CreateNoteResult; +import gg.modl.backend.player.dto.response.OnlinePlayersResult; +import gg.modl.backend.player.dto.response.PaginatedNotesResult; +import gg.modl.backend.player.dto.response.PaginatedPunishmentsResult; +import gg.modl.backend.player.dto.response.PardonResult; +import gg.modl.backend.player.dto.response.PlayerLoginResult; +import gg.modl.backend.player.dto.response.PlayerReportsResult; +import gg.modl.backend.player.dto.response.PunishmentView; +import gg.modl.backend.player.dto.response.SimpleActionResult; +import gg.modl.backend.player.dto.response.SimplePunishmentView; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.PunishmentType; import gg.modl.backend.settings.service.PunishmentTypeIndex; import gg.modl.backend.settings.service.PunishmentTypeService; import gg.modl.backend.infrastructure.util.PaginationHelper; -import gg.modl.backend.player.service.PlayerDataUtils; import java.util.ArrayList; -import java.util.Collections; import java.util.Date; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.UUID; import gg.modl.backend.infrastructure.util.IdGenerator; -import org.springframework.http.HttpStatus; +import gg.modl.backend.infrastructure.util.UuidUtils; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service +@RequiredArgsConstructor public class MinecraftPlayerService { private final PlayerService playerService; private final PlayerMongoRepository playerRepository; @@ -44,31 +50,8 @@ public class MinecraftPlayerService { private final PunishmentLifecycleService punishmentLifecycleService; private final AccountLinkingService accountLinkingService; private final IssuerNameResolver issuerNameResolver; - private final StaffMongoRepository staffRepository; - - public MinecraftPlayerService( - PlayerService playerService, - PlayerMongoRepository playerRepository, - TicketMongoRepository ticketRepository, - PlayerStatusCalculator statusCalculator, - PunishmentTypeService punishmentTypeService, - PunishmentLifecycleService punishmentLifecycleService, - AccountLinkingService accountLinkingService, - IssuerNameResolver issuerNameResolver, - StaffMongoRepository staffRepository - ) { - this.playerService = playerService; - this.playerRepository = playerRepository; - this.ticketRepository = ticketRepository; - this.statusCalculator = statusCalculator; - this.punishmentTypeService = punishmentTypeService; - this.punishmentLifecycleService = punishmentLifecycleService; - this.accountLinkingService = accountLinkingService; - this.issuerNameResolver = issuerNameResolver; - this.staffRepository = staffRepository; - } - public ServiceResponse login( + public PlayerLoginResult login( Server server, UUID playerUuid, String username, @@ -87,8 +70,8 @@ public ServiceResponse login( player = promotePunishments(server, player, playerUuid, username, skinHash); List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); - Map resolvedIssuers = resolveIssuersForPlayer(server, player); - List> activePunishments = collectActivePunishments(player, punishmentTypes, resolvedIssuers); + Map resolvedIssuers = issuerNameResolver.resolveForPlayers(server, List.of(player)); + List activePunishments = collectActivePunishments(player, punishmentTypes, resolvedIssuers); return buildLoginResponse(server, player, playerUuid, ip, username, activePunishments, isNewIp); } @@ -104,12 +87,12 @@ private Player promotePunishments(Server server, Player player, UUID playerUuid, return player; } - private List> collectActivePunishments( + private List collectActivePunishments( Player player, List punishmentTypes, Map resolvedIssuers ) { - List> activePunishments = new ArrayList<>(); + List activePunishments = new ArrayList<>(); Map typesByOrdinal = PunishmentTypeIndex.byOrdinal(punishmentTypes); for (Punishment punishment : player.getPunishments()) { @@ -135,22 +118,16 @@ private List> collectActivePunishments( return deduplicateActivePunishments(activePunishments); } - private List> deduplicateActivePunishments(List> punishments) { - Map> oldestByCategory = new LinkedHashMap<>(); - List> result = new ArrayList<>(); + private List deduplicateActivePunishments(List punishments) { + Map oldestByCategory = new LinkedHashMap<>(); + List result = new ArrayList<>(); - for (Map punishment : punishments) { - String category = (String) punishment.get("category"); + for (SimplePunishmentView punishment : punishments) { + String category = punishment.category(); if (EnforcementCategory.BAN.name().equals(category) || EnforcementCategory.MUTE.name().equals(category)) { - Map existing = oldestByCategory.get(category); - if (existing == null) { + SimplePunishmentView existing = oldestByCategory.get(category); + if (existing == null || punishment.issuedAt() < existing.issuedAt()) { oldestByCategory.put(category, punishment); - } else { - long existingIssued = existing.get("issuedAt") instanceof Number number ? number.longValue() : 0L; - long currentIssued = punishment.get("issuedAt") instanceof Number number ? number.longValue() : 0L; - if (currentIssued < existingIssued) { - oldestByCategory.put(category, punishment); - } } } else { result.add(punishment); @@ -161,16 +138,16 @@ private List> deduplicateActivePunishments(List> activePunishments, + List activePunishments, boolean isNewIp ) { - List> pendingNotifications = extractPendingNotifications(player); + List> pendingNotifications = player.data().pendingNotifications(); List pendingIpLookups = new ArrayList<>(); if (ip != null && isNewIp) { @@ -184,10 +161,9 @@ private ServiceResponse buildLoginResponse( List> pendingStatWipes = new ArrayList<>(); for (Punishment punishment : player.getPunishments()) { - Map data = punishment.getData(); - if (data == null - || !PunishmentData.isWipeAfterExpiry(data) - || PunishmentData.isStatWipeCompleted(data) + PunishmentDataView data = punishment.data(); + if (!data.wipeAfterExpiry() + || data.statWipeCompleted() || !statusCalculator.isPunishmentNaturallyExpired(punishment)) { continue; } @@ -200,38 +176,13 @@ private ServiceResponse buildLoginResponse( } boolean isNewPlayer = player.getUsernames().size() == 1; - Map responseBody = new LinkedHashMap<>(); - responseBody.put("status", isNewPlayer ? 201 : 200); - responseBody.put("activePunishments", activePunishments); - responseBody.put("pendingNotifications", pendingNotifications); - if (!pendingIpLookups.isEmpty()) { - responseBody.put("pendingIpLookups", pendingIpLookups); - } - if (!pendingStatWipes.isEmpty()) { - responseBody.put("pendingStatWipes", pendingStatWipes); - } - - return new ServiceResponse(isNewPlayer ? HttpStatus.CREATED : HttpStatus.OK, responseBody); - } - - private List> extractPendingNotifications(Player player) { - if (player.getData() == null) { - return List.of(); - } - - Object rawPending = player.getData().getOrDefault("pendingNotifications", Collections.emptyList()); - if (!(rawPending instanceof List list)) { - return List.of(); - } - - return list.stream() - .filter(entry -> entry instanceof Map) - .map(entry -> { - @SuppressWarnings("unchecked") - Map notification = (Map) entry; - return notification; - }) - .toList(); + return new PlayerLoginResult( + isNewPlayer ? 201 : 200, + activePunishments, + pendingNotifications, + pendingIpLookups, + pendingStatWipes + ); } private List safeUsernames(Player player) { @@ -239,38 +190,26 @@ private List safeUsernames(Player player) { } private Optional findPlayerByUuid(Server server, String uuid) { - return playerRepository.findByMinecraftUuid(server, normalizeUuid(uuid)); + return playerRepository.findByMinecraftUuid(server, UuidUtils.normalize(uuid)); } - private Map resolveIssuersForPlayer(Server server, Player player) { - Set ids = new HashSet<>(); - for (Punishment p : player.getPunishments()) { - ids.addAll(PunishmentQueryService.collectIssuerIds(p)); - } - if (ids.isEmpty()) { - return Map.of(); - } - return issuerNameResolver.batchResolve(ids, server); - } - - public Map disconnect(Server server, String minecraftUuid, long sessionDurationMs) { - playerRepository.markDisconnected(server, normalizeUuid(minecraftUuid), sessionDurationMs); - return Map.of("status", 200, "success", true); + public SimpleActionResult disconnect(Server server, String minecraftUuid, long sessionDurationMs) { + playerRepository.markDisconnected(server, UuidUtils.normalize(minecraftUuid), sessionDurationMs); + return new SimpleActionResult(true); } - public Map updateServer(Server server, String minecraftUuid, String serverName) { - playerRepository.updateLastServer(server, normalizeUuid(minecraftUuid), serverName); - return Map.of("status", 200, "success", true); + public SimpleActionResult updateServer(Server server, String minecraftUuid, String serverName) { + playerRepository.updateLastServer(server, UuidUtils.normalize(minecraftUuid), serverName); + return new SimpleActionResult(true); } - public Map getOnlinePlayers(Server server) { + public OnlinePlayersResult getOnlinePlayers(Server server) { List> players = playerRepository.findOnlinePlayers(server, 500) .stream() .map(player -> { - Object lastLoginObj = player.getData() != null ? player.getData().get("lastLogin") : null; - Date joinedAt = lastLoginObj instanceof Date date ? date : null; - Object playtimeObj = player.getData() != null ? player.getData().get("totalPlaytimeSeconds") : null; - long totalPlaytimeMs = playtimeObj instanceof Number number ? number.longValue() * 1000 : 0L; + Date joinedAt = player.data().lastLogin(); + Number playtime = player.data().totalPlaytimeSeconds(); + long totalPlaytimeMs = playtime != null ? playtime.longValue() * 1000 : 0L; Map entry = new LinkedHashMap<>(); entry.put("uuid", player.getMinecraftUuid().toString()); @@ -281,13 +220,13 @@ public Map getOnlinePlayers(Server server) { }) .toList(); - return Map.of("status", 200, "players", players); + return new OnlinePlayersResult(players); } - public ServiceResponse createNote(Server server, String uuid, String text, String issuerName, String issuerId) { + public CreateNoteResult createNote(Server server, String uuid, String text, String issuerName, String issuerId) { Player player = findPlayerByUuid(server, uuid).orElse(null); if (player == null) { - return notFound("Player not found"); + return new CreateNoteResult.NotFound("Player not found"); } ensurePlayerNotes(player).add(NoteEntry.builder() @@ -299,11 +238,7 @@ public ServiceResponse createNote(Server server, String uuid, String text, Strin .build()); playerRepository.replaceNotes(server, player); - return ok(Map.of( - "status", 200, - "success", true, - "message", "Note added" - )); + return new CreateNoteResult.Created("Note added"); } private List ensurePlayerNotes(Player player) { @@ -313,11 +248,11 @@ private List ensurePlayerNotes(Player player) { return player.getNotes(); } - public ServiceResponse acknowledgeNotifications(Server server, AcknowledgeNotificationsRequest request) { + public AcknowledgeResult acknowledgeNotifications(Server server, AcknowledgeNotificationsRequest request) { return acknowledgeNotifications(server, request.playerUuid(), request.notificationIds(), request.acknowledgedAt()); } - public ServiceResponse acknowledgeNotifications( + public AcknowledgeResult acknowledgeNotifications( Server server, String playerUuid, List notificationIds, @@ -325,14 +260,10 @@ public ServiceResponse acknowledgeNotifications( ) { Player player = findPlayerByUuid(server, playerUuid).orElse(null); if (player == null) { - return ok(Map.of( - "status", 200, - "success", true, - "message", "No player found, nothing to acknowledge" - )); + return new AcknowledgeResult("No player found, nothing to acknowledge"); } - List> pendingNotifications = extractPendingNotifications(player); + List> pendingNotifications = player.data().pendingNotifications(); List> remainingNotifications = pendingNotifications.stream() .filter(notification -> { Object notificationId = notification.get("id"); @@ -340,53 +271,36 @@ public ServiceResponse acknowledgeNotifications( }) .toList(); - ensurePlayerData(player).put("pendingNotifications", remainingNotifications); + player.data().setPendingNotifications(remainingNotifications); playerRepository.replacePendingNotifications(server, player, remainingNotifications); - return ok(Map.of( - "status", 200, - "success", true, - "message", "Acknowledged " + (pendingNotifications.size() - remainingNotifications.size()) + " notification(s)" - )); - } - - private Map ensurePlayerData(Player player) { - if (player.getData() == null) { - player.setData(new LinkedHashMap<>()); - } - return player.getData(); + return new AcknowledgeResult( + "Acknowledged " + (pendingNotifications.size() - remainingNotifications.size()) + " notification(s)"); } - public ServiceResponse getPlayerPunishments(Server server, String uuid, int page, int limit) { + public PaginatedPunishmentsResult getPlayerPunishments(Server server, String uuid, int page, int limit) { Player player = findPlayerByUuid(server, uuid).orElse(null); if (player == null) { - return notFound("Player not found"); + return new PaginatedPunishmentsResult.NotFound("Player not found"); } List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); - Map resolvedIssuers = resolveIssuersForPlayer(server, player); + Map resolvedIssuers = issuerNameResolver.resolveForPlayers(server, List.of(player)); - List> allPunishments = player.getPunishments() + List allPunishments = player.getPunishments() .stream() .sorted((a, b) -> b.getIssued().compareTo(a.getIssued())) - .map(p -> PunishmentMapper.toPunishmentMap(p, punishmentTypes, resolvedIssuers)) + .map(p -> PunishmentMapper.toPunishmentView(p, punishmentTypes, resolvedIssuers)) .toList(); - PaginationHelper.PageResult> result = PaginationHelper.paginate(allPunishments, page, limit); - - Map response = new LinkedHashMap<>(); - response.put("status", 200); - response.put("punishments", result.items()); - response.put("totalCount", result.totalCount()); - response.put("page", result.page()); - response.put("hasMore", result.hasMore()); - return ok(response); + PaginationHelper.PageResult result = PaginationHelper.paginate(allPunishments, page, limit); + return new PaginatedPunishmentsResult.Found(result.items(), result.totalCount(), result.page(), result.hasMore()); } - public ServiceResponse getPlayerNotes(Server server, String uuid, int page, int limit) { + public PaginatedNotesResult getPlayerNotes(Server server, String uuid, int page, int limit) { Player player = findPlayerByUuid(server, uuid).orElse(null); if (player == null) { - return notFound("Player not found"); + return new PaginatedNotesResult.NotFound("Player not found"); } List> allNotes = player.getNotes() @@ -404,18 +318,11 @@ public ServiceResponse getPlayerNotes(Server server, String uuid, int page, int .toList(); PaginationHelper.PageResult> result = PaginationHelper.paginate(allNotes, page, limit); - - Map response = new LinkedHashMap<>(); - response.put("status", 200); - response.put("notes", result.items()); - response.put("totalCount", result.totalCount()); - response.put("page", result.page()); - response.put("hasMore", result.hasMore()); - return ok(response); + return new PaginatedNotesResult.Found(result.items(), result.totalCount(), result.page(), result.hasMore()); } - public Map getPlayerReports(Server server, String uuid) { - List> reports = ticketRepository.findReportedPlayerTickets(server, normalizeUuid(uuid), 50) + public PlayerReportsResult getPlayerReports(Server server, String uuid) { + List> reports = ticketRepository.findReportedPlayerTickets(server, UuidUtils.normalize(uuid), 50) .stream() .map(ticket -> { Map report = new LinkedHashMap<>(); @@ -431,10 +338,10 @@ public Map getPlayerReports(Server server, String uuid) { }) .toList(); - return Map.of("status", 200, "reports", reports); + return new PlayerReportsResult(reports); } - public Map submitIpInfo( + public SimpleActionResult submitIpInfo( Server server, String minecraftUuid, String ip, @@ -444,39 +351,38 @@ public Map submitIpInfo( boolean proxy, boolean hosting ) { - playerService.updateIpGeoData(server, normalizeUuid(minecraftUuid), ip, Map.of( + playerService.updateIpGeoData(server, UuidUtils.normalize(minecraftUuid), ip, Map.of( "country", country != null ? country : "", "region", region != null ? region : "", "asn", asn != null ? asn : "", "proxy", proxy, "hosting", hosting )); - return Map.of("status", 200, "success", true); + return new SimpleActionResult(true); } - public Map pardonPlayer(Server server, String playerName, String punishmentType, String issuerName, String issuerId, String reason) { + public PardonResult pardonPlayer(Server server, String playerName, String punishmentType, String issuerName, String issuerId, String reason) { Player player = playerService.findBestByUsername(server, playerName).orElse(null); if (player == null) { - return Map.of("status", 404, "message", "Player not found"); + return new PardonResult.PlayerNotFound("Player not found"); } List types = punishmentTypeService.getPunishmentTypes(server); List targets = new ArrayList<>(); for (Punishment punishment : player.getPunishments()) { - if (isAlreadyPardoned(punishment)) { + if (punishment.isPardoned()) { continue; } boolean isActive = statusCalculator.isPunishmentActive(punishment); - Map punishmentData = punishment.getData(); - boolean isUnstarted = PunishmentStatus.UNSTARTED.equals(PunishmentData.getStatus(punishmentData)); + boolean isUnstarted = punishment.hasUnstartedStatus(); boolean shouldPardon; if (punishmentType == null) { shouldPardon = isActive || isUnstarted; } else { - String requestedType = punishmentType.toLowerCase(); + String requestedType = punishmentType.toLowerCase(Locale.ROOT); String effectiveCategory = statusCalculator.getEffectiveCategory(punishment, types); shouldPardon = ("ban".equals(requestedType) && EnforcementCategory.BAN.name().equals(effectiveCategory) && (isActive || isUnstarted)) || ("mute".equals(requestedType) && EnforcementCategory.MUTE.name().equals(effectiveCategory) && (isActive || isUnstarted)); @@ -491,35 +397,12 @@ public Map pardonPlayer(Server server, String playerName, String ? 0 : punishmentLifecycleService.pardonPunishments(server, player, targets, issuerName, issuerId, reason); - return Map.of( - "status", 200, - "success", pardoned > 0, - "pardonedCount", pardoned, - "message", pardoned > 0 - ? "Pardoned " + pardoned + " punishment(s)" - : "No punishments found to pardon" + return new PardonResult.Pardoned( + pardoned > 0, + pardoned, + pardoned > 0 + ? "Pardoned " + pardoned + " punishment(s)" + : "No punishments found to pardon" ); } - - private boolean isAlreadyPardoned(Punishment punishment) { - return punishment.getModifications() - .stream() - .anyMatch(modification -> - PunishmentModificationType.isPardon(modification.type())); - } - - private ServiceResponse ok(Map body) { - return new ServiceResponse(HttpStatus.OK, body); - } - - private ServiceResponse notFound(String message) { - return new ServiceResponse(HttpStatus.NOT_FOUND, Map.of("status", 404, "message", message)); - } - - public record ServiceResponse(HttpStatus status, Map body) { - } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } } diff --git a/src/main/java/gg/modl/backend/player/service/MinecraftStartupService.java b/src/main/java/gg/modl/backend/player/service/MinecraftStartupService.java index 5cd43c1..8bc4adf 100644 --- a/src/main/java/gg/modl/backend/player/service/MinecraftStartupService.java +++ b/src/main/java/gg/modl/backend/player/service/MinecraftStartupService.java @@ -1,9 +1,9 @@ package gg.modl.backend.player.service; -import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.server.service.PanelDomainResolver; import gg.modl.backend.database.mongo.repository.ServerInstanceSnapshotMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.player.controller.MinecraftStartupController.StartupRequest; +import gg.modl.backend.database.mongo.repository.ServerActivityRepository; +import gg.modl.backend.player.dto.request.StartupRequest; import gg.modl.backend.realtime.config.RealtimeProperties; import gg.modl.backend.server.data.Server; import gg.modl.proto.modl.v1.Topic; @@ -17,9 +17,6 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; import org.springframework.stereotype.Service; @Service @@ -35,9 +32,9 @@ public class MinecraftStartupService { Topic.TOPIC_MINECRAFT_MIGRATION_TASKS ); - private final ModlProperties modlProperties; + private final PanelDomainResolver panelDomainResolver; private final RealtimeProperties realtimeProperties; - private final ServerMongoRepository serverRepository; + private final ServerActivityRepository serverActivityRepository; private final ServerInstanceSnapshotMongoRepository serverInstanceSnapshotRepository; public Map handleStartup(Server server, StartupRequest request, String clientIp) { @@ -63,20 +60,11 @@ public Map handleStartup( ) { Instant now = Instant.now(); - // Build panel URL - String domain = server.getCustomDomainOverride(); - if (domain == null || domain.isBlank()) { - domain = server.getCustomDomain() + "." + modlProperties.getDomain(); - } + String domain = panelDomainResolver.panelDomain(server); String panelUrl = "https://" + domain; - // Update lastActivityAt on the server - serverRepository.updateFirst( - Query.query(Criteria.where("_id").is(server.getId())), - new Update().set("lastActivityAt", Date.from(now)) - ); + serverActivityRepository.updateActivity(server, Date.from(now)); - // Store metrics via ServerInstanceSnapshotMongoRepository try { long epochSeconds = now.getEpochSecond(); Date fiveMinBoundary = Date.from(Instant.ofEpochSecond((epochSeconds / 300) * 300)); diff --git a/src/main/java/gg/modl/backend/player/service/MinecraftSyncService.java b/src/main/java/gg/modl/backend/player/service/MinecraftSyncService.java index 3aaacd3..684ff96 100644 --- a/src/main/java/gg/modl/backend/player/service/MinecraftSyncService.java +++ b/src/main/java/gg/modl/backend/player/service/MinecraftSyncService.java @@ -3,17 +3,21 @@ import gg.modl.backend.database.mongo.repository.MigrationMongoRepository; import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; import gg.modl.backend.database.mongo.repository.ServerInstanceSnapshotMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerActivityRepository; import gg.modl.backend.database.mongo.repository.StaffMongoRepository; import gg.modl.backend.migration.data.MigrationStatus; import gg.modl.backend.player.data.Player; import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentModification; import gg.modl.backend.player.data.punishment.PunishmentModificationType; import gg.modl.backend.player.data.punishment.EnforcementCategory; +import gg.modl.backend.player.dto.response.SimplePunishmentView; +import gg.modl.backend.player.dto.response.SyncDataView; +import gg.modl.backend.player.dto.response.SyncPunishmentEntry; +import gg.modl.backend.player.dto.response.SyncResult; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.PunishmentType; import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.backend.infrastructure.util.UuidUtils; import gg.modl.backend.staff.data.Staff; import java.net.URI; import java.time.Instant; @@ -24,13 +28,9 @@ import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -41,7 +41,7 @@ public class MinecraftSyncService { private final PlayerMongoRepository playerRepository; private final StaffMongoRepository staffRepository; - private final ServerMongoRepository serverRepository; + private final ServerActivityRepository serverActivityRepository; private final MigrationMongoRepository migrationRepository; private final ServerInstanceSnapshotMongoRepository serverInstanceSnapshotRepository; private final PlayerStatusCalculator statusCalculator; @@ -52,7 +52,7 @@ public class MinecraftSyncService { private final SyncStaffEventService syncStaffEventService; private final SyncActiveStaffService syncActiveStaffService; - public Map sync( + public SyncResult sync( Server server, String lastSyncTimestamp, List onlinePlayers, @@ -69,8 +69,8 @@ public Map sync( Instant lastSync = parseLastSync(lastSyncTimestamp, now); List types = punishmentTypeService.getPunishmentTypes(server); - List> pendingPunishments = new ArrayList<>(); - List> recentlyModifiedPunishments = new ArrayList<>(); + List pendingPunishments = new ArrayList<>(); + List recentlyModifiedPunishments = new ArrayList<>(); List> playerNotifications = new ArrayList<>(); Set onlineUuids = collectOnlineUuids(onlinePlayers); @@ -78,22 +78,7 @@ public Map sync( if (!onlineUuids.isEmpty()) { List players = playerRepository.findByMinecraftUuids(server, onlineUuids); - Set allIssuerIds = new HashSet<>(); - for (Player p : players) { - for (Punishment pun : p.getPunishments()) { - if (pun.getIssuerId() != null) { - allIssuerIds.add(pun.getIssuerId()); - } - for (PunishmentModification m : pun.getModifications()) { - if (m.issuerId() != null) { - allIssuerIds.add(m.issuerId()); - } - } - } - } - Map resolvedIssuers = allIssuerIds.isEmpty() - ? Map.of() - : issuerNameResolver.batchResolve(allIssuerIds, server); + Map resolvedIssuers = issuerNameResolver.resolveForPlayers(server, players); for (Player player : players) { List promoted = punishmentLifecycleService.promoteUnstartedPunishments(server, player); @@ -104,122 +89,8 @@ public Map sync( } } - String uuid = player.getMinecraftUuid().toString(); - String username = PlayerDataUtils.extractLatestUsername(player.getUsernames()); - - // Pass 1: classify all punishments in a single iteration - Set categoriesWithActiveStarted = new HashSet<>(); - Map oldestUnstartedPerCategory = new LinkedHashMap<>(); - Set pardonedCategories = new HashSet<>(); - - for (Punishment punishment : player.getPunishments()) { - boolean active = statusCalculator.isPunishmentActive(punishment); - String category = statusCalculator.getEffectiveCategory(punishment, types); - - boolean recentlyModified = punishment.getModifications() - .stream() - .anyMatch(mod -> mod.date() != null && mod.date().toInstant().isAfter(lastSync)); - if (recentlyModified) { - recentlyModifiedPunishments.add(Map.of( - "minecraftUuid", uuid, - "username", username, - "punishment", PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers) - )); - } - - boolean recentlyPardoned = punishment.getModifications() - .stream() - .anyMatch(mod -> mod.date() != null - && mod.date().toInstant().isAfter(lastSync) - && PunishmentModificationType.isPardon(mod.type())); - if (recentlyPardoned && category != null) { - pardonedCategories.add(category); - } - - // Unstarted kicks (ordinal 0) - if (punishment.getTypeOrdinal() == 0 && punishment.getStarted() == null) { - pendingPunishments.add(Map.of( - "minecraftUuid", uuid, - "username", username, - "punishment", PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers) - )); - continue; - } - - if (!active) { - continue; - } - - // Newly issued active punishments since last sync - if (punishment.getStarted() != null - && punishment.getIssued() != null - && punishment.getIssued().toInstant().isAfter(lastSync) - && category != null) { - pendingPunishments.add(Map.of( - "minecraftUuid", uuid, - "username", username, - "punishment", PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers) - )); - } - - // Track active started vs unstarted per category - if (category != null && punishment.getStarted() != null) { - categoriesWithActiveStarted.add(category); - } else if (category != null && punishment.getStarted() == null) { - Punishment existing = oldestUnstartedPerCategory.get(category); - if (existing == null - || (punishment.getIssued() != null - && (existing.getIssued() == null - || punishment.getIssued().before(existing.getIssued())))) { - oldestUnstartedPerCategory.put(category, punishment); - } - } - } - - // Add oldest unstarted punishments that don't have an active started one in the same category - for (Map.Entry entry : oldestUnstartedPerCategory.entrySet()) { - if (!categoriesWithActiveStarted.contains(entry.getKey())) { - pendingPunishments.add(Map.of( - "minecraftUuid", uuid, - "username", username, - "punishment", PunishmentMapper.toSimplePunishment(entry.getValue(), types, statusCalculator, resolvedIssuers) - )); - } - } - - // Pass 2: find active replacements for recently pardoned categories - if (!pardonedCategories.isEmpty()) { - for (Punishment punishment : player.getPunishments()) { - if (!statusCalculator.isPunishmentActive(punishment) || punishment.getStarted() == null) { - continue; - } - - String category = statusCalculator.getEffectiveCategory(punishment, types); - if (category != null && pardonedCategories.contains(category)) { - pendingPunishments.add(Map.of( - "minecraftUuid", uuid, - "username", username, - "punishment", PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers) - )); - pardonedCategories.remove(category); - } - } - } - - Map playerData = player.getData(); - Object rawPending = playerData != null ? playerData.get("pendingNotifications") : null; - if (rawPending instanceof List pendingList) { - for (Object item : pendingList) { - if (!(item instanceof Map notification)) { - continue; - } - @SuppressWarnings("unchecked") - Map typedNotification = (Map) notification; - Map payload = new HashMap<>(typedNotification); - payload.put("targetPlayerUuid", uuid); - playerNotifications.add(payload); - } - } + collectPlayerPunishments(player, types, lastSync, resolvedIssuers, pendingPunishments, recentlyModifiedPunishments); + collectPlayerNotifications(player, playerNotifications); } } @@ -230,7 +101,7 @@ public Map sync( if (onlinePlayers != null) { for (OnlinePlayerInput onlinePlayer : onlinePlayers) { if (onlinePlayer.uuid() != null && onlinePlayer.ipAddress() != null) { - onlinePlayerIps.put(normalizeUuid(onlinePlayer.uuid()), onlinePlayer.ipAddress()); + onlinePlayerIps.put(UuidUtils.normalize(onlinePlayer.uuid()), onlinePlayer.ipAddress()); } } } @@ -239,58 +110,173 @@ public Map sync( submitLogs(server, chatLogs, commandLogs); - Map data = new LinkedHashMap<>(); - data.put("pendingPunishments", pendingPunishments); - data.put("recentlyStartedPunishments", List.of()); - data.put("recentlyModifiedPunishments", recentlyModifiedPunishments); - data.put("playerNotifications", playerNotifications); - data.put("staffNotifications", staffNotifications); - data.put("activeStaffMembers", activeStaffMembers); - data.put("pendingStatWipes", List.of()); - data.put("staffPermissionsUpdatedAt", server.getStaffPermissionsUpdatedAt() != null - ? server.getStaffPermissionsUpdatedAt().getTime() - : null); - data.put("punishmentTypesUpdatedAt", server.getPunishmentTypesUpdatedAt() != null - ? server.getPunishmentTypesUpdatedAt().getTime() - : null); + Long staffPermissionsUpdatedAt = server.getStaffPermissionsUpdatedAt() != null + ? server.getStaffPermissionsUpdatedAt().getTime() + : null; + Long punishmentTypesUpdatedAt = server.getPunishmentTypesUpdatedAt() != null + ? server.getPunishmentTypesUpdatedAt().getTime() + : null; + List> staff2faVerifications = collectStaff2faVerifications(server); + Map migrationTask = collectMigrationTask(server); + + applyServerStatus(server, serverStatus, serverName, clientIp, now); + + SyncDataView data = new SyncDataView( + pendingPunishments, + List.of(), + recentlyModifiedPunishments, + playerNotifications, + staffNotifications, + activeStaffMembers, + List.of(), + staffPermissionsUpdatedAt, + punishmentTypesUpdatedAt, + staff2faVerifications, + migrationTask + ); + + return new SyncResult(now.toString(), data); + } + private void collectPlayerPunishments( + Player player, + List types, + Instant lastSync, + Map resolvedIssuers, + List pendingPunishments, + List recentlyModifiedPunishments + ) { + String uuid = player.getMinecraftUuid().toString(); + String username = PlayerDataUtils.extractLatestUsername(player.getUsernames()); + + Set categoriesWithActiveStarted = new HashSet<>(); + Map oldestUnstartedPerCategory = new LinkedHashMap<>(); + Set pardonedCategories = new HashSet<>(); + + for (Punishment punishment : player.getPunishments()) { + boolean active = statusCalculator.isPunishmentActive(punishment); + String category = statusCalculator.getEffectiveCategory(punishment, types); + + boolean recentlyModified = punishment.getModifications() + .stream() + .anyMatch(mod -> mod.date() != null && mod.date().toInstant().isAfter(lastSync)); + if (recentlyModified) { + recentlyModifiedPunishments.add(new SyncPunishmentEntry( + uuid, username, + PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers))); + } + + boolean recentlyPardoned = punishment.getModifications() + .stream() + .anyMatch(mod -> mod.date() != null + && mod.date().toInstant().isAfter(lastSync) + && PunishmentModificationType.isPardon(mod.type())); + if (recentlyPardoned && category != null) { + pardonedCategories.add(category); + } + + if (punishment.getTypeOrdinal() == 0 && punishment.getStarted() == null) { + pendingPunishments.add(new SyncPunishmentEntry( + uuid, username, + PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers))); + continue; + } + + if (!active) { + continue; + } + + if (punishment.getStarted() != null + && punishment.getIssued() != null + && punishment.getIssued().toInstant().isAfter(lastSync) + && category != null) { + pendingPunishments.add(new SyncPunishmentEntry( + uuid, username, + PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers))); + } + + if (category != null && punishment.getStarted() != null) { + categoriesWithActiveStarted.add(category); + } else if (category != null && punishment.getStarted() == null) { + Punishment existing = oldestUnstartedPerCategory.get(category); + if (existing == null + || (punishment.getIssued() != null + && (existing.getIssued() == null + || punishment.getIssued().before(existing.getIssued())))) { + oldestUnstartedPerCategory.put(category, punishment); + } + } + } + + for (Map.Entry entry : oldestUnstartedPerCategory.entrySet()) { + if (!categoriesWithActiveStarted.contains(entry.getKey())) { + pendingPunishments.add(new SyncPunishmentEntry( + uuid, username, + PunishmentMapper.toSimplePunishment(entry.getValue(), types, statusCalculator, resolvedIssuers))); + } + } + + if (!pardonedCategories.isEmpty()) { + for (Punishment punishment : player.getPunishments()) { + if (!statusCalculator.isPunishmentActive(punishment) || punishment.getStarted() == null) { + continue; + } + + String category = statusCalculator.getEffectiveCategory(punishment, types); + if (category != null && pardonedCategories.contains(category)) { + pendingPunishments.add(new SyncPunishmentEntry( + uuid, username, + PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers))); + pardonedCategories.remove(category); + } + } + } + } + + private void collectPlayerNotifications(Player player, List> playerNotifications) { + String uuid = player.getMinecraftUuid().toString(); + for (Map notification : player.data().pendingNotifications()) { + Map payload = new HashMap<>(notification); + payload.put("targetPlayerUuid", uuid); + playerNotifications.add(payload); + } + } + + private List> collectStaff2faVerifications(Server server) { try { List pendingStaff = staffRepository.findWithPendingTwoFactorDelivery(server); if (!pendingStaff.isEmpty()) { - data.put("staff2faVerifications", pendingStaff.stream() + List> verifications = pendingStaff.stream() .map(staff -> Map.of("minecraftUuid", staff.getAssignedMinecraftUuid())) - .toList()); + .toList(); staffRepository.clearPendingTwoFactorDelivery(server); + return verifications; } } catch (Exception e) { log.warn("Failed to process 2FA verifications during sync", e); } + return null; + } + private Map collectMigrationTask(Server server) { try { Optional activeMigration = migrationRepository.findActiveMigration(server); - activeMigration.ifPresent(migration -> data.put("migrationTask", Map.of( - "taskId", migration.getTaskId(), - "type", migration.getType() - ))); + if (activeMigration.isPresent()) { + MigrationStatus migration = activeMigration.get(); + return Map.of( + "taskId", migration.getTaskId(), + "type", migration.getType() + ); + } } catch (Exception e) { log.warn("Failed to check active migration during sync", e); } - - applyServerStatus(server, serverStatus, serverName, clientIp, now); - - return Map.of( - "timestamp", now.toString(), - "data", data - ); + return null; } public void applyPresence(Server server, List onlinePlayers, String serverName, Instant now) { - serverRepository.updateFirst( - Query.query(Criteria.where("_id").is(server.getId())), - new Update() - .set("lastActivityAt", Date.from(now)) - .set("onlinePlayerCount", onlinePlayers != null ? (long) onlinePlayers.size() : 0L) - ); + serverActivityRepository.updateActivityAndPlayerCount(server, Date.from(now), + onlinePlayers != null ? (long) onlinePlayers.size() : 0L); playerRepository.markStalePlayersOffline(server, collectOnlineUuids(onlinePlayers), serverName, Date.from(now)); } @@ -360,41 +346,27 @@ private Set collectOnlineUuids(List onlinePlayers) { if (onlinePlayers != null) { for (OnlinePlayerInput onlinePlayer : onlinePlayers) { if (onlinePlayer.uuid() != null) { - onlineUuids.add(normalizeUuid(onlinePlayer.uuid())); + onlineUuids.add(UuidUtils.normalize(onlinePlayer.uuid())); } } } return onlineUuids; } - private List> deduplicatePendingPunishments(List> punishments) { - Map> oldestByPlayerCategory = new LinkedHashMap<>(); - List> result = new ArrayList<>(); - - for (Map entry : punishments) { - String uuid = entry.get("minecraftUuid") instanceof String value ? value : ""; - if (!(entry.get("punishment") instanceof Map rawPunishment)) { - result.add(entry); - continue; - } - - @SuppressWarnings("unchecked") - Map punishment = (Map) rawPunishment; - String category = punishment.get("category") instanceof String value ? value : null; + private List deduplicatePendingPunishments(List punishments) { + Map oldestByPlayerCategory = new LinkedHashMap<>(); + List result = new ArrayList<>(); + for (SyncPunishmentEntry entry : punishments) { + SimplePunishmentView punishment = entry.punishment(); + String category = punishment.category(); if (EnforcementCategory.BAN.name().equals(category) || EnforcementCategory.MUTE.name().equals(category)) { - String key = uuid + "|" + category; - Map existing = oldestByPlayerCategory.get(key); + String key = entry.minecraftUuid() + "|" + category; + SyncPunishmentEntry existing = oldestByPlayerCategory.get(key); if (existing == null) { oldestByPlayerCategory.put(key, entry); - } else if (existing.get("punishment") instanceof Map rawExisting) { - @SuppressWarnings("unchecked") - Map existingPunishment = (Map) rawExisting; - long existingIssued = existingPunishment.get("issuedAt") instanceof Number number ? number.longValue() : 0L; - long currentIssued = punishment.get("issuedAt") instanceof Number number ? number.longValue() : 0L; - if (currentIssued < existingIssued) { - oldestByPlayerCategory.put(key, entry); - } + } else if (punishment.issuedAt() < existing.punishment().issuedAt()) { + oldestByPlayerCategory.put(key, entry); } } else { result.add(entry); @@ -405,7 +377,7 @@ private List> deduplicatePendingPunishments(List syncV2( + public SyncResult syncV2( Server server, String lastSyncTimestamp, List onlinePlayers, @@ -414,7 +386,7 @@ public Map syncV2( List commandLogs, String clientIp ) { - Map result = sync( + SyncResult result = sync( server, lastSyncTimestamp, onlinePlayers, @@ -425,22 +397,12 @@ public Map syncV2( clientIp ); - convertUrlsToRelativePaths(result); + convertTicketUrlsInNotifications(result.data().staffNotifications()); + convertTicketUrlsInNotifications(result.data().playerNotifications()); return result; } - @SuppressWarnings("unchecked") - private void convertUrlsToRelativePaths(Map result) { - Object dataObj = result.get("data"); - if (!(dataObj instanceof Map data)) { - return; - } - - convertTicketUrlsInNotifications((List>) data.get("staffNotifications")); - convertTicketUrlsInNotifications((List>) data.get("playerNotifications")); - } - @SuppressWarnings("unchecked") private void convertTicketUrlsInNotifications(List> notifications) { if (notifications == null) { @@ -480,8 +442,4 @@ public record ChatLogInput(String uuid, String username, String message, long ti public record CommandLogInput(String uuid, String username, String command, long timestamp, String server) { } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } } diff --git a/src/main/java/gg/modl/backend/player/service/MojangApiService.java b/src/main/java/gg/modl/backend/player/service/MojangApiService.java index 8935bd7..c74511c 100644 --- a/src/main/java/gg/modl/backend/player/service/MojangApiService.java +++ b/src/main/java/gg/modl/backend/player/service/MojangApiService.java @@ -1,101 +1,103 @@ -package gg.modl.backend.player.service; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.Optional; -import java.util.UUID; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@Slf4j -public class MojangApiService { - - private final HttpClient httpClient; - private static final String PROFILE_BY_NAME_URL = "https://api.mojang.com/users/profiles/minecraft/"; - private static final String PROFILE_BY_UUID_URL = "https://sessionserver.mojang.com/session/minecraft/profile/"; - private static final Pattern NAME_PATTERN = Pattern.compile("\"name\"\\s*:\\s*\"([^\"]+)\""); - private static final Pattern ID_PATTERN = Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\""); - - public MojangApiService() { - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(5)) - .build(); - } - - public Optional lookupByUsername(String username) { - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(PROFILE_BY_NAME_URL + username)) - .timeout(Duration.ofSeconds(5)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200 || response.body() == null || response.body().isBlank()) { - return Optional.empty(); - } - - return parseProfile(response.body()); - } catch (Exception e) { - log.warn("Mojang API lookup by username '{}' failed", username, e); - return Optional.empty(); - } - } - - private Optional parseProfile(String json) { - Matcher nameMatcher = NAME_PATTERN.matcher(json); - Matcher idMatcher = ID_PATTERN.matcher(json); - - if (!nameMatcher.find() || !idMatcher.find()) { - return Optional.empty(); - } - - String name = nameMatcher.group(1); - String rawId = idMatcher.group(1); - UUID uuid = fromDashlessUuid(rawId); - - return Optional.of(new MojangProfile(name, uuid)); - } - - private static UUID fromDashlessUuid(String id) { - if (id.contains("-")) { - return UUID.fromString(id); - } - String withDashes = id.replaceFirst( - "(\\w{8})(\\w{4})(\\w{4})(\\w{4})(\\w{12})", - "$1-$2-$3-$4-$5" - ); - return UUID.fromString(withDashes); - } - - public Optional lookupByUuid(String uuid) { - try { - String dashlessUuid = uuid.replace("-", ""); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(PROFILE_BY_UUID_URL + dashlessUuid)) - .timeout(Duration.ofSeconds(5)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200 || response.body() == null || response.body().isBlank()) { - return Optional.empty(); - } - - return parseProfile(response.body()); - } catch (Exception e) { - log.warn("Mojang API lookup by UUID '{}' failed", uuid, e); - return Optional.empty(); - } - } - - public record MojangProfile(String name, UUID uuid) {} -} +package gg.modl.backend.player.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class MojangApiService { + + private final HttpClient httpClient; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String PROFILE_BY_NAME_URL = "https://api.mojang.com/users/profiles/minecraft/"; + private static final String PROFILE_BY_UUID_URL = "https://sessionserver.mojang.com/session/minecraft/profile/"; + + public MojangApiService() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + } + + public Optional lookupByUsername(String username) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(PROFILE_BY_NAME_URL + username)) + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200 || response.body() == null || response.body().isBlank()) { + return Optional.empty(); + } + + return parseProfile(response.body()); + } catch (Exception e) { + log.warn("Mojang API lookup by username '{}' failed", username, e); + return Optional.empty(); + } + } + + static Optional parseProfile(String json) { + JsonNode root; + try { + root = OBJECT_MAPPER.readTree(json); + } catch (JsonProcessingException e) { + return Optional.empty(); + } + + JsonNode nameNode = root.get("name"); + JsonNode idNode = root.get("id"); + if (nameNode == null || !nameNode.isTextual() || idNode == null || !idNode.isTextual()) { + return Optional.empty(); + } + + return Optional.of(new MojangProfile(nameNode.asText(), fromDashlessUuid(idNode.asText()))); + } + + private static UUID fromDashlessUuid(String id) { + if (id.contains("-")) { + return UUID.fromString(id); + } + String withDashes = id.replaceFirst( + "(\\w{8})(\\w{4})(\\w{4})(\\w{4})(\\w{12})", + "$1-$2-$3-$4-$5" + ); + return UUID.fromString(withDashes); + } + + public Optional lookupByUuid(String uuid) { + try { + String dashlessUuid = uuid.replace("-", ""); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(PROFILE_BY_UUID_URL + dashlessUuid)) + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200 || response.body() == null || response.body().isBlank()) { + return Optional.empty(); + } + + return parseProfile(response.body()); + } catch (Exception e) { + log.warn("Mojang API lookup by UUID '{}' failed", uuid, e); + return Optional.empty(); + } + } + + public record MojangProfile(String name, UUID uuid) {} +} diff --git a/src/main/java/gg/modl/backend/player/service/PlayerLookupService.java b/src/main/java/gg/modl/backend/player/service/PlayerLookupService.java index 6d7dc29..fa93379 100644 --- a/src/main/java/gg/modl/backend/player/service/PlayerLookupService.java +++ b/src/main/java/gg/modl/backend/player/service/PlayerLookupService.java @@ -1,34 +1,35 @@ package gg.modl.backend.player.service; import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; import gg.modl.backend.infrastructure.util.PaginationHelper; +import gg.modl.backend.infrastructure.util.UuidUtils; import gg.modl.backend.player.PlayerService; -import gg.modl.backend.player.service.PlayerDataUtils; import gg.modl.backend.player.data.IPEntry; import gg.modl.backend.player.data.NoteEntry; import gg.modl.backend.player.data.Player; import gg.modl.backend.player.data.UsernameEntry; import gg.modl.backend.player.data.punishment.EnforcementCategory; import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.dto.response.LinkedAccountsResult; +import gg.modl.backend.player.dto.response.PlayerFetchResult; +import gg.modl.backend.player.dto.response.PlayerLookupResult; +import gg.modl.backend.player.dto.response.PlayerProfileResult; +import gg.modl.backend.player.dto.response.PunishmentView; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.PunishmentType; import gg.modl.backend.settings.service.PunishmentTypeIndex; import gg.modl.backend.settings.service.PunishmentTypeService; import java.util.ArrayList; -import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @Service @@ -40,75 +41,58 @@ public class PlayerLookupService { private final MojangApiService mojangApiService; private final PlayerStatusCalculator statusCalculator; private final IssuerNameResolver issuerNameResolver; - private final StaffMongoRepository staffRepository; private final PlayerService playerService; - public MinecraftPlayerService.ServiceResponse getPlayerByUuid(Server server, String uuid, Integer punishmentLimit, Integer noteLimit) { + public PlayerProfileResult getPlayerByUuid(Server server, String uuid, Integer punishmentLimit, Integer noteLimit) { Player player = findPlayerByUuid(server, uuid).orElse(null); if (player == null) { - return notFound("Player not found"); + return new PlayerProfileResult.NotFound("Player not found"); } List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); - return ok(Map.of("status", 200, "profile", toPlayerProfile(server, player, punishmentTypes, punishmentLimit, noteLimit))); + return new PlayerProfileResult.Found(toPlayerProfile(server, player, punishmentTypes, punishmentLimit, noteLimit)); } - public MinecraftPlayerService.ServiceResponse getPlayerByMinecraftUuid(Server server, String minecraftUuid, boolean queryMojang) { + public PlayerFetchResult getPlayerByMinecraftUuid(Server server, String minecraftUuid, boolean queryMojang) { if (minecraftUuid == null || minecraftUuid.isBlank()) { - return badRequest("minecraftUuid parameter required"); + return new PlayerFetchResult.InvalidRequest("minecraftUuid parameter required"); } Player player = findPlayerByUuid(server, minecraftUuid).orElse(null); if (player == null && queryMojang) { Optional profile = mojangApiService.lookupByUuid(minecraftUuid); if (profile.isPresent()) { - return ok(Map.of( - "status", 200, - "message", "Player found via Mojang", - "player", toMojangProfile(profile.get()) - )); + return new PlayerFetchResult.Found("Player found via Mojang", toMojangProfile(profile.get())); } } if (player == null) { - return notFound("Player not found"); + return new PlayerFetchResult.NotFound("Player not found"); } List types = punishmentTypeService.getPunishmentTypes(server); - return ok(Map.of( - "status", 200, - "message", "Player found", - "player", toPlayerProfile(server, player, types) - )); + return new PlayerFetchResult.Found("Player found", toPlayerProfile(server, player, types)); } - public MinecraftPlayerService.ServiceResponse getPlayerByUsername(Server server, String username, boolean queryMojang) { + public PlayerFetchResult getPlayerByUsername(Server server, String username, boolean queryMojang) { Player player = findByUsername(server, username).orElse(null); if (player == null && queryMojang) { Optional profile = mojangApiService.lookupByUsername(username); if (profile.isPresent()) { - return ok(Map.of( - "status", 200, - "message", "Player found via Mojang", - "player", toMojangProfile(profile.get()) - )); + return new PlayerFetchResult.Found("Player found via Mojang", toMojangProfile(profile.get())); } } if (player == null) { - return notFound("Player not found"); + return new PlayerFetchResult.NotFound("Player not found"); } List types = punishmentTypeService.getPunishmentTypes(server); - return ok(Map.of( - "status", 200, - "message", "Player found", - "player", toPlayerProfile(server, player, types) - )); + return new PlayerFetchResult.Found("Player found", toPlayerProfile(server, player, types)); } - public MinecraftPlayerService.ServiceResponse lookupPlayer(Server server, String query, boolean shouldQueryMojang) { - boolean isUuid = query.contains("-") && query.length() == 36; + public PlayerLookupResult lookupPlayer(Server server, String query, boolean shouldQueryMojang) { + boolean isUuid = looksLikeUuid(query); Player player = isUuid ? findPlayerByUuid(server, query).orElse(null) : findByUsername(server, query).orElse(null); @@ -135,28 +119,20 @@ public MinecraftPlayerService.ServiceResponse lookupPlayer(Server server, String "warnings", 0 )); lookupData.put("recentPunishments", List.of()); - return ok(Map.of( - "status", 200, - "message", "Player found via Mojang", - "data", lookupData - )); + return new PlayerLookupResult.Found("Player found via Mojang", lookupData); } } if (player == null) { - return notFound("Player not found"); + return new PlayerLookupResult.NotFound("Player not found"); } List types = punishmentTypeService.getPunishmentTypes(server); - return ok(Map.of( - "status", 200, - "message", "Player found", - "data", buildLookupResponse(server, player, types) - )); + return new PlayerLookupResult.Found("Player found", buildLookupResponse(server, player, types)); } - public MinecraftPlayerService.ServiceResponse lookupProfile(Server server, String query, boolean shouldQueryMojang, Integer punishmentLimit, Integer noteLimit) { - boolean isUuid = query.contains("-") && query.length() == 36; + public PlayerProfileResult lookupProfile(Server server, String query, boolean shouldQueryMojang, Integer punishmentLimit, Integer noteLimit) { + boolean isUuid = looksLikeUuid(query); Player player = isUuid ? findPlayerByUuid(server, query).orElse(null) : findByUsername(server, query).orElse(null); @@ -174,11 +150,11 @@ public MinecraftPlayerService.ServiceResponse lookupProfile(Server server, Strin } if (player == null) { - return notFound("Player not found"); + return new PlayerProfileResult.NotFound("Player not found"); } List types = punishmentTypeService.getPunishmentTypes(server); - return ok(Map.of("status", 200, "profile", toPlayerProfile(server, player, types, punishmentLimit, noteLimit))); + return new PlayerProfileResult.Found(toPlayerProfile(server, player, types, punishmentLimit, noteLimit)); } Map toPlayerProfile(Server server, Player player, List punishmentTypes) { @@ -187,7 +163,7 @@ Map toPlayerProfile(Server server, Player player, List toPlayerProfile(Server server, Player player, List punishmentTypes, Integer punishmentLimit, Integer noteLimit) { return toPlayerProfile(server, player, punishmentTypes, punishmentLimit, noteLimit, - resolveIssuersForPunishments(server, safePunishments(player))); + issuerNameResolver.resolveForPunishments(server, safePunishments(player))); } Map toPlayerProfile(Server server, Player player, List punishmentTypes, Integer punishmentLimit, Integer noteLimit, Map resolvedIssuers) { @@ -232,11 +208,11 @@ Map toPlayerProfile(Server server, Player player, List allPunishments = safePunishments(player); List limitedPunishments = limitNewestFirst(allPunishments, punishmentLimit, Comparator.comparing(Punishment::getIssued, Comparator.nullsLast(Comparator.naturalOrder())).reversed()); - List> punishments = limitedPunishments.stream() - .map(punishment -> PunishmentMapper.toPunishmentMap(punishment, punishmentTypes, resolvedIssuers)) + List punishments = limitedPunishments.stream() + .map(punishment -> PunishmentMapper.toPunishmentView(punishment, punishmentTypes, resolvedIssuers)) .toList(); - List> pendingNotifications = extractPendingNotifications(player); + List> pendingNotifications = player.data().pendingNotifications(); Map profile = new LinkedHashMap<>(); profile.put("id", player.getId()); @@ -246,7 +222,7 @@ Map toPlayerProfile(Server server, Player player, List buildLookupResponse(Server server, Player player, Li String currentUsername = PlayerDataUtils.extractLatestUsername(safeUsernames(player)); List previousUsernames = player.getUsernames() .stream() - .map(username -> username.username()) + .map(UsernameEntry::username) .skip(1) .toList(); @@ -322,7 +298,7 @@ private Map buildLookupResponse(Server server, Player player, Li data.put("previousUsernames", previousUsernames); data.put("firstSeen", firstSeen); data.put("lastSeen", lastSeen); - data.put("isOnline", player.getData().getOrDefault("isOnline", false)); + data.put("isOnline", player.data().isOnline()); data.put("punishmentStats", Map.of( "totalPunishments", totalPunishments, "activePunishments", activePunishments, @@ -350,32 +326,15 @@ private Map toMojangProfile(MojangApiService.MojangProfile profi } private Optional findPlayerByUuid(Server server, String uuid) { - return playerRepository.findByMinecraftUuid(server, normalizeUuid(uuid)); + return playerRepository.findByMinecraftUuid(server, UuidUtils.normalize(uuid)); } private Optional findByUsername(Server server, String username) { return playerService.findBestByUsername(server, username); } - private Map resolveIssuersForPlayers(Server server, List players) { - Set ids = new HashSet<>(); - for (Player player : players) { - for (Punishment punishment : safePunishments(player)) { - ids.addAll(PunishmentQueryService.collectIssuerIds(punishment)); - } - } - return ids.isEmpty() ? Map.of() : issuerNameResolver.batchResolve(ids, server); - } - - private Map resolveIssuersForPunishments(Server server, List punishments) { - Set ids = new HashSet<>(); - for (Punishment p : punishments) { - ids.addAll(PunishmentQueryService.collectIssuerIds(p)); - } - if (ids.isEmpty()) { - return Map.of(); - } - return issuerNameResolver.batchResolve(ids, server); + private boolean looksLikeUuid(String query) { + return query.contains("-") && query.length() == 36; } private List safeUsernames(Player player) { @@ -404,61 +363,25 @@ private List limitNewestFirst(List values, Integer limit, Comparator> extractPendingNotifications(Player player) { - if (player.getData() == null) { - return List.of(); - } - Object rawPending = player.getData().getOrDefault("pendingNotifications", Collections.emptyList()); - if (!(rawPending instanceof List list)) { - return List.of(); + public LinkedAccountsResult getLinkedAccounts(Server server, String uuid, Integer page, Integer limit) { + LinkedAccountsResult fullResult = getLinkedAccountsFull(server, uuid); + if (!(fullResult instanceof LinkedAccountsResult.Found found) || page == null || limit == null) { + return fullResult; } - return list.stream() - .filter(entry -> entry instanceof Map) - .map(entry -> { - @SuppressWarnings("unchecked") - Map notification = (Map) entry; - return notification; - }) - .toList(); + PaginationHelper.PageResult> result = PaginationHelper.paginate(found.linkedAccounts(), page, limit); + return new LinkedAccountsResult.Found(result.items(), result.totalCount(), result.page(), result.hasMore()); } - private MinecraftPlayerService.ServiceResponse ok(Map body) { - return new MinecraftPlayerService.ServiceResponse(HttpStatus.OK, body); - } - - private MinecraftPlayerService.ServiceResponse notFound(String message) { - return new MinecraftPlayerService.ServiceResponse(HttpStatus.NOT_FOUND, Map.of("status", 404, "message", message)); - } - - public MinecraftPlayerService.ServiceResponse getLinkedAccounts(Server server, String uuid, Integer page, Integer limit) { - MinecraftPlayerService.ServiceResponse fullResponse = getLinkedAccountsFull(server, uuid); - if (fullResponse.status() != HttpStatus.OK || page == null || limit == null) { - return fullResponse; - } - - @SuppressWarnings("unchecked") - List> allAccounts = (List>) fullResponse.body().get("linkedAccounts"); - PaginationHelper.PageResult> result = PaginationHelper.paginate(allAccounts, page, limit); - - Map response = new LinkedHashMap<>(); - response.put("status", 200); - response.put("linkedAccounts", result.items()); - response.put("totalCount", result.totalCount()); - response.put("page", result.page()); - response.put("hasMore", result.hasMore()); - return ok(response); - } - - private MinecraftPlayerService.ServiceResponse getLinkedAccountsFull(Server server, String uuid) { + private LinkedAccountsResult getLinkedAccountsFull(Server server, String uuid) { Player player = findPlayerByUuid(server, uuid).orElse(null); if (player == null) { - return notFound("Player not found"); + return new LinkedAccountsResult.NotFound("Player not found"); } List ips = safeIpAddresses(player).stream() - .map(ip -> ip.getIpAddress()) + .map(IPEntry::getIpAddress) .toList(); List types = punishmentTypeService.getPunishmentTypes(server); @@ -466,39 +389,25 @@ private MinecraftPlayerService.ServiceResponse getLinkedAccountsFull(Server serv List linkedPlayers = new ArrayList<>(); if (!ips.isEmpty()) { - for (Player related : playerRepository.findByIpAddressesExcludingUuid(server, ips, normalizeUuid(uuid), 20)) { + for (Player related : playerRepository.findByIpAddressesExcludingUuid(server, ips, UuidUtils.normalize(uuid), 20)) { linkedPlayers.add(related); addedUuids.add(related.getMinecraftUuid().toString()); } } - if (player.getData() != null && player.getData().containsKey("linkedAccounts")) { - Object rawLinked = player.getData().get("linkedAccounts"); - List storedLinkedUuids = rawLinked instanceof List list - ? list.stream().filter(String.class::isInstance).map(String.class::cast).toList() - : List.of(); - - List missingUuids = storedLinkedUuids.stream() - .filter(linkedUuid -> !addedUuids.contains(linkedUuid)) - .toList(); - if (!missingUuids.isEmpty()) { - linkedPlayers.addAll(playerRepository.findByMinecraftUuids(server, missingUuids)); - } + List storedLinkedUuids = player.data().linkedAccountUuids(); + List missingUuids = storedLinkedUuids.stream() + .filter(linkedUuid -> !addedUuids.contains(linkedUuid)) + .toList(); + if (!missingUuids.isEmpty()) { + linkedPlayers.addAll(playerRepository.findByMinecraftUuids(server, missingUuids)); } - Map resolvedIssuers = resolveIssuersForPlayers(server, linkedPlayers); + Map resolvedIssuers = issuerNameResolver.resolveForPlayers(server, linkedPlayers); List> linkedAccounts = linkedPlayers.stream() .map(linked -> toPlayerProfile(server, linked, types, null, null, resolvedIssuers)) .toList(); - return ok(Map.of("status", 200, "linkedAccounts", linkedAccounts)); - } - - private MinecraftPlayerService.ServiceResponse badRequest(String message) { - return new MinecraftPlayerService.ServiceResponse(HttpStatus.BAD_REQUEST, Map.of("status", 400, "message", message)); - } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); + return new LinkedAccountsResult.Found(linkedAccounts, null, null, null); } } diff --git a/src/main/java/gg/modl/backend/player/service/PlayerStatusCalculator.java b/src/main/java/gg/modl/backend/player/service/PlayerStatusCalculator.java index 6b3d291..20a62c2 100644 --- a/src/main/java/gg/modl/backend/player/service/PlayerStatusCalculator.java +++ b/src/main/java/gg/modl/backend/player/service/PlayerStatusCalculator.java @@ -2,7 +2,7 @@ import gg.modl.backend.player.data.punishment.EnforcementCategory; import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentData; +import gg.modl.backend.player.data.punishment.PunishmentDataView; import gg.modl.backend.player.data.punishment.PunishmentModification; import gg.modl.backend.player.data.punishment.PunishmentModificationType; import gg.modl.backend.player.data.punishment.PunishmentStatus; @@ -16,8 +16,8 @@ import gg.modl.backend.settings.service.PunishmentTypeService; import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.Map; -import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -46,8 +46,7 @@ public PlayerStatus calculateStatus(Server server, List punishments) } int typeOrdinal = punishment.getTypeOrdinal(); - Map data = punishment.getData(); - String severity = PunishmentData.getSeverity(data); + String severity = punishment.data().severity(); PunishmentType type = typesByOrdinal.get(typeOrdinal); if (type == null) { @@ -91,30 +90,22 @@ private boolean isPunishmentEligible(Punishment punishment) { return false; } - Map data = punishment.getData(); - if (data == null) { + PunishmentDataView data = punishment.data(); + if (data.asMap() == null) { return false; } - if (PunishmentStatus.UNSTARTED.equals(PunishmentData.getStatus(data))) { + if (data.isUnstarted()) { return false; } - return !isPardoned(punishment); + return !punishment.isPardoned(); } - private boolean isPardoned(Punishment punishment) { - for (PunishmentModification mod : punishment.getModifications()) { - if (PunishmentModificationType.isPardon(mod.type())) { - return true; - } - } - return false; - } public Date getEffectiveExpiry(Punishment punishment) { - Map data = punishment.getData(); - if (data == null) { + PunishmentDataView data = punishment.data(); + if (data.asMap() == null) { return null; } @@ -133,49 +124,29 @@ public Date getEffectiveExpiry(Punishment punishment) { return new Date(latestDurationChange.date().getTime() + eff); } - Long duration = PunishmentData.getDuration(data); - // null, 0, or negative (-1L) indicates permanent (no expiry) + Long duration = data.duration(); if (duration == null || duration <= 0) { return null; } - // Count from started date, or current time if not yet started - // (unstarted punishments use current time so the plugin receives a proper - // expiration for display — nothing is persisted until the plugin acknowledges) Date baseDate = punishment.getStarted() != null ? punishment.getStarted() : new Date(); return new Date(baseDate.getTime() + duration); } - private Optional findTypeByOrdinal(Map typesByOrdinal, int ordinal) { - return Optional.ofNullable(typesByOrdinal.get(ordinal)); - } - - /** - * Determine the effective enforcement category for a punishment. - * Core types use isBan()/isMute()/isKick(). - * Social/gameplay types use the DurationDetail for the stored severity and offense level. - * - * @return EnforcementCategory name, or null (for kicks and unknown types) - */ public String getEffectiveCategory(Punishment punishment, List types) { return getEffectiveCategory(punishment, PunishmentTypeIndex.byOrdinal(types)); } public String getEffectiveCategory(Punishment punishment, Map typesByOrdinal) { PunishmentType pt = typesByOrdinal.get(punishment.getTypeOrdinal()); - return getEffectiveCategory(pt, punishment.getData()); + return getEffectiveCategory(pt, punishment.data()); } - /** - * Determine the effective enforcement category for a punishment type with given data. - * - * @return EnforcementCategory name, or null - */ - public String getEffectiveCategory(PunishmentType pt, Map data) { + public String getEffectiveCategory(PunishmentType pt, PunishmentDataView data) { if (pt == null) { return null; } - String storedCategory = PunishmentData.getEnforcementCategory(data); + String storedCategory = data.enforcementCategory(); if (storedCategory != null) { return storedCategory; } @@ -189,21 +160,20 @@ public String getEffectiveCategory(PunishmentType pt, Map data) return EnforcementCategory.MUTE.name(); } - // Social/gameplay types: determine from DurationDetail - if (data != null) { - String severity = PunishmentData.getSeverity(data) != null ? PunishmentData.getSeverity(data) : "regular"; + if (data.asMap() != null) { + String severity = data.severity() != null ? data.severity() : "regular"; String offenseLevel; - String rawOffenseLevel = PunishmentData.getOffenseLevel(data); + String rawOffenseLevel = data.offenseLevel(); if (rawOffenseLevel != null) { offenseLevel = rawOffenseLevel; } else { - String status = PunishmentData.getStatus(data); + String status = data.status(); if (status == null || PunishmentStatus.UNSTARTED.equals(status) || PunishmentStatus.PARDONED.equals(status)) { offenseLevel = "first"; } else { - offenseLevel = switch (status.toLowerCase()) { + offenseLevel = switch (status.toLowerCase(Locale.ROOT)) { case "low" -> "first"; case "medium" -> "medium"; case "habitual" -> "habitual"; @@ -224,32 +194,24 @@ public String getEffectiveCategory(PunishmentType pt, Map data) return null; } - /** - * Returns true when a punishment has naturally expired (not pardoned, not permanent, not a kick). - * Used to detect punishments eligible for stat-wipe execution. - */ public boolean isPunishmentNaturallyExpired(Punishment punishment) { - // Must have been started if (punishment.getStarted() == null) { return false; } - // Kicks are instant, not expirable if (punishment.getTypeOrdinal() == 0) { return false; } - if (isPardoned(punishment)) { + if (punishment.isPardoned()) { return false; } - // Must have a finite expiry (not permanent) Date effectiveExpiry = getEffectiveExpiry(punishment); if (effectiveExpiry == null) { return false; } - // Must have expired return effectiveExpiry.before(new Date()); } diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentDurationCalculator.java b/src/main/java/gg/modl/backend/player/service/PunishmentDurationCalculator.java index c960629..e28f7d5 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentDurationCalculator.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentDurationCalculator.java @@ -46,10 +46,7 @@ public DurationResult calculate(Server server, List existingPunishme DurationDetail durationDetail = PunishmentDurationResolver.resolveDetail(punishmentType, internalSeverity, offenseLevel); - String displayStatus = switch (offenseLevel) { - case "first" -> "low"; - default -> offenseLevel; - }; + String displayStatus = PunishmentMapper.offenseDisplayStatus(offenseLevel); Long calculatedDuration = null; if (durationDetail != null) { diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentEvidenceService.java b/src/main/java/gg/modl/backend/player/service/PunishmentEvidenceService.java index 4e9ebac..c39dd67 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentEvidenceService.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentEvidenceService.java @@ -1,169 +1,165 @@ -package gg.modl.backend.player.service; - -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.infrastructure.validation.SafeUrls; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentEvidence; -import gg.modl.backend.player.data.punishment.PunishmentNote; -import gg.modl.backend.player.dto.request.AddEvidenceRequest; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentContext; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; -import gg.modl.backend.player.service.PunishmentQueryService.UploadedEvidenceItem; -import gg.modl.backend.server.data.Server; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import gg.modl.backend.infrastructure.util.IdGenerator; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PunishmentEvidenceService { - private final PlayerMongoRepository playerRepository; - private final PunishmentMongoRepository punishmentRepository; - private final PunishmentQueryService punishmentQueryService; - private final PunishmentRealtimePublisher realtimePublisher; - - public PunishmentOperationResult addEvidence(Server server, String punishmentId, String evidenceUrl, String issuerName, String issuerId) { - SafeUrls.requireSafe(evidenceUrl, "Invalid evidence URL"); - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); - } - - String resolvedIssuerName = issuerId != null ? null : issuerName; - Date now = new Date(); - PunishmentEvidence evidence = new PunishmentEvidence( - null, - evidenceUrl, - "url", - resolvedIssuerName, - issuerId, - now, - null, - null, - null - ); - PunishmentNote note = new PunishmentNote( - IdGenerator.generateShortId(), - "added evidence", - now, - resolvedIssuerName, - issuerId - ); - context.punishment().getEvidence().add(evidence); - context.punishment().getNotes().add(note); - punishmentRepository.appendEvidence(server, context.player().getMinecraftUuid().toString(), punishmentId, - List.of(evidence), note); - - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Evidence added", true, 1); - } - - public Player addEvidence(Server server, UUID playerUuid, String punishmentId, AddEvidenceRequest request) { - SafeUrls.requireSafe(request.url(), "Invalid evidence URL"); - Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) - .orElseThrow(() -> new ResourceNotFoundException("Player not found")); - - Punishment punishment = findPunishment(player, punishmentId); - if (punishment == null) { - throw new ResourceNotFoundException("Punishment not found"); - } - - String evIssuerId = request.issuerId(); - String evIssuerName = evIssuerId != null ? null : (request.issuerName() != null ? request.issuerName() : "System"); - - PunishmentEvidence evidence = new PunishmentEvidence( - request.text(), - request.url(), - request.type(), - evIssuerName, - evIssuerId, - new Date(), - request.fileName(), - request.fileType(), - request.fileSize() - ); - punishment.getEvidence().add(evidence); - punishmentRepository.appendEvidence(server, player.getMinecraftUuid().toString(), punishmentId, List.of(evidence), null); - realtimePublisher.punishmentDetailsChanged(server, player, punishment); - return player; - } - - private Punishment findPunishment(Player player, String punishmentId) { - return PunishmentQueryService.findPunishment(player, punishmentId); - } - - public PunishmentOperationResult addUploadedEvidence(Server server, String punishmentId, String issuerName, String issuerId, List evidenceItems) { - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); - } - - String resolvedIssuerName = issuerId != null ? null : issuerName; - Date now = new Date(); - List evidenceList = new ArrayList<>(); - for (UploadedEvidenceItem evidenceItem : evidenceItems) { - PunishmentEvidence evidence = new PunishmentEvidence( - null, - evidenceItem.url(), - "file", - resolvedIssuerName, - issuerId, - now, - evidenceItem.fileName(), - evidenceItem.fileType(), - evidenceItem.fileSize() - ); - evidenceList.add(evidence); - context.punishment().getEvidence().add(evidence); - } - PunishmentNote note = new PunishmentNote( - IdGenerator.generateShortId(), - "uploaded " + evidenceItems.size() + " evidence file(s)", - now, - resolvedIssuerName, - issuerId - ); - context.punishment().getNotes().add(note); - punishmentRepository.appendEvidence(server, context.player().getMinecraftUuid().toString(), punishmentId, evidenceList, note); - - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Evidence uploaded successfully", true, evidenceItems.size()); - } - - public PunishmentOperationResult addPunishmentNote(Server server, String punishmentId, String text, String issuerName, String issuerId) { - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); - } - - String resolvedIssuerName = issuerId != null ? null : issuerName; - PunishmentNote note = new PunishmentNote(IdGenerator.generateShortId(), text, new Date(), resolvedIssuerName, issuerId); - context.punishment().getNotes().add(note); - punishmentRepository.addPunishmentNote(server, context.player().getMinecraftUuid().toString(), punishmentId, note, null); - - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Note added", true, 1); - } - - public Player addPunishmentNote(Server server, UUID playerUuid, String punishmentId, String text, String issuerName, String issuerId) { - Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) - .orElseThrow(() -> new ResourceNotFoundException("Player not found")); - - Punishment punishment = findPunishment(player, punishmentId); - if (punishment == null) { - throw new ResourceNotFoundException("Punishment not found"); - } - - String resolvedIssuerName = issuerId != null ? null : issuerName; - PunishmentNote note = new PunishmentNote(IdGenerator.generateShortId(), text, new Date(), resolvedIssuerName, issuerId); - punishment.getNotes().add(note); - punishmentRepository.addPunishmentNote(server, player.getMinecraftUuid().toString(), punishmentId, note, null); - realtimePublisher.punishmentDetailsChanged(server, player, punishment); - return player; - } -} +package gg.modl.backend.player.service; + +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.validation.SafeUrls; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentEvidence; +import gg.modl.backend.player.data.punishment.PunishmentNote; +import gg.modl.backend.player.dto.request.AddEvidenceRequest; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentContext; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; +import gg.modl.backend.player.service.PunishmentQueryService.UploadedEvidenceItem; +import gg.modl.backend.server.data.Server; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import gg.modl.backend.infrastructure.util.IdGenerator; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PunishmentEvidenceService { + private final PlayerMongoRepository playerRepository; + private final PunishmentMongoRepository punishmentRepository; + private final PunishmentQueryService punishmentQueryService; + private final PunishmentRealtimePublisher realtimePublisher; + + public PunishmentOperationResult addEvidence(Server server, String punishmentId, String evidenceUrl, String issuerName, String issuerId) { + SafeUrls.requireSafe(evidenceUrl, "Invalid evidence URL"); + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); + } + + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, issuerName); + Date now = new Date(); + PunishmentEvidence evidence = new PunishmentEvidence( + null, + evidenceUrl, + "url", + resolvedIssuerName, + issuerId, + now, + null, + null, + null + ); + PunishmentNote note = new PunishmentNote( + IdGenerator.generateShortId(), + "added evidence", + now, + resolvedIssuerName, + issuerId + ); + context.punishment().getEvidence().add(evidence); + context.punishment().getNotes().add(note); + punishmentRepository.appendEvidence(server, context.player().getMinecraftUuid().toString(), punishmentId, + List.of(evidence), note); + + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Evidence added", true, 1); + } + + public Player addEvidence(Server server, UUID playerUuid, String punishmentId, AddEvidenceRequest request) { + SafeUrls.requireSafe(request.url(), "Invalid evidence URL"); + Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) + .orElseThrow(() -> new ResourceNotFoundException("Player not found")); + + Punishment punishment = PunishmentQueryService.findPunishment(player, punishmentId); + if (punishment == null) { + throw new ResourceNotFoundException("Punishment not found"); + } + + String evIssuerId = request.issuerId(); + String evIssuerName = PunishmentMapper.storedName(evIssuerId, request.issuerName() != null ? request.issuerName() : "System"); + + PunishmentEvidence evidence = new PunishmentEvidence( + request.text(), + request.url(), + request.type(), + evIssuerName, + evIssuerId, + new Date(), + request.fileName(), + request.fileType(), + request.fileSize() + ); + punishment.getEvidence().add(evidence); + punishmentRepository.appendEvidence(server, player.getMinecraftUuid().toString(), punishmentId, List.of(evidence), null); + realtimePublisher.punishmentDetailsChanged(server, player, punishment); + return player; + } + + public PunishmentOperationResult addUploadedEvidence(Server server, String punishmentId, String issuerName, String issuerId, List evidenceItems) { + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); + } + + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, issuerName); + Date now = new Date(); + List evidenceList = new ArrayList<>(); + for (UploadedEvidenceItem evidenceItem : evidenceItems) { + PunishmentEvidence evidence = new PunishmentEvidence( + null, + evidenceItem.url(), + "file", + resolvedIssuerName, + issuerId, + now, + evidenceItem.fileName(), + evidenceItem.fileType(), + evidenceItem.fileSize() + ); + evidenceList.add(evidence); + context.punishment().getEvidence().add(evidence); + } + PunishmentNote note = new PunishmentNote( + IdGenerator.generateShortId(), + "uploaded " + evidenceItems.size() + " evidence file(s)", + now, + resolvedIssuerName, + issuerId + ); + context.punishment().getNotes().add(note); + punishmentRepository.appendEvidence(server, context.player().getMinecraftUuid().toString(), punishmentId, evidenceList, note); + + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Evidence uploaded successfully", true, evidenceItems.size()); + } + + public PunishmentOperationResult addPunishmentNote(Server server, String punishmentId, String text, String issuerName, String issuerId) { + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); + } + + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, issuerName); + PunishmentNote note = new PunishmentNote(IdGenerator.generateShortId(), text, new Date(), resolvedIssuerName, issuerId); + context.punishment().getNotes().add(note); + punishmentRepository.addPunishmentNote(server, context.player().getMinecraftUuid().toString(), punishmentId, note, null); + + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Note added", true, 1); + } + + public Player addPunishmentNote(Server server, UUID playerUuid, String punishmentId, String text, String issuerName, String issuerId) { + Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) + .orElseThrow(() -> new ResourceNotFoundException("Player not found")); + + Punishment punishment = PunishmentQueryService.findPunishment(player, punishmentId); + if (punishment == null) { + throw new ResourceNotFoundException("Punishment not found"); + } + + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, issuerName); + PunishmentNote note = new PunishmentNote(IdGenerator.generateShortId(), text, new Date(), resolvedIssuerName, issuerId); + punishment.getNotes().add(note); + punishmentRepository.addPunishmentNote(server, player.getMinecraftUuid().toString(), punishmentId, note, null); + realtimePublisher.punishmentDetailsChanged(server, player, punishment); + return player; + } +} diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentLifecycleService.java b/src/main/java/gg/modl/backend/player/service/PunishmentLifecycleService.java index 5fa321f..78298bf 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentLifecycleService.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentLifecycleService.java @@ -5,10 +5,11 @@ import gg.modl.backend.database.mongo.repository.StaffMongoRepository; import gg.modl.backend.ticket.service.TicketService; import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.player.controller.MinecraftPunishmentController.MinecraftCreatePunishmentRequest; +import gg.modl.backend.player.dto.request.MinecraftCreatePunishmentRequest; import gg.modl.backend.player.data.Player; import gg.modl.backend.player.data.punishment.Punishment; import gg.modl.backend.player.data.punishment.PunishmentData; +import gg.modl.backend.player.data.punishment.PunishmentDataView; import gg.modl.backend.player.data.punishment.PunishmentEvidence; import gg.modl.backend.player.data.punishment.PunishmentModification; import gg.modl.backend.player.data.punishment.PunishmentModificationType; @@ -68,19 +69,21 @@ public class PunishmentLifecycleService { private final PunishmentRealtimePublisher realtimePublisher; private final LogService logService; + private static final String SYSTEM_ISSUER = "System"; + private static final Set CLIENT_SETTABLE_PUNISHMENT_DATA_KEYS = Set.of( - "altBlocking", - "wipeAfterExpiry", + PunishmentData.ALT_BLOCKING, + PunishmentData.WIPE_AFTER_EXPIRY, "banLinkedAccounts", "kickSameIP", "silent", - "linkedBanId", - "severity", - "status", - "duration", - "reason", + PunishmentData.LINKED_BAN_ID, + PunishmentData.SEVERITY, + PunishmentData.STATUS, + PunishmentData.DURATION, + PunishmentData.REASON, "aiGenerated", - "pendingAcknowledgement" + PunishmentData.PENDING_ACKNOWLEDGEMENT ); private Map filterClientSettableData(Map requestData) { @@ -144,7 +147,7 @@ public String createMinecraftPunishment(Server server, MinecraftCreatePunishment } Map data = request.data() != null ? MongoKeyUtils.sanitizeKeys(new HashMap<>(request.data())) : new HashMap<>(); - data.put("pendingAcknowledgement", true); + PunishmentDataView.ofMap(data).setPendingAcknowledgement(true); CreatePunishmentRequest serviceRequest = new CreatePunishmentRequest( request.issuerName(), @@ -172,12 +175,13 @@ public String createPunishment(Server server, UUID playerUuid, CreatePunishmentR } Date now = new Date(); Map data = filterClientSettableData(request.data()); + PunishmentDataView view = PunishmentDataView.ofMap(data); if (request.severity() != null) { - data.put("severity", request.severity()); + view.setSeverity(request.severity()); } if (request.status() != null) { - data.put("status", request.status()); + view.setStatus(request.status()); } Long calculatedDuration = request.duration(); @@ -186,48 +190,34 @@ public String createPunishment(Server server, UUID playerUuid, CreatePunishmentR durationCalculator.calculate(server, player.getPunishments(), request.typeOrdinal(), request.severity()); calculatedDuration = result.duration(); - if (result.status() != null && (!data.containsKey("status") || PunishmentData.getStatus(data) == null)) { - data.put("status", result.status()); + if (result.status() != null && (!view.hasStatus() || view.status() == null)) { + view.setStatus(result.status()); } - if (result.offenseLevel() != null && !data.containsKey("offenseLevel")) { - data.put("offenseLevel", result.offenseLevel()); + if (result.offenseLevel() != null && !view.hasOffenseLevel()) { + view.setOffenseLevel(result.offenseLevel()); } } if (calculatedDuration == null) { - calculatedDuration = PunishmentData.getDuration(data); + calculatedDuration = view.duration(); } if (calculatedDuration != null && calculatedDuration != 0) { - data.put("duration", calculatedDuration); + view.setDuration(calculatedDuration); } if (request.reason() != null && !request.reason().isBlank()) { - data.put("reason", request.reason()); + view.setReason(request.reason()); } List types = punishmentTypeService.getPunishmentTypes(server); Map typesByOrdinal = PunishmentTypeIndex.byOrdinal(types); PunishmentType newPunishmentType = typesByOrdinal.get(request.typeOrdinal()); - if (newPunishmentType != null) { - if (newPunishmentType.isPermanentUntilUsernameChange() && !data.containsKey("blockedName")) { - String currentUsername = PlayerDataUtils.extractLatestUsername(player.getUsernames()); - currentUsername = "Unknown".equals(currentUsername) ? null : currentUsername; - if (currentUsername != null) { - data.put("blockedName", currentUsername); - } - } - if (newPunishmentType.isPermanentUntilSkinChange() && !data.containsKey("blockedSkin")) { - Object skinHash = player.getData() != null ? player.getData().get("lastSkinHash") : null; - if (skinHash instanceof String s) { - data.put("blockedSkin", s); - } - } - } + applyRestrictionBlocks(newPunishmentType, player, view); - String newCategory = statusCalculator.getEffectiveCategory(newPunishmentType, data); + String newCategory = statusCalculator.getEffectiveCategory(newPunishmentType, view); if (newCategory != null) { - data.put("enforcementCategory", newCategory); + view.setEnforcementCategory(newCategory); boolean hasExistingInCategory = player.getPunishments() .stream().anyMatch(existing -> { String existingCategory = statusCalculator.getEffectiveCategory(existing, types); @@ -236,79 +226,27 @@ public String createPunishment(Server server, UUID playerUuid, CreatePunishmentR } boolean active = statusCalculator.isPunishmentActive(existing); - boolean unstarted = isUnstarted(existing); + boolean unstarted = existing.isUnstarted(); return active || unstarted; }); if (hasExistingInCategory) { - data.put("status", PunishmentStatus.UNSTARTED); + view.setStatus(PunishmentStatus.UNSTARTED); } } - String reqIssuerName = request.issuerId() != null ? null : request.issuerName(); + String reqIssuerName = PunishmentMapper.storedName(request.issuerId(), request.issuerName()); String reqIssuerId = request.issuerId(); - List notes = new ArrayList<>(); - String enforcementType = newPunishmentType != null && newPunishmentType.isKick() ? "kick" - : EnforcementCategory.BAN.name().equals(newCategory) ? "ban" - : EnforcementCategory.MUTE.name().equals(newCategory) ? "mute" - : "punishment"; - String issuedNote = calculatedDuration != null && calculatedDuration > 0 - ? "issued " + PunishmentMapper.formatDuration(calculatedDuration, false) + " " + enforcementType - : "issued permanent " + enforcementType; - if ("kick".equals(enforcementType)) { - issuedNote = "issued kick"; - } - notes.add(new PunishmentNote( - IdGenerator.generateShortId(), - issuedNote, - now, - reqIssuerName, - reqIssuerId - )); - if (request.reason() != null && !request.reason().isBlank()) { - notes.add(new PunishmentNote( - IdGenerator.generateShortId(), - request.reason(), - now, - reqIssuerName, - reqIssuerId - )); - } - if (request.notes() != null) { - for (CreateNoteRequest noteRequest : request.notes()) { - String noteIssuerId = noteRequest.issuerId() != null ? noteRequest.issuerId() : reqIssuerId; - String noteIssuerName = noteIssuerId != null ? null : (noteRequest.issuerName() != null ? noteRequest.issuerName() : request.issuerName()); - notes.add(new PunishmentNote(IdGenerator.generateShortId(), noteRequest.text(), now, noteIssuerName, noteIssuerId)); - } - } + List notes = buildCreationNotes(request, now, reqIssuerName, reqIssuerId, newPunishmentType, newCategory, calculatedDuration); - List evidence = new ArrayList<>(); - if (request.evidence() != null) { - for (CreateEvidenceRequest evidenceRequest : request.evidence()) { - String evIssuerId = evidenceRequest.issuerId() != null ? evidenceRequest.issuerId() : reqIssuerId; - String evIssuerName = evIssuerId != null ? null : (evidenceRequest.issuerName() != null ? evidenceRequest.issuerName() : request.issuerName()); - String type = evidenceRequest.type() != null ? evidenceRequest.type() : "text"; - SafeUrls.requireSafe(evidenceRequest.fileUrl(), "Invalid evidence URL"); - evidence.add(new PunishmentEvidence( - evidenceRequest.text(), - evidenceRequest.fileUrl(), - type, - evIssuerName, - evIssuerId, - now, - evidenceRequest.fileName(), - evidenceRequest.fileType(), - evidenceRequest.fileSize() - )); - } - } + List evidence = buildCreationEvidence(request, now, reqIssuerId); String punishmentId = IdGenerator.generateShortId(); - boolean pendingAcknowledgement = Boolean.TRUE.equals(data.remove("pendingAcknowledgement")); + boolean pendingAcknowledgement = view.removePendingAcknowledgement(); if (pendingAcknowledgement) { - data.put("status", PunishmentStatus.UNSTARTED); + view.setStatus(PunishmentStatus.UNSTARTED); } Date startedDate = null; @@ -362,42 +300,101 @@ public String createPunishment(Server server, UUID playerUuid, CreatePunishmentR return punishmentId; } - private void closeAttachedTickets(Server server, List ticketIds, String issuerName) { - for (String ticketId : ticketIds) { - try { - ticketService.closeTicketForPunishment(server, ticketId, issuerName); - } catch (Exception e) { - log.error("[TICKET_CLOSE] Failed to close ticket {}", ticketId, e); + private void applyRestrictionBlocks(PunishmentType punishmentType, Player player, PunishmentDataView data) { + if (punishmentType == null) { + return; + } + if (punishmentType.isPermanentUntilUsernameChange() && !data.hasBlockedName()) { + String currentUsername = PlayerDataUtils.extractLatestUsername(player.getUsernames()); + currentUsername = "Unknown".equals(currentUsername) ? null : currentUsername; + if (currentUsername != null) { + data.setBlockedName(currentUsername); + } + } + if (punishmentType.isPermanentUntilSkinChange() && !data.hasBlockedSkin()) { + String skinHash = player.data().lastSkinHash(); + if (skinHash != null) { + data.setBlockedSkin(skinHash); } } } - private boolean isUnstarted(Punishment punishment) { - Map data = punishment.getData(); - if (data == null) { - return false; + private List buildCreationNotes( + CreatePunishmentRequest request, + Date now, + String reqIssuerName, + String reqIssuerId, + PunishmentType punishmentType, + String newCategory, + Long calculatedDuration + ) { + List notes = new ArrayList<>(); + String enforcementType = punishmentType != null && punishmentType.isKick() ? "kick" + : EnforcementCategory.BAN.name().equals(newCategory) ? "ban" + : EnforcementCategory.MUTE.name().equals(newCategory) ? "mute" + : "punishment"; + String issuedNote = calculatedDuration != null && calculatedDuration > 0 + ? "issued " + PunishmentMapper.formatDuration(calculatedDuration, false) + " " + enforcementType + : "issued permanent " + enforcementType; + if ("kick".equals(enforcementType)) { + issuedNote = "issued kick"; } + notes.add(new PunishmentNote(IdGenerator.generateShortId(), issuedNote, now, reqIssuerName, reqIssuerId)); + if (request.reason() != null && !request.reason().isBlank()) { + notes.add(new PunishmentNote(IdGenerator.generateShortId(), request.reason(), now, reqIssuerName, reqIssuerId)); + } + if (request.notes() != null) { + for (CreateNoteRequest noteRequest : request.notes()) { + String noteIssuerId = noteRequest.issuerId() != null ? noteRequest.issuerId() : reqIssuerId; + String noteIssuerName = PunishmentMapper.storedName(noteIssuerId, noteRequest.issuerName() != null ? noteRequest.issuerName() : request.issuerName()); + notes.add(new PunishmentNote(IdGenerator.generateShortId(), noteRequest.text(), now, noteIssuerName, noteIssuerId)); + } + } + return notes; + } - String status = PunishmentData.getStatus(data); - if (!PunishmentStatus.UNSTARTED.equals(status)) { - return false; + private List buildCreationEvidence(CreatePunishmentRequest request, Date now, String reqIssuerId) { + List evidence = new ArrayList<>(); + if (request.evidence() != null) { + for (CreateEvidenceRequest evidenceRequest : request.evidence()) { + String evIssuerId = evidenceRequest.issuerId() != null ? evidenceRequest.issuerId() : reqIssuerId; + String evIssuerName = PunishmentMapper.storedName(evIssuerId, evidenceRequest.issuerName() != null ? evidenceRequest.issuerName() : request.issuerName()); + String type = evidenceRequest.type() != null ? evidenceRequest.type() : "text"; + SafeUrls.requireSafe(evidenceRequest.fileUrl(), "Invalid evidence URL"); + evidence.add(new PunishmentEvidence( + evidenceRequest.text(), + evidenceRequest.fileUrl(), + type, + evIssuerName, + evIssuerId, + now, + evidenceRequest.fileName(), + evidenceRequest.fileType(), + evidenceRequest.fileSize() + )); + } } + return evidence; + } - for (PunishmentModification mod : punishment.getModifications()) { - if (PunishmentModificationType.isPardon(mod.type())) { - return false; + void closeAttachedTickets(Server server, List ticketIds, String issuerName) { + for (String ticketId : ticketIds) { + try { + ticketService.closeTicketForPunishment(server, ticketId, issuerName); + } catch (Exception e) { + log.error("[TICKET_CLOSE] Failed to close ticket {}", ticketId, e); } } - return true; } + public PunishmentOperationResult acknowledgePunishment(Server server, UUID playerUuid, String punishmentId) { Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()).orElse(null); if (player == null) { return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Player not found: " + playerUuid, false, 0); } - Punishment punishment = findPunishment(player, punishmentId); + Punishment punishment = PunishmentQueryService.findPunishment(player, punishmentId); if (punishment == null) { return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found: " + punishmentId + " for player: " + playerUuid, false, 0); @@ -412,10 +409,6 @@ public PunishmentOperationResult acknowledgePunishment(Server server, UUID playe return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Punishment acknowledged", true, 1); } - private Punishment findPunishment(Player player, String punishmentId) { - return PunishmentQueryService.findPunishment(player, punishmentId); - } - public PunishmentOperationResult pardonPunishment(Server server, String punishmentId, String issuerName, String issuerId, String reason) { PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); if (context == null) { @@ -423,7 +416,7 @@ public PunishmentOperationResult pardonPunishment(Server server, String punishme } Punishment punishment = context.punishment(); - if (isPardoned(punishment)) { + if (punishment.isPardoned()) { return new PunishmentOperationResult(PunishmentOperationStatus.NO_OP, "Punishment has already been pardoned", false, 0); } @@ -431,7 +424,7 @@ public PunishmentOperationResult pardonPunishment(Server server, String punishme applyManualPardon(server, context.player(), punishment, issuerName, issuerId, reason); realtimePublisher.punishmentModified(server, context.player(), punishment); - if (PunishmentData.isAltBlocking(punishment.getData())) { + if (punishment.data().altBlocking()) { cascadePardonLinkedBans(server, punishmentId); } @@ -446,7 +439,7 @@ public int pardonPunishments(Server server, Player player, List targ for (Punishment punishment : targets) { applyManualPardon(server, player, punishment, issuerName, issuerId, reason); modified.add(new PunishmentRealtimePublisher.PlayerPunishment(player, punishment)); - if (PunishmentData.isAltBlocking(punishment.getData())) { + if (punishment.data().altBlocking()) { altBlockingParents.add(punishment.getId()); } } @@ -462,7 +455,7 @@ public int pardonPunishments(Server server, Player player, List targ private void applyManualPardon(Server server, Player player, Punishment punishment, String issuerName, String issuerId, String reason) { - String resolvedIssuerName = issuerId != null ? null : issuerName; + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, issuerName); Date now = new Date(); PunishmentModification modification = new PunishmentModification( @@ -485,22 +478,38 @@ private void applyManualPardon(Server server, Player player, Punishment punishme punishment.getModifications().add(modification); punishment.getNotes().addAll(notes); - punishment.getData().put("status", PunishmentStatus.PARDONED); + punishment.data().setStatus(PunishmentStatus.PARDONED); punishmentRepository.appendPardon(server, player.getMinecraftUuid().toString(), punishment.getId(), modification, notes, PunishmentStatus.PARDONED); } - public int cascadePardonLinkedBans(Server server, String parentPunishmentId) { + private int cascadeToLinkedBans(Server server, String parentPunishmentId, LinkedBanCascade cascade) { List modified = new ArrayList<>(); int count = 0; for (Player player : punishmentRepository.findByLinkedBanId(server, parentPunishmentId)) { - count += applyLinkedBanSystemPardon(server, player, parentPunishmentId, modified); + count += cascade.apply(player, modified); } realtimePublisher.punishmentsModified(server, modified); return count; } + @FunctionalInterface + private interface LinkedBanCascade { + int apply(Player player, List modified); + } + + private boolean isActiveLinkedBanFor(Punishment punishment, String parentPunishmentId) { + return punishment.getTypeOrdinal() == Punishment.LINKED_BAN_TYPE_ORDINAL + && parentPunishmentId.equals(punishment.data().linkedBanId()) + && statusCalculator.isPunishmentActive(punishment); + } + + public int cascadePardonLinkedBans(Server server, String parentPunishmentId) { + return cascadeToLinkedBans(server, parentPunishmentId, + (player, modified) -> applyLinkedBanSystemPardon(server, player, parentPunishmentId, modified)); + } + private int applyLinkedBanSystemPardon( Server server, Player player, @@ -510,10 +519,7 @@ private int applyLinkedBanSystemPardon( int count = 0; for (Punishment punishment : player.getPunishments()) { - if (punishment.getTypeOrdinal() != Punishment.LINKED_BAN_TYPE_ORDINAL - || punishment.getData() == null - || !parentPunishmentId.equals(punishment.getData().get("linkedBanId")) - || !statusCalculator.isPunishmentActive(punishment)) { + if (!isActiveLinkedBanFor(punishment, parentPunishmentId)) { continue; } @@ -531,7 +537,7 @@ private void applySystemPardon(Server server, String playerUuid, Punishment puni IdGenerator.generateShortId(), PunishmentModificationType.SYSTEM_PARDON.name(), now, - "System", + SYSTEM_ISSUER, null, reason, null, @@ -542,37 +548,22 @@ private void applySystemPardon(Server server, String playerUuid, Punishment puni IdGenerator.generateShortId(), reason, now, - "System", + SYSTEM_ISSUER, null ); punishment.getModifications().add(modification); punishment.getNotes().add(note); - if (punishment.getData() != null) { - punishment.getData().put("status", PunishmentStatus.PARDONED); - } + punishment.data().setStatus(PunishmentStatus.PARDONED); punishmentRepository.appendPardon(server, playerUuid, punishment.getId(), modification, List.of(note), PunishmentStatus.PARDONED); } - private boolean isPardoned(Punishment punishment) { - return punishment.getModifications() - .stream() - .anyMatch(modification -> - PunishmentModificationType.isPardon(modification.type())); - } public int cascadeDurationChangeToLinkedBans(Server server, String parentPunishmentId, Long newDuration, String issuerName) { - int count = 0; - List modified = new ArrayList<>(); - - for (Player player : punishmentRepository.findByLinkedBanId(server, parentPunishmentId)) { - count += applyLinkedBanDurationChange(server, player, parentPunishmentId, newDuration, modified); - } - - realtimePublisher.punishmentsModified(server, modified); - return count; + return cascadeToLinkedBans(server, parentPunishmentId, + (player, modified) -> applyLinkedBanDurationChange(server, player, parentPunishmentId, newDuration, modified)); } private int applyLinkedBanDurationChange( @@ -585,10 +576,7 @@ private int applyLinkedBanDurationChange( int count = 0; for (Punishment punishment : player.getPunishments()) { - if (punishment.getTypeOrdinal() != Punishment.LINKED_BAN_TYPE_ORDINAL - || punishment.getData() == null - || !parentPunishmentId.equals(punishment.getData().get("linkedBanId")) - || !statusCalculator.isPunishmentActive(punishment)) { + if (!isActiveLinkedBanFor(punishment, parentPunishmentId)) { continue; } @@ -598,7 +586,7 @@ private int applyLinkedBanDurationChange( IdGenerator.generateShortId(), PunishmentModificationType.MANUAL_DURATION_CHANGE.name(), now, - "System", + SYSTEM_ISSUER, null, "Cascaded from parent ban duration change", effective, @@ -609,11 +597,11 @@ private int applyLinkedBanDurationChange( IdGenerator.generateShortId(), "Duration changed (cascaded from parent ban)", now, - "System", + SYSTEM_ISSUER, null ); - punishment.getData().put("duration", effective); + punishment.data().setDuration(effective); punishment.getModifications().add(modification); punishment.getNotes().add(note); String linkedPlayerUuid = player.getMinecraftUuid().toString(); @@ -650,16 +638,14 @@ public List promoteUnstartedPunishments(Server server, Player player) { .stream() .filter(p -> { String effectiveCategory = statusCalculator.getEffectiveCategory(p, types); - return category.equals(effectiveCategory) && isUnstarted(p); + return category.equals(effectiveCategory) && p.isUnstarted(); }) .min((a, b) -> a.getIssued().compareTo(b.getIssued())); if (oldest.isPresent()) { Punishment toPromote = oldest.get(); punishmentRepository.unsetPunishmentStatus(server, player.getMinecraftUuid().toString(), toPromote.getId()); - if (toPromote.getData() != null) { - toPromote.getData().remove("status"); - } + toPromote.data().removeStatus(); promotedIds.add(toPromote.getId()); promoted.add(toPromote); } @@ -697,12 +683,7 @@ public List enforceAltBlockingBans(Server server, Player player) { continue; } - Map data = punishment.getData(); - if (data == null) { - continue; - } - - if (!PunishmentData.isAltBlocking(data)) { + if (!punishment.data().altBlocking()) { continue; } @@ -744,10 +725,11 @@ private String createLinkedBanPunishment(Server server, UUID playerUuid, String Date now = new Date(); Map data = new HashMap<>(); - data.put("linkedBanId", parentPunishmentId); - data.put("linkedBanParentUuid", parentPlayerUuid); + PunishmentDataView view = PunishmentDataView.ofMap(data); + view.setLinkedBanId(parentPunishmentId); + view.setLinkedBanParentUuid(parentPlayerUuid); if (duration != null) { - data.put("duration", duration); + view.setDuration(duration); } String punishmentId = IdGenerator.generateShortId(); @@ -760,21 +742,21 @@ private String createLinkedBanPunishment(Server server, UUID playerUuid, String IdGenerator.generateShortId(), linkedBanNote, now, - "System", + SYSTEM_ISSUER, null )); notes.add(new PunishmentNote( IdGenerator.generateShortId(), "Automatically issued linked ban due to alt-blocking ban on linked account", now, - "System", + SYSTEM_ISSUER, null )); Punishment punishment = new Punishment( punishmentId, Punishment.LINKED_BAN_TYPE_ORDINAL, - "System", + SYSTEM_ISSUER, null, now, now, @@ -791,19 +773,8 @@ private String createLinkedBanPunishment(Server server, UUID playerUuid, String return punishmentId; } - @SuppressWarnings("unchecked") private List getLinkedAccountUuids(Player player) { - if (player.getData() == null) { - return List.of(); - } - Object linkedObj = player.getData().get("linkedAccounts"); - if (linkedObj instanceof List list) { - return list.stream() - .filter(String.class::isInstance) - .map(String.class::cast) - .toList(); - } - return List.of(); + return player.data().linkedAccountUuids(); } public List checkRestrictionAutoPardons(Server server, Player player, String currentUsername, String currentSkinHash) { @@ -820,13 +791,13 @@ public List checkRestrictionAutoPardons(Server server, Player player, St continue; } - Map data = punishment.getData(); - if (data == null) { + PunishmentDataView data = punishment.data(); + if (data.asMap() == null) { continue; } if (type.isPermanentUntilUsernameChange() && currentUsername != null) { - String blockedName = PunishmentData.getBlockedName(data); + String blockedName = data.blockedName(); if (blockedName != null && !blockedName.equalsIgnoreCase(currentUsername)) { String reason = "Auto-pardoned: username changed from '" + blockedName + "' to '" + currentUsername + "'"; systemPardonPunishment(server, player.getMinecraftUuid(), punishment.getId(), reason); @@ -835,7 +806,7 @@ public List checkRestrictionAutoPardons(Server server, Player player, St } if (type.isPermanentUntilSkinChange() && currentSkinHash != null) { - String blockedSkin = PunishmentData.getBlockedSkin(data); + String blockedSkin = data.blockedSkin(); if (blockedSkin != null && !blockedSkin.equals(currentSkinHash)) { String reason = "Auto-pardoned: skin changed"; systemPardonPunishment(server, player.getMinecraftUuid(), punishment.getId(), reason); @@ -853,7 +824,7 @@ public void systemPardonPunishment(Server server, UUID playerUuid, String punish return; } - Punishment punishment = findPunishment(player, punishmentId); + Punishment punishment = PunishmentQueryService.findPunishment(player, punishmentId); if (punishment == null) { return; } diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentMapper.java b/src/main/java/gg/modl/backend/player/service/PunishmentMapper.java index ce7021e..88571e5 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentMapper.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentMapper.java @@ -1,198 +1,229 @@ -package gg.modl.backend.player.service; - -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentData; -import gg.modl.backend.player.data.punishment.PunishmentNote; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeIndex; -import java.util.Collections; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.jetbrains.annotations.Nullable; - -public final class PunishmentMapper { - - private PunishmentMapper() {} - - public static Map toPunishmentMap(Punishment punishment, List punishmentTypes) { - return toPunishmentMap(punishment, PunishmentTypeIndex.byOrdinal(punishmentTypes), Collections.emptyMap()); - } - - public static Map toPunishmentMap(Punishment punishment, List punishmentTypes, Map resolvedIssuers) { - return toPunishmentMap(punishment, PunishmentTypeIndex.byOrdinal(punishmentTypes), resolvedIssuers); - } - - public static Map toPunishmentMap(Punishment punishment, Map typesByOrdinal, Map resolvedIssuers) { - Map map = new LinkedHashMap<>(); - map.put("id", punishment.getId()); - map.put("issuerName", resolveIssuer(punishment.getIssuerId(), punishment.getIssuerName(), resolvedIssuers)); - map.put("issued", punishment.getIssued()); - map.put("started", punishment.getStarted()); - - int ordinal = punishment.getTypeOrdinal(); - map.put("typeOrdinal", ordinal); - - PunishmentType matchedType = typesByOrdinal.get(ordinal); - String actualTypeName = matchedType != null ? matchedType.getName() : null; - - map.put("type", actualTypeName != null ? actualTypeName : "Unknown"); - - Map dataWithTypeName = punishment.getData() != null ? - new LinkedHashMap<>(punishment.getData()) : new LinkedHashMap<>(); - if (actualTypeName != null) { - dataWithTypeName.put("typeName", actualTypeName); - } - - List> modifications = punishment.getModifications() - .stream() - .map(m -> { - Map mod = new LinkedHashMap<>(); - mod.put("id", m.id()); - mod.put("type", m.type()); - mod.put("date", m.date()); - mod.put("issuerName", resolveIssuer(m.issuerId(), m.issuerName(), resolvedIssuers)); - mod.put("effectiveDuration", m.effectiveDuration()); - mod.put("data", m.data()); - return mod; - }).toList(); - map.put("modifications", modifications); - - List> notes = punishment.getNotes() - .stream() - .map(n -> { - Map note = new LinkedHashMap<>(); - note.put("id", n.id()); - note.put("text", n.text()); - note.put("issuerName", resolveIssuer(n.issuerId(), n.issuerName(), resolvedIssuers)); - note.put("date", n.date()); - return note; - }).toList(); - map.put("notes", notes); - - List> evidence = punishment.getEvidence() - .stream() - .map(e -> { - Map ev = new LinkedHashMap<>(); - ev.put("text", e.text()); - ev.put("url", e.url()); - ev.put("type", e.type()); - ev.put("uploadedBy", resolveIssuer(e.uploadedById(), e.uploadedBy(), resolvedIssuers)); - ev.put("uploadedAt", e.uploadedAt()); - ev.put("fileName", e.fileName()); - ev.put("fileType", e.fileType()); - ev.put("fileSize", e.fileSize()); - return ev; - }).toList(); - map.put("evidence", evidence); - - map.put("attachedTicketIds", punishment.getAttachedTicketIds()); - map.put("data", dataWithTypeName); - - return map; - } - - static String resolveIssuer(@Nullable String issuerId, @Nullable String issuerName, Map resolvedIssuers) { - if (issuerId != null && resolvedIssuers.containsKey(issuerId)) { - return resolvedIssuers.get(issuerId); - } - if (issuerName != null) { - return issuerName; - } - return issuerId != null ? "Unknown Staff" : "Console"; - } - - public static Map toSimplePunishment(Punishment punishment, List types, PlayerStatusCalculator statusCalculator) { - return toSimplePunishment(punishment, PunishmentTypeIndex.byOrdinal(types), statusCalculator, Collections.emptyMap()); - } - - public static Map toSimplePunishment(Punishment punishment, List types, PlayerStatusCalculator statusCalculator, Map resolvedIssuers) { - return toSimplePunishment(punishment, PunishmentTypeIndex.byOrdinal(types), statusCalculator, resolvedIssuers); - } - - public static Map toSimplePunishment(Punishment punishment, Map typesByOrdinal, PlayerStatusCalculator statusCalculator, Map resolvedIssuers) { - Map data = punishment.getData(); - Date expires = statusCalculator.getEffectiveExpiry(punishment); - - PunishmentType punishmentType = typesByOrdinal.get(punishment.getTypeOrdinal()); - - String typeName = punishmentType != null ? punishmentType.getName() : "Unknown"; - String playerDescription = punishmentType != null ? punishmentType.getPlayerDescription() : null; - - String effectiveCategory = statusCalculator.getEffectiveCategory(punishmentType, data); - String category = effectiveCategory != null ? effectiveCategory : "OTHER"; - - String reason = PunishmentData.getReason(data); - if ((reason == null || reason.isBlank()) && punishment.getNotes() != null) { - for (PunishmentNote note : punishment.getNotes()) { - String noteText = note.text(); - if (noteText != null && !isAutoGeneratedNote(noteText)) { - reason = noteText; - break; - } - } - } - - Map result = new LinkedHashMap<>(); - result.put("id", punishment.getId()); - result.put("type", typeName); - result.put("category", category); - result.put("typeOrdinal", punishment.getTypeOrdinal()); - result.put("ordinal", punishment.getTypeOrdinal()); - result.put("started", punishment.getStarted() != null); - result.put("expiration", expires != null ? expires.getTime() : null); - result.put("description", reason != null && !reason.isBlank() ? reason : "No reason specified"); - result.put("issuerName", resolveIssuer(punishment.getIssuerId(), punishment.getIssuerName(), resolvedIssuers)); - result.put("issuedAt", punishment.getIssued().getTime()); - result.put("playerDescription", playerDescription); - result.put("modifications", punishment.getModifications() - .stream().map(m -> { - Map modMap = new LinkedHashMap<>(); - modMap.put("type", m.type()); - modMap.put("timestamp", m.date() != null ? m.date().getTime() : null); - modMap.put("effectiveDuration", m.effectiveDuration() != null ? m.effectiveDuration() : 0L); - modMap.put("issuerName", resolveIssuer(m.issuerId(), m.issuerName(), resolvedIssuers)); - return modMap; - }).toList()); - - return result; - } - - public static boolean isAutoGeneratedNote(String noteText) { - if (noteText == null) { - return true; - } - String lower = noteText.toLowerCase(); - return lower.equals("issued punishment") || - lower.startsWith("issued ") || - lower.equals("pardoned punishment") || - lower.equals("added evidence") || - lower.startsWith("changed duration to ") || - lower.startsWith("enabled ") || - lower.startsWith("disabled "); - } - - public static String formatDuration(long durationMs, boolean isPermanent) { - if (isPermanent || durationMs < 0) { - return "Permanent"; - } - if (durationMs == 0) { - return "Instant"; - } - - long totalSeconds = durationMs / 1000; - long days = totalSeconds / 86400; - long hours = (totalSeconds % 86400) / 3600; - long minutes = (totalSeconds % 3600) / 60; - long seconds = totalSeconds % 60; - - StringBuilder sb = new StringBuilder(); - if (days > 0) sb.append(days).append("d"); - if (hours > 0) { if (!sb.isEmpty()) sb.append(" "); sb.append(hours).append("h"); } - if (minutes > 0) { if (!sb.isEmpty()) sb.append(" "); sb.append(minutes).append("m"); } - if (seconds > 0 && days == 0) { if (!sb.isEmpty()) sb.append(" "); sb.append(seconds).append("s"); } - - return sb.isEmpty() ? "0s" : sb.toString(); - } -} +package gg.modl.backend.player.service; + +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentDataView; +import gg.modl.backend.player.data.punishment.PunishmentNote; +import gg.modl.backend.player.dto.response.PunishmentView; +import gg.modl.backend.player.dto.response.SimplePunishmentView; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeIndex; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +public final class PunishmentMapper { + + private PunishmentMapper() {} + + public static PunishmentView toPunishmentView(Punishment punishment, List punishmentTypes) { + return toPunishmentView(punishment, PunishmentTypeIndex.byOrdinal(punishmentTypes), Collections.emptyMap()); + } + + public static PunishmentView toPunishmentView(Punishment punishment, List punishmentTypes, Map resolvedIssuers) { + return toPunishmentView(punishment, PunishmentTypeIndex.byOrdinal(punishmentTypes), resolvedIssuers); + } + + public static PunishmentView toPunishmentView(Punishment punishment, Map typesByOrdinal, Map resolvedIssuers) { + int ordinal = punishment.getTypeOrdinal(); + PunishmentType matchedType = typesByOrdinal.get(ordinal); + String actualTypeName = matchedType != null ? matchedType.getName() : null; + + Map punishmentData = punishment.data().asMap(); + Map dataWithTypeName = punishmentData != null ? + new LinkedHashMap<>(punishmentData) : new LinkedHashMap<>(); + if (actualTypeName != null) { + dataWithTypeName.put("typeName", actualTypeName); + } + + List> modifications = punishment.getModifications() + .stream() + .map(m -> { + Map mod = new LinkedHashMap<>(); + mod.put("id", m.id()); + mod.put("type", m.type()); + mod.put("date", m.date()); + mod.put("issuerName", resolveIssuer(m.issuerId(), m.issuerName(), resolvedIssuers)); + mod.put("effectiveDuration", m.effectiveDuration()); + mod.put("data", m.data()); + return mod; + }).toList(); + + List> notes = punishment.getNotes() + .stream() + .map(n -> { + Map note = new LinkedHashMap<>(); + note.put("id", n.id()); + note.put("text", n.text()); + note.put("issuerName", resolveIssuer(n.issuerId(), n.issuerName(), resolvedIssuers)); + note.put("date", n.date()); + return note; + }).toList(); + + List> evidence = punishment.getEvidence() + .stream() + .map(e -> { + Map ev = new LinkedHashMap<>(); + ev.put("text", e.text()); + ev.put("url", e.url()); + ev.put("type", e.type()); + ev.put("uploadedBy", resolveIssuer(e.uploadedById(), e.uploadedBy(), resolvedIssuers)); + ev.put("uploadedAt", e.uploadedAt()); + ev.put("fileName", e.fileName()); + ev.put("fileType", e.fileType()); + ev.put("fileSize", e.fileSize()); + return ev; + }).toList(); + + return new PunishmentView( + punishment.getId(), + resolveIssuer(punishment.getIssuerId(), punishment.getIssuerName(), resolvedIssuers), + punishment.getIssued(), + punishment.getStarted(), + ordinal, + actualTypeName != null ? actualTypeName : "Unknown", + modifications, + notes, + evidence, + punishment.getAttachedTicketIds(), + dataWithTypeName, + null, + null + ); + } + + static String resolveIssuer(@Nullable String issuerId, @Nullable String issuerName, Map resolvedIssuers) { + if (issuerId != null && resolvedIssuers.containsKey(issuerId)) { + return resolvedIssuers.get(issuerId); + } + if (issuerName != null) { + return issuerName; + } + return issuerId != null ? "Unknown Staff" : "Console"; + } + + static String storedName(@Nullable String issuerId, @Nullable String issuerName) { + return issuerId != null ? null : issuerName; + } + + public static String offenseDisplayStatus(String offenseLevel) { + return switch (offenseLevel) { + case "first" -> "low"; + default -> offenseLevel; + }; + } + + public static SimplePunishmentView toSimplePunishment(Punishment punishment, List types, PlayerStatusCalculator statusCalculator) { + return toSimplePunishment(punishment, PunishmentTypeIndex.byOrdinal(types), statusCalculator, Collections.emptyMap()); + } + + public static SimplePunishmentView toSimplePunishment(Punishment punishment, List types, PlayerStatusCalculator statusCalculator, Map resolvedIssuers) { + return toSimplePunishment(punishment, PunishmentTypeIndex.byOrdinal(types), statusCalculator, resolvedIssuers); + } + + public static SimplePunishmentView toSimplePunishment(Punishment punishment, Map typesByOrdinal, PlayerStatusCalculator statusCalculator, Map resolvedIssuers) { + PunishmentDataView data = punishment.data(); + Date expires = statusCalculator.getEffectiveExpiry(punishment); + + PunishmentType punishmentType = typesByOrdinal.get(punishment.getTypeOrdinal()); + + String typeName = punishmentType != null ? punishmentType.getName() : "Unknown"; + String playerDescription = punishmentType != null ? punishmentType.getPlayerDescription() : null; + + String effectiveCategory = statusCalculator.getEffectiveCategory(punishmentType, data); + String category = effectiveCategory != null ? effectiveCategory : "OTHER"; + + String reason = data.reason(); + if ((reason == null || reason.isBlank()) && punishment.getNotes() != null) { + for (PunishmentNote note : punishment.getNotes()) { + String noteText = note.text(); + if (noteText != null && !isAutoGeneratedNote(noteText)) { + reason = noteText; + break; + } + } + } + + List modifications = punishment.getModifications() + .stream() + .map(m -> new SimplePunishmentView.Modification( + m.type(), + m.date() != null ? m.date().getTime() : null, + m.effectiveDuration() != null ? m.effectiveDuration() : 0L, + resolveIssuer(m.issuerId(), m.issuerName(), resolvedIssuers) + )) + .toList(); + + return new SimplePunishmentView( + punishment.getId(), + typeName, + category, + punishment.getTypeOrdinal(), + punishment.getTypeOrdinal(), + punishment.getStarted() != null, + expires != null ? expires.getTime() : null, + reason != null && !reason.isBlank() ? reason : "No reason specified", + resolveIssuer(punishment.getIssuerId(), punishment.getIssuerName(), resolvedIssuers), + punishment.getIssued().getTime(), + playerDescription, + modifications + ); + } + + public static boolean isAutoGeneratedNote(String noteText) { + if (noteText == null) { + return true; + } + String lower = noteText.toLowerCase(Locale.ROOT); + return lower.equals("issued punishment") || + lower.startsWith("issued ") || + lower.equals("pardoned punishment") || + lower.equals("added evidence") || + lower.startsWith("changed duration to ") || + lower.startsWith("enabled ") || + lower.startsWith("disabled "); + } + + public static String formatDuration(long durationMs, boolean isPermanent) { + if (isPermanent || durationMs < 0) { + return "Permanent"; + } + if (durationMs == 0) { + return "Instant"; + } + + long totalSeconds = durationMs / 1000; + long days = totalSeconds / 86400; + long hours = (totalSeconds % 86400) / 3600; + long minutes = (totalSeconds % 3600) / 60; + long seconds = totalSeconds % 60; + + StringBuilder sb = new StringBuilder(); + if (days > 0) { + sb.append(days).append("d"); + } + if (hours > 0) { + if (!sb.isEmpty()) { + sb.append(" "); + } + sb.append(hours).append("h"); + } + if (minutes > 0) { + if (!sb.isEmpty()) { + sb.append(" "); + } + sb.append(minutes).append("m"); + } + if (seconds > 0 && days == 0) { + if (!sb.isEmpty()) { + sb.append(" "); + } + sb.append(seconds).append("s"); + } + + return sb.isEmpty() ? "0s" : sb.toString(); + } +} diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentMutationService.java b/src/main/java/gg/modl/backend/player/service/PunishmentMutationService.java index 0db970c..63dcd63 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentMutationService.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentMutationService.java @@ -1,374 +1,331 @@ -package gg.modl.backend.player.service; - -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentData; -import gg.modl.backend.player.data.punishment.PunishmentModification; -import gg.modl.backend.player.data.punishment.PunishmentModificationType; -import gg.modl.backend.player.data.punishment.PunishmentNote; -import gg.modl.backend.player.dto.request.AddModificationRequest; -import gg.modl.backend.player.dto.request.ModifyPunishmentTicketsRequest; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentContext; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.AppealWorkflowStatus; -import gg.modl.backend.ticket.service.AppealWorkflowTransitionService; -import gg.modl.backend.ticket.service.TicketService; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import gg.modl.backend.infrastructure.util.IdGenerator; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class PunishmentMutationService { - private final PlayerMongoRepository playerRepository; - private final PunishmentMongoRepository punishmentRepository; - private final TicketService ticketService; - private final AppealWorkflowTransitionService appealWorkflowTransitionService; - private final IssuerNameResolver issuerNameResolver; - private final StaffMongoRepository staffRepository; - private final PunishmentQueryService punishmentQueryService; - private final PunishmentLifecycleService punishmentLifecycleService; - private final PunishmentRealtimePublisher realtimePublisher; - - public Player addModification(Server server, UUID playerUuid, String punishmentId, AddModificationRequest request) { - Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) - .orElseThrow(() -> new ResourceNotFoundException("Player not found")); - - Date now = new Date(); - String modIssuerName = request.issuerId() != null ? null : request.issuerName(); - String modIssuerId = request.issuerId(); - - PunishmentModification modification = new PunishmentModification( - IdGenerator.generateShortId(), - request.type(), - now, - modIssuerName, - modIssuerId, - request.reason() != null ? request.reason() : "", - request.effectiveDuration(), - request.appealTicketId(), - null - ); - - Punishment punishment = findPunishment(player, punishmentId); - if (punishment == null) { - throw new ResourceNotFoundException("Punishment not found"); - } - - syncLinkedAppealOutcome(server, punishmentId, request); - - punishment.getModifications().add(modification); - punishmentRepository.appendModification(server, playerUuid.toString(), punishmentId, modification); - if (request.effectiveDuration() != null && punishment.getStarted() == null) { - punishment.setStarted(now); - punishmentRepository.setPunishmentStartedIfUnset(server, playerUuid.toString(), punishmentId, now); - } - realtimePublisher.punishmentModified(server, player, punishment); - return player; - } - - private void syncLinkedAppealOutcome(Server server, String punishmentId, AddModificationRequest request) { - if (request.appealTicketId() == null) { - return; - } - PunishmentModificationType modificationType = PunishmentModificationType.fromName(request.type()); - AppealWorkflowStatus outcome = modificationType != null ? modificationType.appealOutcome() : null; - if (outcome == null) { - return; - } - String issuerName = issuerNameResolver.resolve(request.issuerId(), request.issuerName(), server); - appealWorkflowTransitionService.applyOutcomeForPunishment(server, request.appealTicketId(), punishmentId, outcome, issuerName); - } - - private Punishment findPunishment(Player player, String punishmentId) { - return PunishmentQueryService.findPunishment(player, punishmentId); - } - - public PunishmentOperationResult changeDuration(Server server, String punishmentId, Long newDuration, String issuerName, String issuerId) { - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); - } - - String resolvedIssuerName = issuerId != null ? null : issuerName; - - Punishment punishment = context.punishment(); - Date now = new Date(); - - Long effective = (newDuration == null) ? -1L : newDuration; - - PunishmentModification modification = new PunishmentModification( - IdGenerator.generateShortId(), - PunishmentModificationType.MANUAL_DURATION_CHANGE.name(), - now, - resolvedIssuerName, - issuerId, - "Duration changed", - effective, - null, - null - ); - - String durationText = newDuration == null || newDuration < 0 - ? "permanent" - : PunishmentMapper.formatDuration(newDuration, false); - PunishmentNote note = new PunishmentNote( - IdGenerator.generateShortId(), - "changed duration to " + durationText, - now, - resolvedIssuerName, - issuerId - ); - - punishment.getModifications().add(modification); - punishment.getNotes().add(note); - punishment.getData().put("duration", effective); - String uuid = context.player().getMinecraftUuid().toString(); - punishmentRepository.appendDurationChange(server, uuid, punishmentId, modification, note, effective); - if (punishment.getStarted() == null) { - punishment.setStarted(now); - punishmentRepository.setPunishmentStartedIfUnset(server, uuid, punishmentId, now); - } - realtimePublisher.punishmentModified(server, context.player(), punishment); - - if (PunishmentData.isAltBlocking(punishment.getData())) { - int cascaded = punishmentLifecycleService.cascadeDurationChangeToLinkedBans(server, punishmentId, newDuration, issuerName); - if (cascaded > 0) { - return new PunishmentOperationResult( - PunishmentOperationStatus.SUCCESS, - "Duration changed (cascaded to " + cascaded + " linked ban" + (cascaded > 1 ? "s" : "") + ")", - true, - cascaded + 1 - ); - } - } - - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Duration changed", true, 1); - } - - public PunishmentOperationResult toggleOption(Server server, String punishmentId, String option, boolean enabled, String issuerName, String issuerId) { - PunishmentToggleOption toggleOption = PunishmentToggleOption.from(option); - if (toggleOption == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.INVALID_REQUEST, "Invalid option", false, 0); - } - - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); - } - - Punishment punishment = context.punishment(); - Date now = new Date(); - String resolvedIssuerName = issuerId != null ? null : issuerName; - PunishmentNote note = new PunishmentNote( - IdGenerator.generateShortId(), - (enabled ? "enabled " : "disabled ") + toggleOption.displayName, - now, - resolvedIssuerName, - issuerId - ); - punishment.getData().put(toggleOption.dataKey, enabled); - punishment.getNotes().add(note); - punishmentRepository.setPunishmentData(server, context.player().getMinecraftUuid().toString(), punishmentId, - Map.of(toggleOption.dataKey, enabled), note); - realtimePublisher.punishmentModified(server, context.player(), punishment); - - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Option toggled", true, 1); - } - - public PunishmentOperationResult acknowledgeStatWipe(Server server, String punishmentId) { - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); - } - - Map data = context.punishment().getData(); - if (!PunishmentData.isWipeAfterExpiry(data)) { - return new PunishmentOperationResult( - PunishmentOperationStatus.NO_OP, - "Stat wipe no longer enabled for this punishment", - false, - 0 - ); - } - - punishmentRepository.setPunishmentData(server, context.player().getMinecraftUuid().toString(), punishmentId, - Map.of("statWipeCompleted", true, "statWipeCompletedAt", new Date()), null); - - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Stat wipe acknowledged", true, 1); - } - - public PunishmentOperationResult modifyPunishmentTickets(Server server, String punishmentId, ModifyPunishmentTicketsRequest request) { - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Failed to modify punishment tickets", false, 0); - } - - applyPunishmentTicketModifications(server, context.player(), context.punishment(), request); - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Punishment tickets modified", true, 1); - } - - public PunishmentOperationResult modifyPunishmentTickets( - Server server, - String punishmentId, - List addTicketIds, - List removeTicketIds, - boolean modifyAssociatedTickets, - String issuerName, - String issuerId - ) { - return modifyPunishmentTickets( - server, - punishmentId, - new ModifyPunishmentTicketsRequest( - addTicketIds, - removeTicketIds, - modifyAssociatedTickets, - issuerName, - issuerId - ) - ); - } - - public Player modifyPunishmentTickets(Server server, UUID playerUuid, String punishmentId, ModifyPunishmentTicketsRequest request) { - Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) - .orElseThrow(() -> new ResourceNotFoundException("Player not found")); - - Punishment punishment = player.getPunishments() - .stream() - .filter(p -> p.getId().equals(punishmentId)) - .findFirst() - .orElseThrow(() -> new ResourceNotFoundException("Punishment not found")); - - applyPunishmentTicketModifications(server, player, punishment, request); - realtimePublisher.punishmentDetailsChanged(server, player, punishment); - return player; - } - - private void applyPunishmentTicketModifications(Server server, Player player, Punishment punishment, ModifyPunishmentTicketsRequest request) { - - List currentIds = punishment.getAttachedTicketIds() != null - ? new ArrayList<>(punishment.getAttachedTicketIds()) - : new ArrayList<>(); - List originalIds = new ArrayList<>(currentIds); - - if (request.addTicketIds() != null) { - for (String id : request.addTicketIds()) { - if (!currentIds.contains(id)) { - currentIds.add(id); - } - } - } - - if (request.removeTicketIds() != null) { - currentIds.removeAll(request.removeTicketIds()); - } - - List addedIds = new ArrayList<>(currentIds); - addedIds.removeAll(originalIds); - - List removedIds = new ArrayList<>(originalIds); - removedIds.removeAll(currentIds); - - punishment.setAttachedTicketIds(currentIds); - punishmentRepository.setPunishmentTickets(server, player.getMinecraftUuid().toString(), punishment.getId(), currentIds); - - if (request.modifyAssociatedTickets()) { - String ticketIssuerName = issuerNameResolver.resolve(request.issuerId(), request.issuerName(), server); - if (!addedIds.isEmpty()) { - closeAttachedTickets(server, addedIds, ticketIssuerName); - } - if (!removedIds.isEmpty()) { - reopenAttachedTickets(server, removedIds, ticketIssuerName); - } - } - } - - private void closeAttachedTickets(Server server, List ticketIds, String issuerName) { - for (String ticketId : ticketIds) { - try { - ticketService.closeTicketForPunishment(server, ticketId, issuerName); - } catch (Exception e) { - log.error("[TICKET_CLOSE] Failed to close ticket {}", ticketId, e); - } - } - } - - private void reopenAttachedTickets(Server server, List ticketIds, String issuerName) { - for (String ticketId : ticketIds) { - try { - ticketService.reopenTicketForPunishment(server, ticketId, issuerName); - } catch (Exception e) { - log.error("[TICKET_REOPEN] Failed to reopen ticket {}", ticketId, e); - } - } - } - - public void linkAppealToPunishment(Server server, String playerUuid, String punishmentId, - String appealId, PunishmentNote note) { - punishmentRepository.linkAppealToPunishment(server, normalizeUuid(playerUuid), punishmentId, appealId, note); - } - - public void addPunishmentNote(Server server, String playerUuid, String punishmentId, - PunishmentNote note, Map dataUpdates) { - punishmentRepository.addPunishmentNote(server, normalizeUuid(playerUuid), punishmentId, note, dataUpdates); - } - - public void applyAppealApproval(Server server, String playerUuid, String punishmentId, - PunishmentModification modification, PunishmentNote note, - String appealOutcome, String appealTicketId) { - String normalizedUuid = normalizeUuid(playerUuid); - punishmentRepository.applyAppealApproval(server, normalizedUuid, punishmentId, - modification, note, appealOutcome, appealTicketId); - - playerRepository.findByMinecraftUuid(server, normalizedUuid).ifPresent(player -> { - Punishment punishment = findPunishment(player, punishmentId); - if (punishment != null) { - realtimePublisher.punishmentModified(server, player, punishment); - } - }); - } - - private enum PunishmentToggleOption { - ALT_BLOCKING("altBlocking", "alt-blocking"), - STAT_WIPE("wipeAfterExpiry", "stat wipe"); - - private final String dataKey; - private final String displayName; - - PunishmentToggleOption(String dataKey, String displayName) { - this.dataKey = dataKey; - this.displayName = displayName; - } - - private static PunishmentToggleOption from(String option) { - if (option == null || option.isBlank()) { - return null; - } - - try { - return PunishmentToggleOption.valueOf(option.trim().toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException exception) { - return null; - } - } - } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } -} +package gg.modl.backend.player.service; + +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentModification; +import gg.modl.backend.player.data.punishment.PunishmentModificationType; +import gg.modl.backend.player.data.punishment.PunishmentNote; +import gg.modl.backend.player.data.punishment.PunishmentToggleOption; +import gg.modl.backend.player.dto.request.AddModificationRequest; +import gg.modl.backend.player.dto.request.ModifyPunishmentTicketsRequest; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentContext; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.AppealWorkflowStatus; +import gg.modl.backend.ticket.service.AppealWorkflowTransitionService; +import gg.modl.backend.ticket.service.TicketService; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import gg.modl.backend.infrastructure.util.IdGenerator; +import gg.modl.backend.infrastructure.util.UuidUtils; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PunishmentMutationService { + private final PlayerMongoRepository playerRepository; + private final PunishmentMongoRepository punishmentRepository; + private final TicketService ticketService; + private final AppealWorkflowTransitionService appealWorkflowTransitionService; + private final IssuerNameResolver issuerNameResolver; + private final StaffMongoRepository staffRepository; + private final PunishmentQueryService punishmentQueryService; + private final PunishmentLifecycleService punishmentLifecycleService; + private final PunishmentRealtimePublisher realtimePublisher; + + public Player addModification(Server server, UUID playerUuid, String punishmentId, AddModificationRequest request) { + Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) + .orElseThrow(() -> new ResourceNotFoundException("Player not found")); + + Date now = new Date(); + String modIssuerName = PunishmentMapper.storedName(request.issuerId(), request.issuerName()); + String modIssuerId = request.issuerId(); + + PunishmentModification modification = new PunishmentModification( + IdGenerator.generateShortId(), + request.type(), + now, + modIssuerName, + modIssuerId, + request.reason() != null ? request.reason() : "", + request.effectiveDuration(), + request.appealTicketId(), + null + ); + + Punishment punishment = PunishmentQueryService.findPunishment(player, punishmentId); + if (punishment == null) { + throw new ResourceNotFoundException("Punishment not found"); + } + + syncLinkedAppealOutcome(server, punishmentId, request); + + punishment.getModifications().add(modification); + punishmentRepository.appendModification(server, playerUuid.toString(), punishmentId, modification); + if (request.effectiveDuration() != null && punishment.getStarted() == null) { + punishment.setStarted(now); + punishmentRepository.setPunishmentStartedIfUnset(server, playerUuid.toString(), punishmentId, now); + } + realtimePublisher.punishmentModified(server, player, punishment); + return player; + } + + private void syncLinkedAppealOutcome(Server server, String punishmentId, AddModificationRequest request) { + if (request.appealTicketId() == null) { + return; + } + PunishmentModificationType modificationType = PunishmentModificationType.fromName(request.type()); + AppealWorkflowStatus outcome = modificationType != null ? modificationType.appealOutcome() : null; + if (outcome == null) { + return; + } + String issuerName = issuerNameResolver.resolve(request.issuerId(), request.issuerName(), server); + appealWorkflowTransitionService.applyOutcomeForPunishment(server, request.appealTicketId(), punishmentId, outcome, issuerName); + } + + public PunishmentOperationResult changeDuration(Server server, String punishmentId, Long newDuration, String issuerName, String issuerId) { + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); + } + + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, issuerName); + + Punishment punishment = context.punishment(); + Date now = new Date(); + + Long effective = (newDuration == null) ? -1L : newDuration; + + PunishmentModification modification = new PunishmentModification( + IdGenerator.generateShortId(), + PunishmentModificationType.MANUAL_DURATION_CHANGE.name(), + now, + resolvedIssuerName, + issuerId, + "Duration changed", + effective, + null, + null + ); + + String durationText = newDuration == null || newDuration < 0 + ? "permanent" + : PunishmentMapper.formatDuration(newDuration, false); + PunishmentNote note = new PunishmentNote( + IdGenerator.generateShortId(), + "changed duration to " + durationText, + now, + resolvedIssuerName, + issuerId + ); + + punishment.getModifications().add(modification); + punishment.getNotes().add(note); + punishment.data().setDuration(effective); + String uuid = context.player().getMinecraftUuid().toString(); + punishmentRepository.appendDurationChange(server, uuid, punishmentId, modification, note, effective); + if (punishment.getStarted() == null) { + punishment.setStarted(now); + punishmentRepository.setPunishmentStartedIfUnset(server, uuid, punishmentId, now); + } + realtimePublisher.punishmentModified(server, context.player(), punishment); + + if (punishment.data().altBlocking()) { + int cascaded = punishmentLifecycleService.cascadeDurationChangeToLinkedBans(server, punishmentId, newDuration, issuerName); + if (cascaded > 0) { + return new PunishmentOperationResult( + PunishmentOperationStatus.SUCCESS, + "Duration changed (cascaded to " + cascaded + " linked ban" + (cascaded > 1 ? "s" : "") + ")", + true, + cascaded + 1 + ); + } + } + + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Duration changed", true, 1); + } + + public PunishmentOperationResult toggleOption(Server server, String punishmentId, String option, boolean enabled, String issuerName, String issuerId) { + PunishmentToggleOption toggleOption = PunishmentToggleOption.from(option); + if (toggleOption == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.INVALID_REQUEST, "Invalid option", false, 0); + } + + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); + } + + Punishment punishment = context.punishment(); + Date now = new Date(); + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, issuerName); + PunishmentNote note = new PunishmentNote( + IdGenerator.generateShortId(), + (enabled ? "enabled " : "disabled ") + toggleOption.displayName(), + now, + resolvedIssuerName, + issuerId + ); + toggleOption.apply(punishment.data(), enabled); + punishment.getNotes().add(note); + punishmentRepository.setPunishmentData(server, context.player().getMinecraftUuid().toString(), punishmentId, + Map.of(toggleOption.dataKey(), enabled), note); + realtimePublisher.punishmentModified(server, context.player(), punishment); + + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Option toggled", true, 1); + } + + public PunishmentOperationResult acknowledgeStatWipe(Server server, String punishmentId) { + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); + } + + if (!context.punishment().data().wipeAfterExpiry()) { + return new PunishmentOperationResult( + PunishmentOperationStatus.NO_OP, + "Stat wipe no longer enabled for this punishment", + false, + 0 + ); + } + + punishmentRepository.markStatWipeAcknowledged(server, context.player().getMinecraftUuid().toString(), + punishmentId, new Date()); + + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Stat wipe acknowledged", true, 1); + } + + public PunishmentOperationResult modifyPunishmentTickets(Server server, String punishmentId, ModifyPunishmentTicketsRequest request) { + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Failed to modify punishment tickets", false, 0); + } + + applyPunishmentTicketModifications(server, context.player(), context.punishment(), request); + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Punishment tickets modified", true, 1); + } + + public PunishmentOperationResult modifyPunishmentTickets( + Server server, + String punishmentId, + List addTicketIds, + List removeTicketIds, + boolean modifyAssociatedTickets, + String issuerName, + String issuerId + ) { + return modifyPunishmentTickets( + server, + punishmentId, + new ModifyPunishmentTicketsRequest( + addTicketIds, + removeTicketIds, + modifyAssociatedTickets, + issuerName, + issuerId + ) + ); + } + + public Player modifyPunishmentTickets(Server server, UUID playerUuid, String punishmentId, ModifyPunishmentTicketsRequest request) { + Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) + .orElseThrow(() -> new ResourceNotFoundException("Player not found")); + + Punishment punishment = player.getPunishments() + .stream() + .filter(p -> p.getId().equals(punishmentId)) + .findFirst() + .orElseThrow(() -> new ResourceNotFoundException("Punishment not found")); + + applyPunishmentTicketModifications(server, player, punishment, request); + realtimePublisher.punishmentDetailsChanged(server, player, punishment); + return player; + } + + private void applyPunishmentTicketModifications(Server server, Player player, Punishment punishment, ModifyPunishmentTicketsRequest request) { + + List currentIds = punishment.getAttachedTicketIds() != null + ? new ArrayList<>(punishment.getAttachedTicketIds()) + : new ArrayList<>(); + List originalIds = new ArrayList<>(currentIds); + + if (request.addTicketIds() != null) { + for (String id : request.addTicketIds()) { + if (!currentIds.contains(id)) { + currentIds.add(id); + } + } + } + + if (request.removeTicketIds() != null) { + currentIds.removeAll(request.removeTicketIds()); + } + + List addedIds = new ArrayList<>(currentIds); + addedIds.removeAll(originalIds); + + List removedIds = new ArrayList<>(originalIds); + removedIds.removeAll(currentIds); + + punishment.setAttachedTicketIds(currentIds); + punishmentRepository.setPunishmentTickets(server, player.getMinecraftUuid().toString(), punishment.getId(), currentIds); + + if (request.modifyAssociatedTickets()) { + String ticketIssuerName = issuerNameResolver.resolve(request.issuerId(), request.issuerName(), server); + if (!addedIds.isEmpty()) { + punishmentLifecycleService.closeAttachedTickets(server, addedIds, ticketIssuerName); + } + if (!removedIds.isEmpty()) { + reopenAttachedTickets(server, removedIds, ticketIssuerName); + } + } + } + + private void reopenAttachedTickets(Server server, List ticketIds, String issuerName) { + for (String ticketId : ticketIds) { + try { + ticketService.reopenTicketForPunishment(server, ticketId, issuerName); + } catch (Exception e) { + log.error("[TICKET_REOPEN] Failed to reopen ticket {}", ticketId, e); + } + } + } + + public void linkAppealToPunishment(Server server, String playerUuid, String punishmentId, + String appealId, PunishmentNote note) { + punishmentRepository.linkAppealToPunishment(server, UuidUtils.normalize(playerUuid), punishmentId, appealId, note); + } + + public void recordAppealRejection(Server server, String playerUuid, String punishmentId, + PunishmentNote note, String appealTicketId) { + punishmentRepository.appendAppealResultNote(server, UuidUtils.normalize(playerUuid), punishmentId, + note, "Rejected", appealTicketId); + } + + public void applyAppealApproval(Server server, String playerUuid, String punishmentId, + PunishmentModification modification, PunishmentNote note, + String appealOutcome, String appealTicketId) { + String normalizedUuid = UuidUtils.normalize(playerUuid); + punishmentRepository.applyAppealApproval(server, normalizedUuid, punishmentId, + modification, note, appealOutcome, appealTicketId); + + playerRepository.findByMinecraftUuid(server, normalizedUuid).ifPresent(player -> { + Punishment punishment = PunishmentQueryService.findPunishment(player, punishmentId); + if (punishment != null) { + realtimePublisher.punishmentModified(server, player, punishment); + } + }); + } +} diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentQueryService.java b/src/main/java/gg/modl/backend/player/service/PunishmentQueryService.java index 628a4f3..28c47ef 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentQueryService.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentQueryService.java @@ -5,19 +5,22 @@ import gg.modl.backend.database.mongo.repository.TicketMongoRepository; import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; import gg.modl.backend.player.data.Player; import gg.modl.backend.player.data.punishment.Punishment; import gg.modl.backend.player.data.punishment.PunishmentEvidence; import gg.modl.backend.player.data.punishment.PunishmentModification; -import gg.modl.backend.player.data.punishment.PunishmentData; +import gg.modl.backend.player.data.punishment.PunishmentDataView; import gg.modl.backend.player.data.punishment.PunishmentNote; import gg.modl.backend.player.data.punishment.PunishmentStatus; import gg.modl.backend.player.dto.response.PunishmentPreviewResponse; import gg.modl.backend.player.dto.response.PunishmentPreviewView; import gg.modl.backend.player.dto.response.PunishmentResponse; import gg.modl.backend.player.dto.response.PunishmentSearchResult; +import gg.modl.backend.player.dto.response.AppealEligibility; +import gg.modl.backend.player.dto.response.AppealInfoView; +import gg.modl.backend.player.dto.response.LinkedBanView; +import gg.modl.backend.player.dto.response.PunishmentView; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.DefaultPunishmentTypes; import gg.modl.backend.settings.data.DurationDetail; @@ -27,6 +30,7 @@ import gg.modl.backend.settings.service.OffenderThresholdSettingsService; import gg.modl.backend.settings.service.PunishmentTypeIndex; import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.backend.infrastructure.util.UuidUtils; import gg.modl.backend.storage.service.EvidenceUploadTokenService; import gg.modl.backend.ticket.data.Ticket; import gg.modl.backend.ticket.data.TicketStatus; @@ -34,7 +38,6 @@ import java.util.Date; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -55,7 +58,6 @@ public class PunishmentQueryService { private final OffenderThresholdSettingsService thresholdSettingsService; private final EvidenceUploadTokenService evidenceUploadTokenService; private final IssuerNameResolver issuerNameResolver; - private final StaffMongoRepository staffRepository; private final TicketMongoRepository ticketRepository; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final int RECENT_PUNISHMENT_SCAN_LIMIT = 500; @@ -78,18 +80,18 @@ public List getPlayerPunishmentResponses(Server server, Play } private List toPunishmentResponses(Server server, List punishments) { - Map resolvedIssuers = resolveIssuersForPunishments(server, punishments); + Map resolvedIssuers = issuerNameResolver.resolveForPunishments(server, punishments); return punishments.stream() .map(punishment -> buildPunishmentResponse(server, punishment, null, resolvedIssuers)) .toList(); } private PunishmentResponse toPunishmentResponseWithPlayer(Server server, Punishment punishment, Player player) { - return buildPunishmentResponse(server, punishment, player, resolveIssuersForPunishment(server, punishment)); + return buildPunishmentResponse(server, punishment, player, issuerNameResolver.resolveForPunishments(server, List.of(punishment))); } private PunishmentResponse buildPunishmentResponse(Server server, Punishment punishment, Player player, Map resolvedIssuers) { - Map data = punishment.getData(); + PunishmentDataView data = punishment.data(); boolean active = statusCalculator.isPunishmentActive(punishment); Date expires = statusCalculator.getEffectiveExpiry(punishment); @@ -109,15 +111,15 @@ private PunishmentResponse buildPunishmentResponse(Server server, Punishment pun punishment.getIssued(), punishment.getStarted(), punishmentTypeService.isAppealable(server, ordinal), - PunishmentData.getReason(data), - PunishmentData.getSeverity(data), - data != null ? resolveOffenderStatus(data) : null, + data.reason(), + data.severity(), + data.asMap() != null ? resolveOffenderStatus(data) : null, active, expires, playerUuid, playerUsername, - data != null ? PunishmentData.isAltBlocking(data) : null, - data != null ? PunishmentData.isWipeAfterExpiry(data) : null, + data.asMap() != null ? data.altBlocking() : null, + data.asMap() != null ? data.wipeAfterExpiry() : null, effectiveCategory, resolveModifications(punishment.getModifications(), resolvedIssuers), resolveNotes(punishment.getNotes(), resolvedIssuers), @@ -126,27 +128,18 @@ private PunishmentResponse buildPunishmentResponse(Server server, Punishment pun ); } - private Map resolveIssuersForPunishment(Server server, Punishment punishment) { - Set ids = collectIssuerIds(punishment); - if (ids.isEmpty()) { - return Map.of(); - } - return issuerNameResolver.batchResolve(ids, server); - } - - private static String resolveOffenderStatus(Map data) { - String status = PunishmentData.getStatus(data); + private static String resolveOffenderStatus(PunishmentDataView data) { + String status = data.status(); if (status != null && !PunishmentStatus.UNSTARTED.equals(status) && !PunishmentStatus.PARDONED.equals(status)) { return status; } - // Backward compat: map legacy offenseLevel to display status - String offenseLevel = PunishmentData.getOffenseLevel(data); + String offenseLevel = data.offenseLevel(); if (offenseLevel != null) { - return switch (offenseLevel.toLowerCase()) { + return switch (offenseLevel.toLowerCase(Locale.ROOT)) { case "first" -> "low"; - default -> offenseLevel; // "medium" and "habitual" stay as-is + default -> offenseLevel; }; } return null; @@ -199,14 +192,12 @@ private static List resolveEvidence(List )).toList(); } - public Optional> getMinecraftPunishmentById(Server server, String punishmentId) { + public Optional getMinecraftPunishmentById(Server server, String punishmentId) { return findPunishmentContext(server, punishmentId).map(context -> { List types = punishmentTypeService.getPunishmentTypes(server); - Map resolvedIssuers = resolveIssuersForPunishment(server, context.punishment()); - Map result = PunishmentMapper.toPunishmentMap(context.punishment(), types, resolvedIssuers); - result.put("playerUuid", context.player().getMinecraftUuid().toString()); - result.put("playerName", getLatestUsername(context.player())); - return result; + Map resolvedIssuers = issuerNameResolver.resolveForPunishments(server, List.of(context.punishment())); + return PunishmentMapper.toPunishmentView(context.punishment(), types, resolvedIssuers) + .withPlayer(context.player().getMinecraftUuid().toString(), getLatestUsername(context.player())); }); } @@ -255,12 +246,9 @@ public List searchPunishments(Server server, String sear boolean matches = punishment.getId().contains(searchQuery) || pattern.matcher(resolvedIssuerName).find(); - Map pData = punishment.getData(); - if (pData != null) { - String reason = PunishmentData.getReason(pData); - if (reason != null && pattern.matcher(reason).find()) { - matches = true; - } + String reason = punishment.data().reason(); + if (reason != null && pattern.matcher(reason).find()) { + matches = true; } if (matches) { @@ -282,7 +270,7 @@ public List searchPunishments(Server server, String sear return results; } - public List> getRecentPunishments(Server server, int hours) { + public List getRecentPunishments(Server server, int hours) { Date cutoff = new Date(System.currentTimeMillis() - (hours * 60L * 60L * 1000L)); List players = punishmentRepository.findWithPunishmentsIssuedAfter(server, cutoff, RECENT_PUNISHMENT_SCAN_LIMIT); List types = punishmentTypeService.getPunishmentTypes(server); @@ -299,31 +287,18 @@ public List> getRecentPunishments(Server server, int hours) } Map resolvedIssuers = issuerIds.isEmpty() ? Map.of() : issuerNameResolver.batchResolve(issuerIds, server); - List> punishments = new ArrayList<>(); + List punishments = new ArrayList<>(); for (PunishmentContext entry : recent) { - Map punishmentMap = PunishmentMapper.toPunishmentMap(entry.punishment(), types, resolvedIssuers); - punishmentMap.put("playerName", getLatestUsername(entry.player())); - punishmentMap.put("playerUuid", entry.player().getMinecraftUuid().toString()); - punishments.add(punishmentMap); + punishments.add(PunishmentMapper.toPunishmentView(entry.punishment(), types, resolvedIssuers) + .withPlayer(entry.player().getMinecraftUuid().toString(), getLatestUsername(entry.player()))); } - punishments.sort((left, right) -> ((Date) right.get("issued")).compareTo((Date) left.get("issued"))); + punishments.sort((left, right) -> right.issued().compareTo(left.issued())); return punishments.size() > 100 ? punishments.subList(0, 100) : punishments; } - private Map resolveIssuersForPunishments(Server server, List punishments) { - Set ids = new HashSet<>(); - for (Punishment p : punishments) { - ids.addAll(collectIssuerIds(p)); - } - if (ids.isEmpty()) { - return Map.of(); - } - return issuerNameResolver.batchResolve(ids, server); - } - - public List> getLinkedBansForParent(Server server, String parentPunishmentId) { - List> results = new ArrayList<>(); + public List getLinkedBansForParent(Server server, String parentPunishmentId) { + List results = new ArrayList<>(); List players = punishmentRepository.findByLinkedBanId(server, parentPunishmentId); for (Player player : players) { @@ -331,15 +306,14 @@ public List> getLinkedBansForParent(Server server, String pa for (Punishment punishment : player.getPunishments()) { if (punishment.getTypeOrdinal() == Punishment.LINKED_BAN_TYPE_ORDINAL && - punishment.getData() != null && - parentPunishmentId.equals(punishment.getData().get("linkedBanId"))) { - - Map entry = new LinkedHashMap<>(); - entry.put("punishmentId", punishment.getId()); - entry.put("playerUuid", player.getMinecraftUuid().toString()); - entry.put("playerName", username); - entry.put("active", statusCalculator.isPunishmentActive(punishment)); - results.add(entry); + parentPunishmentId.equals(punishment.data().linkedBanId())) { + + results.add(new LinkedBanView( + punishment.getId(), + player.getMinecraftUuid().toString(), + username, + statusCalculator.isPunishmentActive(punishment) + )); } } } @@ -348,7 +322,7 @@ public List> getLinkedBansForParent(Server server, String pa } public PunishmentPreviewView previewPunishment(Server server, String playerUuid, int typeOrdinal) { - Player player = playerRepository.findByMinecraftUuid(server, normalizeUuid(playerUuid)).orElse(null); + Player player = playerRepository.findByMinecraftUuid(server, UuidUtils.normalize(playerUuid)).orElse(null); if (player == null) { return PunishmentPreviewResponse.error("Player not found"); } @@ -368,10 +342,7 @@ public PunishmentPreviewView previewPunishment(Server server, String playerUuid, String socialOffenderLevel = thresholds.getSocialOffenderLevel(currentStatus.socialPoints()); String gameplayOffenderLevel = thresholds.getGameplayOffenderLevel(currentStatus.gameplayPoints()); - String displayStatus = switch (offenseLevel) { - case "first" -> "low"; - default -> offenseLevel; // "medium" and "habitual" stay as-is - }; + String displayStatus = PunishmentMapper.offenseDisplayStatus(offenseLevel); PunishmentPreviewResponse.PunishmentPreviewResponseBuilder builder = PunishmentPreviewResponse.builder() .status(200) @@ -452,7 +423,7 @@ public Optional createEvidenceUploadToken(Server server, String punishme )); } - public Optional> getPublicPunishmentWithAppealEligibility(Server server, String punishmentId) { + public Optional getPublicPunishmentWithAppealEligibility(Server server, String punishmentId) { PunishmentContext context = findPunishmentContext(server, punishmentId).orElse(null); if (context == null) { return Optional.empty(); @@ -461,18 +432,11 @@ public Optional> getPublicPunishmentWithAppealEligibility(Se PunishmentResponse punishment = toPunishmentResponseWithPlayer(server, context.punishment(), context.player()); if (punishment.started() == null) { - return Optional.of(Map.of("error", "This punishment has not been started yet and cannot be appealed at this time.")); + return Optional.of(new AppealEligibility.NotStarted( + "This punishment has not been started yet and cannot be appealed at this time.")); } - Map response = new HashMap<>(); - response.put("id", punishment.id()); - response.put("type", punishment.type()); - response.put("issued", punishment.issued()); - response.put("expires", punishment.expires()); - response.put("active", punishment.active()); - response.put("appealable", punishment.isAppealable()); - response.put("playerUuid", context.player().getMinecraftUuid().toString()); - + Map existingAppeal = null; List existingAppeals = ticketRepository.findAppealsByPunishmentId(server, punishmentId); if (!existingAppeals.isEmpty()) { Ticket latestAppeal = existingAppeals.stream() @@ -491,7 +455,7 @@ public Optional> getPublicPunishmentWithAppealEligibility(Se return leftDate.compareTo(rightDate); }) .orElse(existingAppeals.get(0)); - Map existingAppeal = new HashMap<>(); + existingAppeal = new HashMap<>(); existingAppeal.put("id", latestAppeal.getId()); existingAppeal.put("submittedDate", latestAppeal.getCreated()); existingAppeal.put("status", latestAppeal.getStatus() != null ? latestAppeal.getStatus().getId() : TicketStatus.OPEN.getId()); @@ -499,17 +463,25 @@ public Optional> getPublicPunishmentWithAppealEligibility(Se existingAppeal.put("appealWorkflowStatus", latestAppeal.getAppealWorkflowStatus().getId()); } existingAppeal.put("locked", latestAppeal.isLocked()); - response.put("existingAppeal", existingAppeal); } - Optional punishmentType = punishmentTypeService.getPunishmentTypeByOrdinal(server, punishment.typeOrdinal()); - response.put("appealForm", punishmentType + Map appealForm = punishmentTypeService.getPunishmentTypeByOrdinal(server, punishment.typeOrdinal()) .map(PunishmentType::getAppealForm) .filter(form -> form.getFields() != null && !form.getFields().isEmpty()) .map(form -> OBJECT_MAPPER.convertValue(form, new TypeReference>() {})) - .orElse(null)); + .orElse(null); - return Optional.of(response); + return Optional.of(new AppealEligibility.Eligible(new AppealInfoView( + punishment.id(), + punishment.type(), + punishment.issued(), + punishment.expires(), + punishment.active(), + punishment.isAppealable(), + context.player().getMinecraftUuid().toString(), + existingAppeal, + appealForm + ))); } public PunishmentResponse getPunishmentById(Server server, String punishmentId) { @@ -549,8 +521,4 @@ public record PunishmentOperationResult( int affectedCount ) { } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } } diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentRealtimePublisher.java b/src/main/java/gg/modl/backend/player/service/PunishmentRealtimePublisher.java index fbd7364..7712da8 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentRealtimePublisher.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentRealtimePublisher.java @@ -2,6 +2,8 @@ import gg.modl.backend.player.data.Player; import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.dto.response.SimplePunishmentView; +import gg.modl.backend.player.dto.response.SyncPunishmentEntry; import gg.modl.backend.realtime.publish.RealtimeEventPublisher; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.PunishmentType; @@ -10,10 +12,8 @@ import gg.modl.proto.modl.v1.SyncModifiedPunishment; import gg.modl.proto.modl.v1.SyncPendingPunishment; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -116,38 +116,30 @@ private SyncModifiedPunishment toModified(Server server, Player player, Punishme return syncProtoFactory.toModifiedPunishment(toEntry(server, player, punishment)); } - private Map toEntry(Server server, Player player, Punishment punishment) { + private SyncPunishmentEntry toEntry(Server server, Player player, Punishment punishment) { List types = punishmentTypeService.getPunishmentTypes(server); - Map resolvedIssuers = resolveIssuers(server, punishment); - Map simplePunishment = + Map resolvedIssuers = issuerNameResolver.resolveForPunishments(server, List.of(punishment)); + SimplePunishmentView simplePunishment = PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers); - return Map.of( - "minecraftUuid", player.getMinecraftUuid().toString(), - "username", PlayerDataUtils.extractLatestUsername(player.getUsernames()), - "punishment", simplePunishment + return new SyncPunishmentEntry( + player.getMinecraftUuid().toString(), + PlayerDataUtils.extractLatestUsername(player.getUsernames()), + simplePunishment ); } - private Map toEntry(Server server, EntryPunishment change) { + private SyncPunishmentEntry toEntry(Server server, EntryPunishment change) { List types = punishmentTypeService.getPunishmentTypes(server); - Map resolvedIssuers = resolveIssuers(server, change.punishment()); - Map simplePunishment = + Map resolvedIssuers = issuerNameResolver.resolveForPunishments(server, List.of(change.punishment())); + SimplePunishmentView simplePunishment = PunishmentMapper.toSimplePunishment(change.punishment(), types, statusCalculator, resolvedIssuers); - return Map.of( - "minecraftUuid", change.minecraftUuid(), - "username", change.username(), - "punishment", simplePunishment + return new SyncPunishmentEntry( + change.minecraftUuid(), + change.username(), + simplePunishment ); } - private Map resolveIssuers(Server server, Punishment punishment) { - Set ids = new HashSet<>(PunishmentQueryService.collectIssuerIds(punishment)); - if (ids.isEmpty()) { - return Map.of(); - } - return issuerNameResolver.batchResolve(ids, server); - } - public record PlayerPunishment(Player player, Punishment punishment) { } diff --git a/src/main/java/gg/modl/backend/player/service/SyncProtoFactory.java b/src/main/java/gg/modl/backend/player/service/SyncProtoFactory.java index b321843..4fdbd22 100644 --- a/src/main/java/gg/modl/backend/player/service/SyncProtoFactory.java +++ b/src/main/java/gg/modl/backend/player/service/SyncProtoFactory.java @@ -2,6 +2,10 @@ import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; import gg.modl.backend.player.controller.MinecraftPlayerProtoMapper; +import gg.modl.backend.player.dto.response.SimplePunishmentView; +import gg.modl.backend.player.dto.response.SyncDataView; +import gg.modl.backend.player.dto.response.SyncPunishmentEntry; +import gg.modl.backend.player.dto.response.SyncResult; import gg.modl.proto.modl.v1.SyncActiveStaffMember; import gg.modl.proto.modl.v1.SyncData; import gg.modl.proto.modl.v1.SyncMigrationTask; @@ -18,68 +22,71 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Consumer; import java.util.function.LongConsumer; import org.springframework.stereotype.Component; @Component public class SyncProtoFactory { - public SyncResponse toSyncResponse(Map body) { + public SyncResponse toSyncResponse(SyncResult body) { return SyncResponse.newBuilder() - .setTimestamp(stringValue(body.get("timestamp"))) - .setData(toSyncData(map(body.get("data")))) + .setTimestamp(stringValue(body.timestamp())) + .setData(toSyncData(body.data())) .build(); } - public SyncData toSyncData(Map data) { + public SyncData toSyncData(SyncDataView data) { SyncData.Builder builder = SyncData.newBuilder(); - listOfMaps(data.get("pendingPunishments")).stream() + data.pendingPunishments().stream() .map(this::toPendingPunishment) .forEach(builder::addPendingPunishments); - listOfMaps(data.get("recentlyStartedPunishments")).stream() + data.recentlyStartedPunishments().stream() .map(this::toPendingPunishment) .forEach(builder::addRecentlyStartedPunishments); - listOfMaps(data.get("recentlyModifiedPunishments")).stream() + data.recentlyModifiedPunishments().stream() .map(this::toModifiedPunishment) .forEach(builder::addRecentlyModifiedPunishments); - listOfMaps(data.get("playerNotifications")).stream() + data.playerNotifications().stream() .map(this::toPlayerNotification) .forEach(builder::addPlayerNotifications); - listOfMaps(data.get("activeStaffMembers")).stream() + data.activeStaffMembers().stream() .map(this::toActiveStaffMember) .forEach(builder::addActiveStaffMembers); - listOfMaps(data.get("staffNotifications")).stream() + data.staffNotifications().stream() .map(this::toStaffNotification) .forEach(builder::addStaffNotifications); - listOfMaps(data.get("pendingStatWipes")).stream() + data.pendingStatWipes().stream() .map(this::toPendingStatWipe) .forEach(builder::addPendingStatWipes); - listOfMaps(data.get("staff2faVerifications")).stream() - .map(this::toStaff2faVerification) - .forEach(builder::addStaff2FaVerifications); + if (data.staff2faVerifications() != null) { + data.staff2faVerifications().stream() + .map(this::toStaff2faVerification) + .forEach(builder::addStaff2FaVerifications); + } - if (data.get("migrationTask") instanceof Map migrationTask) { - builder.setMigrationTask(toMigrationTask(stringObjectMap(migrationTask))); + if (data.migrationTask() != null) { + builder.setMigrationTask(toMigrationTask(data.migrationTask())); } - setOptionalLong(builder::setStaffPermissionsUpdatedAt, data.get("staffPermissionsUpdatedAt")); - setOptionalLong(builder::setPunishmentTypesUpdatedAt, data.get("punishmentTypesUpdatedAt")); + setOptionalLong(builder::setStaffPermissionsUpdatedAt, data.staffPermissionsUpdatedAt()); + setOptionalLong(builder::setPunishmentTypesUpdatedAt, data.punishmentTypesUpdatedAt()); return builder.build(); } - public SyncPendingPunishment toPendingPunishment(Map entry) { + public SyncPendingPunishment toPendingPunishment(SyncPunishmentEntry entry) { return SyncPendingPunishment.newBuilder() - .setMinecraftUuid(stringValue(entry.get("minecraftUuid"))) - .setUsername(stringValue(entry.get("username"))) - .setPunishment(MinecraftPlayerProtoMapper.toSimplePunishment(map(entry.get("punishment")))) + .setMinecraftUuid(stringValue(entry.minecraftUuid())) + .setUsername(stringValue(entry.username())) + .setPunishment(MinecraftPlayerProtoMapper.toSimplePunishment(entry.punishment())) .build(); } - public SyncModifiedPunishment toModifiedPunishment(Map entry) { + public SyncModifiedPunishment toModifiedPunishment(SyncPunishmentEntry entry) { return SyncModifiedPunishment.newBuilder() - .setMinecraftUuid(stringValue(entry.get("minecraftUuid"))) - .setUsername(stringValue(entry.get("username"))) - .setPunishment(toPunishmentWithModifications(map(entry.get("punishment")))) + .setMinecraftUuid(stringValue(entry.minecraftUuid())) + .setUsername(stringValue(entry.username())) + .setPunishment(toPunishmentWithModifications(entry.punishment())) .build(); } @@ -149,22 +156,25 @@ public SyncMigrationTask toMigrationTask(Map task) { .build(); } - private SyncPunishmentWithModifications toPunishmentWithModifications(Map punishment) { - SyncPunishmentWithModifications.Builder builder = SyncPunishmentWithModifications.newBuilder() - .setId(stringValue(punishment.get("id"))); + private SyncPunishmentWithModifications toPunishmentWithModifications(SimplePunishmentView punishment) { + SyncPunishmentWithModifications.Builder builder = SyncPunishmentWithModifications.newBuilder(); + if (punishment == null) { + return builder.build(); + } - listOfMaps(punishment.get("modifications")).stream() + builder.setId(stringValue(punishment.id())); + punishment.modifications().stream() .map(this::toPunishmentModification) .forEach(builder::addModifications); return builder.build(); } - private SyncPunishmentModification toPunishmentModification(Map modification) { + private SyncPunishmentModification toPunishmentModification(SimplePunishmentView.Modification modification) { SyncPunishmentModification.Builder builder = SyncPunishmentModification.newBuilder() - .setType(stringValue(modification.get("type"))); + .setType(stringValue(modification.type())); - setOptionalLong(builder::setTimestamp, modification.get("timestamp")); - setOptionalLong(builder::setEffectiveDuration, modification.get("effectiveDuration")); + setOptionalLong(builder::setTimestamp, modification.timestamp()); + setOptionalLong(builder::setEffectiveDuration, modification.effectiveDuration()); return builder.build(); } @@ -175,24 +185,6 @@ private static List list(Object object) { return List.of(); } - private static List> listOfMaps(Object object) { - return list(object).stream() - .filter(Map.class::isInstance) - .map(value -> { - @SuppressWarnings("unchecked") - Map map = (Map) value; - return map; - }) - .toList(); - } - - private static Map map(Object object) { - if (object instanceof Map rawMap) { - return stringObjectMap(rawMap); - } - return Map.of(); - } - private static Map stringObjectMap(Map rawMap) { Map result = new LinkedHashMap<>(); rawMap.forEach((key, value) -> result.put(Objects.toString(key), value)); @@ -213,7 +205,7 @@ private static long longValue(Object value) { return 0L; } - private static void setOptionalString(java.util.function.Consumer setter, Object value) { + private static void setOptionalString(Consumer setter, Object value) { if (value != null) { setter.accept(Objects.toString(value)); } @@ -225,7 +217,7 @@ private static void setOptionalLong(LongConsumer setter, Object value) { } } - private static void setOptionalBoolean(java.util.function.Consumer setter, Object value) { + private static void setOptionalBoolean(Consumer setter, Object value) { if (value instanceof Boolean bool) { setter.accept(bool); } diff --git a/src/main/java/gg/modl/backend/player/service/SyncStaffEventService.java b/src/main/java/gg/modl/backend/player/service/SyncStaffEventService.java index ee98a3b..bdb2f6f 100644 --- a/src/main/java/gg/modl/backend/player/service/SyncStaffEventService.java +++ b/src/main/java/gg/modl/backend/player/service/SyncStaffEventService.java @@ -2,11 +2,13 @@ import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; import gg.modl.backend.database.mongo.repository.TicketMongoRepository; -import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.server.service.PanelDomainResolver; import gg.modl.backend.player.data.Player; import gg.modl.backend.player.data.punishment.EnforcementCategory; import gg.modl.backend.player.data.punishment.Punishment; import gg.modl.backend.player.data.punishment.PunishmentModificationType; +import gg.modl.backend.player.dto.response.SimplePunishmentView; +import gg.modl.backend.player.dto.response.SyncPunishmentEntry; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.PunishmentType; import gg.modl.backend.settings.service.PunishmentTypeIndex; @@ -31,14 +33,14 @@ public class SyncStaffEventService { private final TicketMongoRepository ticketRepository; private final PunishmentMongoRepository punishmentRepository; private final IssuerNameResolver issuerNameResolver; - private final ModlProperties modlProperties; + private final PanelDomainResolver panelDomainResolver; private final PlayerStatusCalculator statusCalculator; public List> collectStaffEvents( Server server, Instant lastSync, List types, - List> recentlyModifiedPunishments + List recentlyModifiedPunishments ) { List> notifications = new ArrayList<>(); collectTicketNotifications(server, lastSync, notifications); @@ -102,10 +104,7 @@ private void collectTicketNotifications(Server server, Instant lastSync, List> recentlyModifiedPunishments, List> notifications) { + private void collectPardonNotifications(List recentlyModifiedPunishments, List> notifications) { try { - for (Map modified : recentlyModifiedPunishments) { - if (!(modified.get("punishment") instanceof Map rawPunishment)) { - continue; - } - Map punishment = (Map) rawPunishment; - String username = modified.get("username") instanceof String value ? value : "Unknown"; + for (SyncPunishmentEntry modified : recentlyModifiedPunishments) { + SimplePunishmentView punishment = modified.punishment(); + String username = modified.username() != null ? modified.username() : "Unknown"; - if (!(punishment.get("modifications") instanceof List rawModifications)) { - continue; - } - - for (Object rawModification : rawModifications) { - if (!(rawModification instanceof Map rawModificationMap)) { + for (SimplePunishmentView.Modification modification : punishment.modifications()) { + if (!PunishmentModificationType.isPardon(modification.type())) { continue; } - Map modification = (Map) rawModificationMap; - String type = modification.get("type") instanceof String value ? value : null; - if (PunishmentModificationType.isPardon(type)) { - String pardoner = modification.get("issuerName") instanceof String value ? value : "System"; - String punishmentType = punishment.get("type") instanceof String value ? value : "punishment"; - Object timestamp = modification.get("timestamp"); - Map notification = new LinkedHashMap<>(); - notification.put("id", "pardon_" + punishment.get("id")); - notification.put("type", "PUNISHMENT_PARDONED"); - notification.put("message", pardoner + ": pardoned " + username + "'s " + punishmentType); - notification.put("timestamp", timestamp != null ? timestamp : System.currentTimeMillis()); - notifications.add(notification); - } + String pardoner = modification.issuerName() != null ? modification.issuerName() : "System"; + String punishmentType = punishment.type() != null ? punishment.type() : "punishment"; + Long timestamp = modification.timestamp(); + Map notification = new LinkedHashMap<>(); + notification.put("id", "pardon_" + punishment.id()); + notification.put("type", "PUNISHMENT_PARDONED"); + notification.put("message", pardoner + ": pardoned " + username + "'s " + punishmentType); + notification.put("timestamp", timestamp != null ? timestamp : System.currentTimeMillis()); + notifications.add(notification); } } } catch (Exception e) { diff --git a/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentController.java b/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentController.java index 17f6dc8..4b07707 100644 --- a/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentController.java +++ b/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentController.java @@ -1,38 +1,41 @@ -package gg.modl.backend.punishment.controller; - -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.player.service.PunishmentQueryService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PUBLIC_PUNISHMENT) -@RequiredArgsConstructor -public class PublicPunishmentController { - private final PunishmentQueryService punishmentQueryService; - - @GetMapping("/{punishmentId}/appeal-info") - public ResponseEntity getAppealInfo( - @PathVariable String punishmentId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Map result = punishmentQueryService.getPublicPunishmentWithAppealEligibility(server, punishmentId) - .orElseThrow(() -> new ResourceNotFoundException("Punishment not found")); - if (result.containsKey("error")) { - return ResponseEntity.badRequest().body(result); - } - - return ResponseEntity.ok(PublicPunishmentProtoMapper.toAppealInfo(result)); - } -} +package gg.modl.backend.punishment.controller; + +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.player.dto.response.AppealEligibility; +import gg.modl.backend.player.service.PunishmentQueryService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PUBLIC_PUNISHMENT) +@RequiredArgsConstructor +public class PublicPunishmentController { + private final PunishmentQueryService punishmentQueryService; + + @GetMapping("/{punishmentId}/appeal-info") + public ResponseEntity getAppealInfo( + @PathVariable String punishmentId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + AppealEligibility eligibility = punishmentQueryService.getPublicPunishmentWithAppealEligibility(server, punishmentId) + .orElseThrow(() -> new ResourceNotFoundException("Punishment not found")); + + return switch (eligibility) { + case AppealEligibility.NotStarted notStarted -> + ResponseEntity.badRequest().body(Map.of("error", notStarted.message())); + case AppealEligibility.Eligible eligible -> + ResponseEntity.ok(PublicPunishmentProtoMapper.toAppealInfo(eligible.info())); + }; + } +} diff --git a/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentProtoMapper.java b/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentProtoMapper.java index 06b6706..02cca41 100644 --- a/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentProtoMapper.java +++ b/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentProtoMapper.java @@ -1,52 +1,41 @@ package gg.modl.backend.punishment.controller; import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; +import gg.modl.backend.player.dto.response.AppealInfoView; import gg.modl.proto.modl.v1.PublicPunishmentAppealInfoResponse; import java.util.Date; -import java.util.Map; -import java.util.Objects; final class PublicPunishmentProtoMapper { private PublicPunishmentProtoMapper() { } - static PublicPunishmentAppealInfoResponse toAppealInfo(Map appealInfo) { + static PublicPunishmentAppealInfoResponse toAppealInfo(AppealInfoView appealInfo) { PublicPunishmentAppealInfoResponse.Builder builder = PublicPunishmentAppealInfoResponse.newBuilder() - .setId(string(appealInfo.get("id"))) - .setType(string(appealInfo.get("type"))) - .setActive(Boolean.TRUE.equals(appealInfo.get("active"))) - .setAppealable(Boolean.TRUE.equals(appealInfo.get("appealable"))) - .setPlayerUuid(string(appealInfo.get("playerUuid"))); + .setId(ProtoMapperSupport.stringValue(appealInfo.id())) + .setType(ProtoMapperSupport.stringValue(appealInfo.type())) + .setActive(appealInfo.active()) + .setAppealable(appealInfo.appealable()) + .setPlayerUuid(ProtoMapperSupport.stringValue(appealInfo.playerUuid())); - Long issued = epochMillis(appealInfo.get("issued")); + Long issued = epochMillis(appealInfo.issued()); if (issued != null) { builder.setIssued(issued); } - Long expires = epochMillis(appealInfo.get("expires")); + Long expires = epochMillis(appealInfo.expires()); if (expires != null) { builder.setExpires(expires); } - if (appealInfo.get("existingAppeal") instanceof Map existingAppeal) { - builder.setExistingAppeal(ProtoMapperSupport.legacyStruct(ProtoMapperSupport.stringObjectMap(existingAppeal))); + if (appealInfo.existingAppeal() != null) { + builder.setExistingAppeal(ProtoMapperSupport.legacyStruct(appealInfo.existingAppeal())); } - if (appealInfo.get("appealForm") instanceof Map appealForm) { - builder.setAppealForm(ProtoMapperSupport.legacyStruct(ProtoMapperSupport.stringObjectMap(appealForm))); + if (appealInfo.appealForm() != null) { + builder.setAppealForm(ProtoMapperSupport.legacyStruct(appealInfo.appealForm())); } return builder.build(); } - private static Long epochMillis(Object value) { - if (value instanceof Date date) { - return date.getTime(); - } - if (value instanceof Number number) { - return number.longValue(); - } - return null; - } - - private static String string(Object value) { - return value == null ? "" : Objects.toString(value); + private static Long epochMillis(Date value) { + return value != null ? value.getTime() : null; } } diff --git a/src/main/java/gg/modl/backend/realtime/auth/RealtimeTopicAuthorizer.java b/src/main/java/gg/modl/backend/realtime/auth/RealtimeTopicAuthorizer.java index 6e7c44f..6099123 100644 --- a/src/main/java/gg/modl/backend/realtime/auth/RealtimeTopicAuthorizer.java +++ b/src/main/java/gg/modl/backend/realtime/auth/RealtimeTopicAuthorizer.java @@ -3,7 +3,7 @@ import gg.modl.backend.role.service.PermissionService; import gg.modl.backend.server.data.Server; import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.staff.service.StaffService; +import gg.modl.backend.staff.service.StaffLookupCache; import gg.modl.proto.modl.v1.Topic; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -13,7 +13,7 @@ @RequiredArgsConstructor public class RealtimeTopicAuthorizer { private final PermissionService permissionService; - private final StaffService staffService; + private final StaffLookupCache staffLookupCache; public boolean canSubscribe(RealtimePrincipal principal, Topic topic) { if (topic == null || topic == Topic.TOPIC_UNSPECIFIED) { @@ -57,7 +57,7 @@ private boolean hasPanelPermission(RealtimePrincipal principal, String permissio return true; } - Optional staffOpt = staffService.getStaffByEmail(server, email); + Optional staffOpt = staffLookupCache.findByEmail(server, email); String roleId = staffOpt.map(Staff::getRoleId).orElse(null); return roleId != null && permissionService.hasPermission(server, roleId, permission); } diff --git a/src/main/java/gg/modl/backend/realtime/publish/RealtimeEventPublisher.java b/src/main/java/gg/modl/backend/realtime/publish/RealtimeEventPublisher.java index 6bf8dd1..4b057ec 100644 --- a/src/main/java/gg/modl/backend/realtime/publish/RealtimeEventPublisher.java +++ b/src/main/java/gg/modl/backend/realtime/publish/RealtimeEventPublisher.java @@ -29,6 +29,10 @@ @RequiredArgsConstructor @Slf4j public class RealtimeEventPublisher { + private static final String PUNISHMENT_KEY_INFIX = "::pun::"; + private static final String STAFF_2FA_KEY_INFIX = "::2fa::"; + private static final String KEY_FIELD_SEPARATOR = "::"; + private final RealtimeEventDispatcher dispatcher; public void invalidatePanel(Server server, PanelResource resource) { @@ -113,11 +117,11 @@ private static String punishmentEventId(String serverId, List modified) { if (pending.size() == 1 && modified.isEmpty()) { var punishment = pending.get(0).getPunishment(); - return serverId + "::pun::" + punishment.getId() + "::" + punishment.getIssuedAt(); + return serverId + PUNISHMENT_KEY_INFIX + punishment.getId() + KEY_FIELD_SEPARATOR + punishment.getIssuedAt(); } if (modified.size() == 1 && pending.isEmpty()) { var punishment = modified.get(0).getPunishment(); - return serverId + "::pun::" + punishment.getId() + "::" + punishment.getModificationsCount(); + return serverId + PUNISHMENT_KEY_INFIX + punishment.getId() + KEY_FIELD_SEPARATOR + punishment.getModificationsCount(); } return null; } @@ -128,6 +132,6 @@ private static String staff2faEventId(String serverId, List= properties.getMaxUnauthenticatedConnections()) { - return Admission.REJECTED_GLOBAL; - } int current = perIpCounts.getOrDefault(ip, 0); if (current >= properties.getMaxUnauthenticatedConnectionsPerIp()) { return Admission.REJECTED_PER_IP; } + if (totalCount >= properties.getMaxUnauthenticatedConnections()) { + return Admission.REJECTED_GLOBAL; + } totalCount++; perIpCounts.put(ip, current + 1); return Admission.ADMITTED; diff --git a/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionRegistry.java b/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionRegistry.java index 14a2e83..0503031 100644 --- a/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionRegistry.java +++ b/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionRegistry.java @@ -95,15 +95,6 @@ private static RealtimeConnectionSnapshot toSnapshot(String sessionId, Connectio return new RealtimeConnectionSnapshot(sessionId, entry.session(), entry.state()); } - public void removeClosedSessions() { - connections.forEach((sessionId, entry) -> { - if (!entry.session().isOpen()) { - connections.remove(sessionId, entry); - pruneServerIndex(sessionId, entry); - } - }); - } - private void pruneServerIndex(String sessionId, ConnectionEntry entry) { String serverId = entry.state().getServerId(); if (serverId == null) { diff --git a/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionState.java b/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionState.java index e392431..dc1d397 100644 --- a/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionState.java +++ b/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionState.java @@ -65,7 +65,7 @@ public RealtimePrincipal getPrincipal() { } public RealtimeClientKind getClientKind() { - return principal.clientKind(); + return principal != null ? principal.clientKind() : null; } @Nullable @@ -78,15 +78,6 @@ public String getServerId() { return principal != null ? principal.serverId() : null; } - @Nullable - public String getUserEmail() { - return principal != null ? principal.email() : null; - } - - public int getProtocolVersion() { - return protocolVersion; - } - public Set getSubscriptions() { synchronized (subscriptions) { return Set.copyOf(subscriptions); @@ -119,11 +110,6 @@ public void recordHeartbeat() { lastHeartbeat = Instant.now(); } - @Nullable - public String getLastAcknowledgedEventId() { - return lastAcknowledgedEventId; - } - public void setLastAcknowledgedEventId(@Nullable String lastAcknowledgedEventId) { this.lastAcknowledgedEventId = lastAcknowledgedEventId; } @@ -139,12 +125,4 @@ public long recordDeliveryAttempt() { public long recordDeliveryFailure() { return deliveryFailures.incrementAndGet(); } - - public long getDeliveryAttempts() { - return deliveryAttempts.get(); - } - - public long getDeliveryFailures() { - return deliveryFailures.get(); - } } diff --git a/src/main/java/gg/modl/backend/realtime/transport/RealtimeCodec.java b/src/main/java/gg/modl/backend/realtime/transport/RealtimeCodec.java index 41279df..8ed1982 100644 --- a/src/main/java/gg/modl/backend/realtime/transport/RealtimeCodec.java +++ b/src/main/java/gg/modl/backend/realtime/transport/RealtimeCodec.java @@ -4,7 +4,6 @@ import com.google.protobuf.Timestamp; import gg.modl.backend.realtime.config.RealtimeProperties; import gg.modl.proto.modl.v1.ErrorCode; -import gg.modl.proto.modl.v1.Heartbeat; import gg.modl.proto.modl.v1.RealtimeEnvelope; import gg.modl.proto.modl.v1.ReconnectAction; import gg.modl.proto.modl.v1.ReconnectAdvice; @@ -45,13 +44,6 @@ public BinaryMessage serverHello(String connectionId, Collection accepted return toMessage(baseEnvelope().setServerHello(hello).build()); } - public BinaryMessage heartbeat(long sequence) { - Heartbeat heartbeat = Heartbeat.newBuilder() - .setSequence(sequence) - .build(); - return toMessage(baseEnvelope().setHeartbeat(heartbeat).build()); - } - public BinaryMessage error(ErrorCode code, String message) { gg.modl.proto.modl.v1.Error error = gg.modl.proto.modl.v1.Error.newBuilder() .setCode(code) @@ -70,7 +62,7 @@ public BinaryMessage deployDrainAdvice() { return toMessage(baseEnvelope().setReconnectAdvice(advice).build()); } - public RealtimeEnvelope buildOutboundEnvelope(RealtimeEnvelope envelope) { + private RealtimeEnvelope buildOutboundEnvelope(RealtimeEnvelope envelope) { RealtimeEnvelope.Builder builder = envelope.toBuilder(); if (builder.getEventId().isBlank()) { builder.setEventId(UUID.randomUUID().toString()); @@ -88,10 +80,6 @@ public RealtimeEnvelope buildOutboundEnvelope(RealtimeEnvelope envelope) { return builder.build(); } - public BinaryMessage outbound(RealtimeEnvelope envelope) { - return toMessage(buildOutboundEnvelope(envelope)); - } - public byte[] outboundPayload(RealtimeEnvelope envelope) { RealtimeEnvelope outboundEnvelope = buildOutboundEnvelope(envelope); byte[] payload = outboundEnvelope.toByteArray(); diff --git a/src/main/java/gg/modl/backend/realtime/transport/RealtimeWebSocketHandler.java b/src/main/java/gg/modl/backend/realtime/transport/RealtimeWebSocketHandler.java index 70c573c..9a9aa8b 100644 --- a/src/main/java/gg/modl/backend/realtime/transport/RealtimeWebSocketHandler.java +++ b/src/main/java/gg/modl/backend/realtime/transport/RealtimeWebSocketHandler.java @@ -195,9 +195,7 @@ private void handleAuthenticatedMessage(WebSocketSession session, RealtimeConnec } switch (envelope.getPayloadCase()) { - case HEARTBEAT -> { - state.recordHeartbeat(); - } + case HEARTBEAT -> state.recordHeartbeat(); case ACK -> { state.setLastAcknowledgedEventId(envelope.getAck().getEventId()); metrics.recordAck(state, envelope.getAck().getEventId()); diff --git a/src/main/java/gg/modl/backend/registration/PublicRegistrationController.java b/src/main/java/gg/modl/backend/registration/PublicRegistrationController.java index 599fc2e..dc4c31b 100644 --- a/src/main/java/gg/modl/backend/registration/PublicRegistrationController.java +++ b/src/main/java/gg/modl/backend/registration/PublicRegistrationController.java @@ -21,7 +21,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.Date; -import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.http.ResponseEntity; @@ -34,20 +33,33 @@ @RestController @RequestMapping(RESTMappingV1.PUBLIC_REGISTRATION) -@RequiredArgsConstructor @Profile("!staging") public class PublicRegistrationController { private static final String CLI_TURNSTILE_TOKEN_HEADER = "X-Turnstile-Token"; - @Value("${modl.registration.cli-turnstile-required:false}") - private boolean cliTurnstileRequired; - + private final boolean cliTurnstileRequired; private final ServerService serverService; private final SessionService sessionService; private final RegistrationService registrationService; private final CookieUtil cookieUtil; private final SubdomainValidator subdomainValidator; + public PublicRegistrationController( + @Value("${modl.registration.cli-turnstile-required:false}") boolean cliTurnstileRequired, + ServerService serverService, + SessionService sessionService, + RegistrationService registrationService, + CookieUtil cookieUtil, + SubdomainValidator subdomainValidator + ) { + this.cliTurnstileRequired = cliTurnstileRequired; + this.serverService = serverService; + this.sessionService = sessionService; + this.registrationService = registrationService; + this.cookieUtil = cookieUtil; + this.subdomainValidator = subdomainValidator; + } + @PostMapping public ResponseEntity register( HttpServletRequest request, diff --git a/src/main/java/gg/modl/backend/registration/RegistrationService.java b/src/main/java/gg/modl/backend/registration/RegistrationService.java index fa69b05..3de0ee3 100644 --- a/src/main/java/gg/modl/backend/registration/RegistrationService.java +++ b/src/main/java/gg/modl/backend/registration/RegistrationService.java @@ -4,6 +4,7 @@ import gg.modl.backend.infrastructure.config.ModlProperties; import gg.modl.backend.email.EmailHTMLTemplate; import gg.modl.backend.email.EmailService; +import gg.modl.backend.infrastructure.ratelimit.BucketPool; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.infrastructure.turnstile.TurnstileService; import gg.modl.backend.server.ServerService; @@ -11,9 +12,11 @@ import gg.modl.backend.server.data.Server; import gg.modl.backend.server.data.ServerPlan; import gg.modl.backend.settings.service.ApiKeySettingsService; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.ConsumptionProbe; +import java.time.Duration; import java.util.Date; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -30,32 +33,32 @@ public class RegistrationService { private final ServerService serverService; private final TurnstileService turnstileService; private final SubdomainValidator subdomainValidator; + private final BucketPool bucketPool; private static final int TOKEN_BYTE_LENGTH = 32; - private static final long RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; - private static final long CLI_RATE_LIMIT_WINDOW_MS = 30 * 60 * 1000; + private static final int REGISTRATION_CAPACITY = 1; + private static final long NANOS_PER_MINUTE = 60_000_000_000L; + private static final Duration RATE_LIMIT_WINDOW = Duration.ofMinutes(10); + private static final Duration CLI_RATE_LIMIT_WINDOW = Duration.ofMinutes(30); private static final long AUTO_LOGIN_TOKEN_EXPIRY_MS = 10 * 60 * 1000; private static final ServerPlan PUBLIC_REGISTRATION_PLAN = ServerPlan.FREE; - private final ConcurrentHashMap rateLimitMap = new ConcurrentHashMap<>(); - private final ConcurrentHashMap cliRateLimitMap = new ConcurrentHashMap<>(); - private record RateLimitResult(boolean limited, long remainingMinutes) {} public enum RegistrationChannel { - WEB(RATE_LIMIT_WINDOW_MS, "You can only register one server every 10 minutes."), - CLI(CLI_RATE_LIMIT_WINDOW_MS, "CLI registration is limited to once every 30 minutes."); + WEB(RATE_LIMIT_WINDOW, "You can only register one server every 10 minutes."), + CLI(CLI_RATE_LIMIT_WINDOW, "CLI registration is limited to once every 30 minutes."); - private final long windowMs; + private final Duration window; private final String cooldownMessage; - RegistrationChannel(long windowMs, String cooldownMessage) { - this.windowMs = windowMs; + RegistrationChannel(Duration window, String cooldownMessage) { + this.window = window; this.cooldownMessage = cooldownMessage; } - long windowMs() { - return windowMs; + Duration window() { + return window; } String cooldownMessage() { @@ -156,35 +159,15 @@ private Optional rejectDuplicateEmail(ServerService.Serve return Optional.empty(); } - private ConcurrentHashMap store(RegistrationChannel channel) { - return channel == RegistrationChannel.CLI ? cliRateLimitMap : rateLimitMap; - } - private RateLimitResult reserve(RegistrationChannel channel, String clientIp) { - return reserve(store(channel), channel.windowMs(), clientIp); - } - - private void evictExpired(ConcurrentHashMap map, long windowMs) { - long now = System.currentTimeMillis(); - map.entrySet().removeIf(entry -> (now - entry.getValue()) >= windowMs); - } - - private RateLimitResult reserve(ConcurrentHashMap map, long windowMs, String clientIp) { - evictExpired(map, windowMs); - long now = System.currentTimeMillis(); - long[] prior = { -1L }; - map.compute(clientIp, (k, last) -> { - if (last != null && (now - last) < windowMs) { - prior[0] = last; - return last; - } - prior[0] = -1L; - return now; - }); - if (prior[0] != -1L) { - return new RateLimitResult(true, (windowMs - (now - prior[0])) / 1000 / 60 + 1); - } - return new RateLimitResult(false, 0); + Bucket bucket = bucketPool.resolveBucket( + "registration:" + channel.name(), clientIp, REGISTRATION_CAPACITY, channel.window()); + ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1); + if (probe.isConsumed()) { + return new RateLimitResult(false, 0); + } + long remainingMinutes = Math.max(1, (probe.getNanosToWaitForRefill() + NANOS_PER_MINUTE - 1) / NANOS_PER_MINUTE); + return new RateLimitResult(true, remainingMinutes); } public String generateToken() { diff --git a/src/main/java/gg/modl/backend/registration/cleanup/RegistrationCleanupService.java b/src/main/java/gg/modl/backend/registration/cleanup/RegistrationCleanupService.java index 1867b72..a0f48ef 100644 --- a/src/main/java/gg/modl/backend/registration/cleanup/RegistrationCleanupService.java +++ b/src/main/java/gg/modl/backend/registration/cleanup/RegistrationCleanupService.java @@ -2,13 +2,13 @@ import gg.modl.backend.database.mongo.repository.ServerDatabaseMongoRepository; import gg.modl.backend.database.mongo.repository.ServerDatabaseMongoRepository.PlayerCollectionInspection; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerRegistrationCleanupRepository; import gg.modl.backend.registration.config.RegistrationCleanupProperties; import gg.modl.backend.role.service.PermissionService; import gg.modl.backend.server.ServerService; import gg.modl.backend.server.data.ProvisioningStatus; import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.service.StaffService; +import gg.modl.backend.staff.service.StaffLookupCache; import java.time.Clock; import java.time.Instant; import java.util.Date; @@ -18,17 +18,18 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; @Service @RequiredArgsConstructor @Slf4j public class RegistrationCleanupService { private final RegistrationCleanupProperties properties; - private final ServerMongoRepository serverRepository; + private final ServerRegistrationCleanupRepository serverRegistrationCleanupRepository; private final ServerDatabaseMongoRepository serverDatabaseRepository; private final ServerService serverService; private final PermissionService permissionService; - private final StaffService staffService; + private final StaffLookupCache staffLookupCache; private final Clock clock; @Scheduled(fixedDelayString = "${modl.registration.cleanup.interval-ms:3600000}") @@ -44,7 +45,7 @@ public void runCleanupOnce() { Instant now = clock.instant(); Date cutoff = Date.from(now.minus(properties.getExpiry())); Date claimCutoff = Date.from(now.minus(properties.getClaimTtl())); - List candidates = serverRepository.findExpiredRegistrationCleanupCandidates( + List candidates = serverRegistrationCleanupRepository.findExpiredRegistrationCleanupCandidates( cutoff, claimCutoff, properties.getBatchSize() @@ -85,14 +86,14 @@ private void processCandidate(Server candidate, Date cutoff, Date claimCutoff, I return; } - Optional claimed = serverRepository.claimExpiredRegistrationForCleanup(candidate.getId(), cutoff, claimCutoff, now); + Optional claimed = serverRegistrationCleanupRepository.claimExpiredRegistrationForCleanup(candidate.getId(), cutoff, claimCutoff, now); if (claimed.isEmpty()) { stats.skippedRace++; return; } Server claimedServer = claimed.get(); - if (!isStillEligible(claimedServer, cutoff) || isBlank(claimedServer.getCleanupClaimId())) { + if (!isStillEligible(claimedServer, cutoff) || !StringUtils.hasText(claimedServer.getCleanupClaimId())) { releaseClaimIfPresent(claimedServer); stats.skippedRace++; return; @@ -104,7 +105,7 @@ private void processCandidate(Server candidate, Date cutoff, Date claimCutoff, I return; } - Optional confirmed = serverRepository.confirmRegistrationCleanupClaim( + Optional confirmed = serverRegistrationCleanupRepository.confirmRegistrationCleanupClaim( claimedServer.getId(), claimedServer.getCleanupClaimId(), cutoff, @@ -121,7 +122,7 @@ private void processCandidate(Server candidate, Date cutoff, Date claimCutoff, I return; } - boolean deleted = serverRepository.deleteClaimedExpiredRegistration( + boolean deleted = serverRegistrationCleanupRepository.deleteClaimedExpiredRegistration( confirmedServer.getId(), confirmedServer.getCleanupClaimId(), cutoff @@ -133,13 +134,13 @@ private void processCandidate(Server candidate, Date cutoff, Date claimCutoff, I serverService.evictAllServerCaches(); permissionService.evictPermissionCache(); - staffService.evictAllStaffCaches(); + staffLookupCache.evictAll(); stats.deleted++; } private void releaseClaimIfPresent(Server server) { - if (!isBlank(server.getCleanupClaimId())) { - serverRepository.releaseRegistrationCleanupClaim(server.getId(), server.getCleanupClaimId()); + if (StringUtils.hasText(server.getCleanupClaimId())) { + serverRegistrationCleanupRepository.releaseRegistrationCleanupClaim(server.getId(), server.getCleanupClaimId()); } } @@ -159,16 +160,16 @@ private boolean isStillEligible(Server server, Date cutoff) { return server != null && Boolean.FALSE.equals(server.getEmailVerified()) && server.getProvisioningStatus() == ProvisioningStatus.PENDING - && !isBlank(server.getEmailVerificationToken()) + && StringUtils.hasText(server.getEmailVerificationToken()) && server.getCreatedAt() != null && server.getCreatedAt().before(cutoff) - && !isBlank(server.getDatabaseName()) + && StringUtils.hasText(server.getDatabaseName()) && !hasActivityEvidence(server); } private boolean hasActivityEvidence(Server server) { return server.getLastActivityAt() != null - || !isBlank(server.getApiKey()) + || StringUtils.hasText(server.getApiKey()) || positive(server.getUserCount()) || positive(server.getTicketCount()) || positive(server.getOnlinePlayerCount()); @@ -178,10 +179,6 @@ private boolean positive(Long value) { return value != null && value > 0; } - private boolean isBlank(String value) { - return value == null || value.isBlank(); - } - private static class CleanupStats { private int candidates; private int deleted; diff --git a/src/main/java/gg/modl/backend/replay/config/ReplayExecutorConfig.java b/src/main/java/gg/modl/backend/replay/config/ReplayExecutorConfig.java new file mode 100644 index 0000000..f3f4681 --- /dev/null +++ b/src/main/java/gg/modl/backend/replay/config/ReplayExecutorConfig.java @@ -0,0 +1,31 @@ +package gg.modl.backend.replay.config; + +import java.util.concurrent.ThreadPoolExecutor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class ReplayExecutorConfig { + + public static final String TRAINING_SEGMENT_TASK_EXECUTOR = "trainingSegmentTaskExecutor"; + + @Bean(name = TRAINING_SEGMENT_TASK_EXECUTOR, autowireCandidate = false) + public ThreadPoolTaskExecutor trainingSegmentTaskExecutor( + @Value("${modl.replay.training-segment-executor.core-pool-size:1}") int corePoolSize, + @Value("${modl.replay.training-segment-executor.max-pool-size:2}") int maxPoolSize, + @Value("${modl.replay.training-segment-executor.queue-capacity:50}") int queueCapacity, + @Value("${modl.replay.training-segment-executor.await-termination-seconds:30}") int awaitTerminationSeconds + ) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setThreadNamePrefix("training-segment-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(awaitTerminationSeconds); + return executor; + } +} diff --git a/src/main/java/gg/modl/backend/replay/controller/MinecraftReplayController.java b/src/main/java/gg/modl/backend/replay/controller/MinecraftReplayController.java index 5104ec9..06732a3 100644 --- a/src/main/java/gg/modl/backend/replay/controller/MinecraftReplayController.java +++ b/src/main/java/gg/modl/backend/replay/controller/MinecraftReplayController.java @@ -10,7 +10,6 @@ import jakarta.validation.Valid; import java.util.Map; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -18,7 +17,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Slf4j @RestController @RequestMapping(RESTMappingV1.MINECRAFT_REPLAYS) @RequiredArgsConstructor diff --git a/src/main/java/gg/modl/backend/replay/controller/PanelReplayController.java b/src/main/java/gg/modl/backend/replay/controller/PanelReplayController.java index 09b27a7..25698b7 100644 --- a/src/main/java/gg/modl/backend/replay/controller/PanelReplayController.java +++ b/src/main/java/gg/modl/backend/replay/controller/PanelReplayController.java @@ -1,5 +1,6 @@ package gg.modl.backend.replay.controller; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.replay.service.ReplayService; @@ -18,6 +19,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_REPLAYS) +@RequiresPanelPermission("punishment.modify") @RequiredArgsConstructor public class PanelReplayController { private final ReplayService replayService; diff --git a/src/main/java/gg/modl/backend/replay/controller/ReplayProtoMapper.java b/src/main/java/gg/modl/backend/replay/controller/ReplayProtoMapper.java index 52ff2b5..762e6b6 100644 --- a/src/main/java/gg/modl/backend/replay/controller/ReplayProtoMapper.java +++ b/src/main/java/gg/modl/backend/replay/controller/ReplayProtoMapper.java @@ -1,5 +1,7 @@ package gg.modl.backend.replay.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.nullToEmpty; + import gg.modl.backend.infrastructure.exception.ValidationException; import gg.modl.backend.replay.data.ReplayLabel; import gg.modl.proto.modl.v1.PublicReplayResponse; @@ -73,8 +75,4 @@ private ReplayLabel.CheatDetail toCheatDetail(SubmitReplayLabelsRequest.ReplayLa detail.setTimeRanges(ranges); return detail; } - - private static String nullToEmpty(String value) { - return value == null ? "" : value; - } } diff --git a/src/main/java/gg/modl/backend/replay/data/TrainingSegmentDocument.java b/src/main/java/gg/modl/backend/replay/data/TrainingSegmentDocument.java index 1881dad..076aefc 100644 --- a/src/main/java/gg/modl/backend/replay/data/TrainingSegmentDocument.java +++ b/src/main/java/gg/modl/backend/replay/data/TrainingSegmentDocument.java @@ -4,6 +4,7 @@ import java.util.Date; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; import org.bson.types.Binary; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; @@ -20,13 +21,14 @@ public class TrainingSegmentDocument { private String serverDatabaseName; private String playerUuid; private String playerName; - private String verdict; // "legit" | "cheating" - private String cheatType; // "aim", "scaffold", etc. (null for legit) + private String verdict; + private String cheatType; private int confidence; private String notes; private long startMs; private long endMs; private String mcVersion; + @ToString.Exclude private Binary segmentBinary; private Date createdAt; } diff --git a/src/main/java/gg/modl/backend/replay/dto/PlayerReplayResponse.java b/src/main/java/gg/modl/backend/replay/dto/PlayerReplayResponse.java index db39301..ba3eb91 100644 --- a/src/main/java/gg/modl/backend/replay/dto/PlayerReplayResponse.java +++ b/src/main/java/gg/modl/backend/replay/dto/PlayerReplayResponse.java @@ -35,9 +35,17 @@ public static PlayerReplayResponse fromTicket(Ticket ticket, String replayUrl, S public String deduplicationKey() { if (replayId != null && !replayId.isBlank()) { - return "id:" + replayId; + return idKey(replayId); } - return replayUrl != null ? "url:" + replayUrl : "empty"; + return replayUrl != null ? urlKey(replayUrl) : "empty"; + } + + public static String idKey(String replayId) { + return "id:" + replayId; + } + + public static String urlKey(String replayUrl) { + return "url:" + replayUrl; } private static String firstNonBlank(String first, String second) { diff --git a/src/main/java/gg/modl/backend/replay/service/LegacyReplayCleanupService.java b/src/main/java/gg/modl/backend/replay/service/LegacyReplayCleanupService.java index 5eb149e..0211cac 100644 --- a/src/main/java/gg/modl/backend/replay/service/LegacyReplayCleanupService.java +++ b/src/main/java/gg/modl/backend/replay/service/LegacyReplayCleanupService.java @@ -2,8 +2,9 @@ import gg.modl.backend.database.mongo.repository.ReplayMongoRepository; import gg.modl.backend.database.mongo.repository.ReplayMongoRepository.ReplayCursor; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerLookupRepository; import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.infrastructure.scheduling.KeysetDrainer; import gg.modl.backend.infrastructure.scheduling.SchedulerLeaseService; import gg.modl.backend.replay.config.LegacyReplayCleanupProperties; import gg.modl.backend.replay.data.ReplayDocument; @@ -31,7 +32,7 @@ public class LegacyReplayCleanupService { private static final String CLEANUP_LEASE = "legacy-replay-cleanup"; private final LegacyReplayCleanupProperties properties; - private final ServerMongoRepository serverRepository; + private final ServerLookupRepository serverLookupRepository; private final ReplayMongoRepository replayRepository; private final TicketMongoRepository ticketRepository; private final ReplayRetentionSettingsService replayRetentionSettingsService; @@ -58,7 +59,7 @@ public void runCleanupOnce() { } RunAggregate aggregate = new RunAggregate(); - List servers = serverRepository.findAll(); + List servers = serverLookupRepository.findAll(); for (Server server : servers) { try { ServerCleanupResult result = processServer(server); @@ -106,25 +107,16 @@ private ServerCleanupResult processServer(Server server) { } private long drain(Server server, Date cutoff, int pageSize, ReplayPageFinder finder, ServerCleanupResult result) { - long deletedTotal = 0; - ReplayCursor cursor = null; - int pages = 0; - while (pages++ < MAX_PAGES_PER_RUN) { - List page = finder.find(server, cutoff, cursor, pageSize); - if (page.isEmpty()) { - break; - } - result.scanned += page.size(); - List deletable = selectDeletable(server, page, result); - ReplayBatchDeletionResult deletion = replayDeletionService.deleteReplaysWithStorage(server, deletable); - deletedTotal += deletion.deleted(); - result.alreadyAbsent += deletion.alreadyAbsent(); - cursor = cursorFrom(page); - if (page.size() < pageSize) { - break; - } - } - return deletedTotal; + return KeysetDrainer.drain(MAX_PAGES_PER_RUN, pageSize, + (cursor, limit) -> finder.find(server, cutoff, cursor, limit), + this::cursorFrom, + page -> { + result.scanned += page.size(); + List deletable = selectDeletable(server, page, result); + ReplayBatchDeletionResult deletion = replayDeletionService.deleteReplaysWithStorage(server, deletable); + result.alreadyAbsent += deletion.alreadyAbsent(); + return deletion.deleted(); + }); } private List selectDeletable(Server server, List page, ServerCleanupResult result) { diff --git a/src/main/java/gg/modl/backend/replay/service/PlayerReplayListingService.java b/src/main/java/gg/modl/backend/replay/service/PlayerReplayListingService.java new file mode 100644 index 0000000..dac0b30 --- /dev/null +++ b/src/main/java/gg/modl/backend/replay/service/PlayerReplayListingService.java @@ -0,0 +1,123 @@ +package gg.modl.backend.replay.service; + +import gg.modl.backend.database.mongo.repository.ReplayMongoRepository; +import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.replay.data.ReplayDocument; +import gg.modl.backend.replay.dto.PlayerReplayResponse; +import gg.modl.backend.replay.util.ReplayReferenceUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.storage.service.S3StorageService; +import gg.modl.backend.storage.service.StorageMetadataService; +import gg.modl.backend.ticket.data.Ticket; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PlayerReplayListingService { + private final ReplayMongoRepository replayRepository; + private final S3StorageService s3StorageService; + private final StorageMetadataService storageMetadataService; + private final TicketMongoRepository ticketRepository; + + public List listPlayerReplays(Server server, String playerUuid) { + String normalizedPlayerUuid = ReplayReferenceUtil.requireValidUuid(playerUuid); + Map responses = new LinkedHashMap<>(); + + List directReplays = replayRepository.findByTargetUuid(server, normalizedPlayerUuid, 100); + Set orphanedReplayIds = orphanedCompleteReplayIds(server, directReplays); + for (ReplayDocument replay : directReplays) { + if (orphanedReplayIds.contains(replay.getId())) { + continue; + } + PlayerReplayResponse response = toPlayerReplayResponse(replay, PlayerReplayResponse.MatchSource.DIRECT_METADATA); + responses.put(response.deduplicationKey(), response); + } + + List tickets = ticketRepository.findPlayerTicketsWithReplayUrl(server, normalizedPlayerUuid, 100); + for (Ticket ticket : tickets) { + String replayUrl = ReplayReferenceUtil.normalize(ticket.getReplayUrl()); + if (replayUrl == null) { + continue; + } + String replayId = ReplayReferenceUtil.extractReplayId(replayUrl); + if (replayId != null && orphanedReplayIds.contains(replayId)) { + continue; + } + if (hasReplayReference(responses, replayUrl, replayId)) { + continue; + } + String key = replayId != null ? PlayerReplayResponse.idKey(replayId) : PlayerReplayResponse.urlKey(replayUrl); + if (responses.containsKey(key)) { + continue; + } + responses.put(key, PlayerReplayResponse.fromTicket(ticket, replayUrl, replayId)); + } + + return List.copyOf(responses.values()); + } + + private Set orphanedCompleteReplayIds(Server server, List replays) { + if (!storageMetadataService.isMetadataAuthoritative(server)) { + return Set.of(); + } + List completeStorageKeys = new ArrayList<>(); + for (ReplayDocument replay : replays) { + if (isCompleteWithStorageKey(replay)) { + completeStorageKeys.add(replay.getStorageKey()); + } + } + if (completeStorageKeys.isEmpty()) { + return Set.of(); + } + + Set existingStorageKeys = storageMetadataService.existingKeys(server, completeStorageKeys); + if (existingStorageKeys.size() == completeStorageKeys.size()) { + return Set.of(); + } + + Set orphanedReplayIds = new HashSet<>(); + for (ReplayDocument replay : replays) { + if (isCompleteWithStorageKey(replay) && !existingStorageKeys.contains(replay.getStorageKey())) { + orphanedReplayIds.add(replay.getId()); + } + } + return orphanedReplayIds; + } + + private boolean isCompleteWithStorageKey(ReplayDocument replay) { + return ReplayDocument.STATUS_COMPLETE.equals(replay.getStatus()) && replay.getStorageKey() != null; + } + + private PlayerReplayResponse toPlayerReplayResponse(ReplayDocument replay, PlayerReplayResponse.MatchSource matchSource) { + String replayUrl = ReplayDocument.STATUS_COMPLETE.equals(replay.getStatus()) && replay.getStorageKey() != null + ? s3StorageService.getCdnUrl(replay.getStorageKey()) + : null; + return new PlayerReplayResponse( + replay.getId(), + replay.getTargetUuid(), + replay.getTargetName(), + replay.getMcVersion(), + replay.getFileSize(), + replay.getCreatedAt(), + replay.getStatus(), + replayUrl, + matchSource + ); + } + + private boolean hasReplayReference(Map responses, String replayUrl, String replayId) { + return responses.values() + .stream() + .anyMatch(response -> + replayUrl.equals(response.replayUrl()) + || (replayId != null && replayId.equals(response.replayId())) + ); + } +} diff --git a/src/main/java/gg/modl/backend/replay/service/ReplayDeletionService.java b/src/main/java/gg/modl/backend/replay/service/ReplayDeletionService.java index ed41606..802c207 100644 --- a/src/main/java/gg/modl/backend/replay/service/ReplayDeletionService.java +++ b/src/main/java/gg/modl/backend/replay/service/ReplayDeletionService.java @@ -6,6 +6,7 @@ import gg.modl.backend.replay.data.ReplayDocument; import gg.modl.backend.server.data.Server; import gg.modl.backend.storage.service.S3StorageService; +import gg.modl.backend.storage.service.StorageKeyUtils; import gg.modl.backend.storage.service.StorageMetadataService; import java.util.ArrayList; import java.util.Collection; @@ -96,7 +97,7 @@ private List replayStorageKeys(Collection keys) { } List replayKeys = new ArrayList<>(); for (String key : keys) { - if (key != null && REPLAY_STORAGE_CATEGORY.equals(S3StorageService.categorizeFile(key))) { + if (key != null && REPLAY_STORAGE_CATEGORY.equals(StorageKeyUtils.categorizeFile(key))) { replayKeys.add(key); } } diff --git a/src/main/java/gg/modl/backend/replay/service/ReplayService.java b/src/main/java/gg/modl/backend/replay/service/ReplayService.java index e52cb14..0e0de8b 100644 --- a/src/main/java/gg/modl/backend/replay/service/ReplayService.java +++ b/src/main/java/gg/modl/backend/replay/service/ReplayService.java @@ -1,64 +1,41 @@ package gg.modl.backend.replay.service; import gg.modl.backend.database.mongo.repository.ReplayMongoRepository; -import gg.modl.backend.database.mongo.repository.TicketMongoRepository; import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.validation.BeanValidationRunner; import gg.modl.backend.infrastructure.validation.RequestValidationLimits; import gg.modl.backend.replay.data.ReplayDocument; import gg.modl.backend.replay.data.ReplayLabel; import gg.modl.backend.replay.dto.InitReplayUploadResponse; -import gg.modl.backend.replay.dto.PlayerReplayResponse; import gg.modl.backend.replay.dto.PublicReplayResponse; import gg.modl.backend.replay.util.ReplayReferenceUtil; import gg.modl.backend.server.data.Server; import gg.modl.backend.storage.dto.response.PresignUploadResponse; import gg.modl.backend.storage.service.S3StorageService; -import gg.modl.backend.storage.service.StorageMetadataService; import gg.modl.backend.storage.service.StorageQuotaService; -import gg.modl.backend.ticket.data.Ticket; import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.Validator; -import java.util.ArrayList; import java.util.Date; -import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.Set; import java.util.UUID; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Service @Slf4j +@RequiredArgsConstructor public class ReplayService { private final ReplayMongoRepository replayRepository; private final S3StorageService s3StorageService; private final StorageQuotaService storageQuotaService; private final TrainingDataService trainingDataService; - private final StorageMetadataService storageMetadataService; - private final TicketMongoRepository ticketRepository; - private final Validator validator; + private final BeanValidationRunner validationRunner; @Value("${modl.replay.max-file-size:10485760}") private long maxFileSize; - public ReplayService(ReplayMongoRepository replayRepository, S3StorageService s3StorageService, - StorageQuotaService storageQuotaService, TrainingDataService trainingDataService, - StorageMetadataService storageMetadataService, TicketMongoRepository ticketRepository, - Validator validator) { - this.replayRepository = replayRepository; - this.s3StorageService = s3StorageService; - this.storageQuotaService = storageQuotaService; - this.trainingDataService = trainingDataService; - this.storageMetadataService = storageMetadataService; - this.ticketRepository = ticketRepository; - this.validator = validator; - } - public InitReplayUploadResponse initUpload( Server server, String mcVersion, @@ -70,7 +47,7 @@ public InitReplayUploadResponse initUpload( throw new ValidationException("File size exceeds maximum of " + (maxFileSize / 1024 / 1024) + " MB"); } - String normalizedTargetUuid = normalizeTargetUuid(targetUuid); + String normalizedTargetUuid = ReplayReferenceUtil.requireValidUuid(targetUuid); String normalizedTargetName = normalizeTargetName(targetName); if (!storageQuotaService.canUpload(server, fileSize)) { @@ -161,10 +138,7 @@ public SubmitLabelsResult submitLabels(Server server, String replayId, List labels) { for (ReplayLabel label : labels) { - Set> violations = validator.validate(label); - if (!violations.isEmpty()) { - throw new ValidationException(violations.iterator().next().getMessage()); - } + validationRunner.validate(label); } } @@ -182,113 +156,8 @@ public Optional getPublicReplay(Server server, String repl )); } - public List listPlayerReplays(Server server, String playerUuid) { - String normalizedPlayerUuid = normalizeTargetUuid(playerUuid); - Map responses = new LinkedHashMap<>(); - - List directReplays = replayRepository.findByTargetUuid(server, normalizedPlayerUuid, 100); - Set orphanedReplayIds = orphanedCompleteReplayIds(server, directReplays); - for (ReplayDocument replay : directReplays) { - if (orphanedReplayIds.contains(replay.getId())) { - continue; - } - PlayerReplayResponse response = toPlayerReplayResponse(replay, PlayerReplayResponse.MatchSource.DIRECT_METADATA); - responses.put(response.deduplicationKey(), response); - } - - List tickets = ticketRepository.findPlayerTicketsWithReplayUrl(server, normalizedPlayerUuid, 100); - for (Ticket ticket : tickets) { - String replayUrl = normalizeOptional(ticket.getReplayUrl()); - if (replayUrl == null) { - continue; - } - String replayId = ReplayReferenceUtil.extractReplayId(replayUrl); - if (replayId != null && orphanedReplayIds.contains(replayId)) { - continue; - } - if (hasReplayReference(responses, replayUrl, replayId)) { - continue; - } - String key = replayId != null ? replayIdKey(replayId) : replayUrlKey(replayUrl); - if (responses.containsKey(key)) { - continue; - } - responses.put(key, PlayerReplayResponse.fromTicket(ticket, replayUrl, replayId)); - } - - return List.copyOf(responses.values()); - } - - private Set orphanedCompleteReplayIds(Server server, List replays) { - if (!storageMetadataService.isMetadataAuthoritative(server)) { - return Set.of(); - } - List completeStorageKeys = new ArrayList<>(); - for (ReplayDocument replay : replays) { - if (isCompleteWithStorageKey(replay)) { - completeStorageKeys.add(replay.getStorageKey()); - } - } - if (completeStorageKeys.isEmpty()) { - return Set.of(); - } - - Set existingStorageKeys = storageMetadataService.existingKeys(server, completeStorageKeys); - if (existingStorageKeys.size() == completeStorageKeys.size()) { - return Set.of(); - } - - Set orphanedReplayIds = new HashSet<>(); - for (ReplayDocument replay : replays) { - if (isCompleteWithStorageKey(replay) && !existingStorageKeys.contains(replay.getStorageKey())) { - orphanedReplayIds.add(replay.getId()); - } - } - return orphanedReplayIds; - } - - private boolean isCompleteWithStorageKey(ReplayDocument replay) { - return ReplayDocument.STATUS_COMPLETE.equals(replay.getStatus()) && replay.getStorageKey() != null; - } - - private PlayerReplayResponse toPlayerReplayResponse(ReplayDocument replay, PlayerReplayResponse.MatchSource matchSource) { - String replayUrl = ReplayDocument.STATUS_COMPLETE.equals(replay.getStatus()) && replay.getStorageKey() != null - ? s3StorageService.getCdnUrl(replay.getStorageKey()) - : null; - return new PlayerReplayResponse( - replay.getId(), - replay.getTargetUuid(), - replay.getTargetName(), - replay.getMcVersion(), - replay.getFileSize(), - replay.getCreatedAt(), - replay.getStatus(), - replayUrl, - matchSource - ); - } - - private String normalizeOptional(String value) { - if (value == null || value.isBlank()) { - return null; - } - return value.trim(); - } - - private String normalizeTargetUuid(String value) { - String normalized = normalizeOptional(value); - if (normalized == null) { - return null; - } - try { - return UUID.fromString(normalized).toString(); - } catch (IllegalArgumentException exception) { - throw new ValidationException("Target UUID must be a valid UUID"); - } - } - private String normalizeTargetName(String value) { - String normalized = normalizeOptional(value); + String normalized = ReplayReferenceUtil.normalize(value); if (normalized == null) { return null; } @@ -297,21 +166,4 @@ private String normalizeTargetName(String value) { } return normalized; } - - private String replayUrlKey(String replayUrl) { - return "url:" + replayUrl; - } - - private String replayIdKey(String replayId) { - return "id:" + replayId; - } - - private boolean hasReplayReference(Map responses, String replayUrl, String replayId) { - return responses.values() - .stream() - .anyMatch(response -> - replayUrl.equals(response.replayUrl()) - || (replayId != null && replayId.equals(response.replayId())) - ); - } } diff --git a/src/main/java/gg/modl/backend/replay/service/TrainingDataService.java b/src/main/java/gg/modl/backend/replay/service/TrainingDataService.java index b44bce3..266a49e 100644 --- a/src/main/java/gg/modl/backend/replay/service/TrainingDataService.java +++ b/src/main/java/gg/modl/backend/replay/service/TrainingDataService.java @@ -1,6 +1,7 @@ package gg.modl.backend.replay.service; import gg.modl.backend.database.mongo.repository.TrainingSegmentRepository; +import gg.modl.backend.replay.config.ReplayExecutorConfig; import gg.modl.backend.replay.data.ReplayDocument; import gg.modl.backend.replay.data.ReplayLabel; import gg.modl.backend.replay.data.TrainingSegmentDocument; @@ -47,7 +48,7 @@ public class TrainingDataService { private static final String VERDICT_CHEATING = "cheating"; private static final String VERDICT_LEGIT = "legit"; - @Async + @Async(ReplayExecutorConfig.TRAINING_SEGMENT_TASK_EXECUTOR) public void generateSegmentsAsync(Server server, ReplayDocument doc, List labels) { try { generateSegments(server, doc, labels); diff --git a/src/main/java/gg/modl/backend/replay/util/ReplayReferenceUtil.java b/src/main/java/gg/modl/backend/replay/util/ReplayReferenceUtil.java index 86285a3..8f48b76 100644 --- a/src/main/java/gg/modl/backend/replay/util/ReplayReferenceUtil.java +++ b/src/main/java/gg/modl/backend/replay/util/ReplayReferenceUtil.java @@ -1,9 +1,11 @@ package gg.modl.backend.replay.util; +import gg.modl.backend.infrastructure.exception.ValidationException; import java.net.URI; import java.net.URISyntaxException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.util.UUID; public final class ReplayReferenceUtil { private static final String REPLAY_ID_QUERY_PARAM = "id"; @@ -11,6 +13,18 @@ public final class ReplayReferenceUtil { private ReplayReferenceUtil() { } + public static String requireValidUuid(String value) { + String normalized = normalize(value); + if (normalized == null) { + return null; + } + try { + return UUID.fromString(normalized).toString(); + } catch (IllegalArgumentException exception) { + throw new ValidationException("Target UUID must be a valid UUID"); + } + } + public static String extractReplayId(String replayReference) { String normalized = normalize(replayReference); if (normalized == null) { @@ -40,7 +54,7 @@ public static String extractReplayId(String replayReference) { } } - private static String normalize(String value) { + public static String normalize(String value) { if (value == null || value.isBlank()) { return null; } diff --git a/src/main/java/gg/modl/backend/replaylite/data/ReplayLiteLabelRange.java b/src/main/java/gg/modl/backend/replaylite/data/ReplayLiteLabelRange.java index cc6e8dd..54dcb0f 100644 --- a/src/main/java/gg/modl/backend/replaylite/data/ReplayLiteLabelRange.java +++ b/src/main/java/gg/modl/backend/replaylite/data/ReplayLiteLabelRange.java @@ -9,6 +9,6 @@ public record ReplayLiteLabelRange( ) { @AssertTrue public boolean isEndAfterStart() { - return endMs > startMs; + return endMs >= startMs; } } diff --git a/src/main/java/gg/modl/backend/replaylite/repository/ReplayLiteMongoRepository.java b/src/main/java/gg/modl/backend/replaylite/repository/ReplayLiteMongoRepository.java index c8055a8..e882d00 100644 --- a/src/main/java/gg/modl/backend/replaylite/repository/ReplayLiteMongoRepository.java +++ b/src/main/java/gg/modl/backend/replaylite/repository/ReplayLiteMongoRepository.java @@ -73,10 +73,6 @@ private Criteria keysetAfter(String sortField, ReplayLiteCursor after) { ); } - public ReplayLiteDocument saveEntity(ReplayLiteDocument document) { - return super.saveEntity(document); - } - public boolean claimLabels(String replayId, Instant now, List labels, String labelIp) { Query query = Query.query(Criteria.where(ReplayLiteDocumentFields.ID).is(replayId) .and(ReplayLiteDocumentFields.STATUS).is(ReplayLiteStatus.CONFIRMED) diff --git a/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteAbuseGuard.java b/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteAbuseGuard.java index 7d6e2b5..3db29e5 100644 --- a/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteAbuseGuard.java +++ b/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteAbuseGuard.java @@ -1,17 +1,16 @@ package gg.modl.backend.replaylite.service; import gg.modl.backend.infrastructure.exception.ValidationException; -import java.time.Clock; +import gg.modl.backend.infrastructure.ratelimit.BucketPool; +import io.github.bucket4j.Bucket; import java.time.Duration; -import java.time.Instant; -import java.util.LinkedHashMap; -import java.util.Map; import java.util.UUID; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service +@RequiredArgsConstructor public class ReplayLiteAbuseGuard { - private static final int MAX_TRACKED_KEYS = 50_000; private static final int MAX_INIT_ATTEMPTS_PER_HOUR = 40; private static final int MAX_CONFIRMS_PER_HOUR = 80; private static final int MAX_LABELS_PER_HOUR = 80; @@ -19,40 +18,40 @@ public class ReplayLiteAbuseGuard { private static final int MAX_REQUESTS_PER_IP_PER_MINUTE = 120; private static final int MAX_ACTIVE_PENDING_PER_SERVER = 25; - private final Clock clock; - private final Map counters = new LinkedHashMap<>(64, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_TRACKED_KEYS; - } - }; + private static final Duration HOURLY_WINDOW = Duration.ofHours(1); + private static final Duration PER_MINUTE_WINDOW = Duration.ofMinutes(1); - public ReplayLiteAbuseGuard() { - this(Clock.systemUTC()); - } + private static final String INIT_NAMESPACE = "replaylite:init"; + private static final String CONFIRM_NAMESPACE = "replaylite:confirm"; + private static final String LABEL_NAMESPACE = "replaylite:label"; + private static final String DOWNLOAD_NAMESPACE = "replaylite:download"; + private static final String IP_NAMESPACE = "replaylite:ip"; - ReplayLiteAbuseGuard(Clock clock) { - this.clock = clock; - } + private final BucketPool bucketPool; public void checkInit(UUID pluginServerUuid) { - consume("init:" + pluginServerUuid, MAX_INIT_ATTEMPTS_PER_HOUR, Duration.ofHours(1), "Too many Replay Lite upload attempts"); + consume(INIT_NAMESPACE, pluginServerUuid.toString(), MAX_INIT_ATTEMPTS_PER_HOUR, HOURLY_WINDOW, + "Too many Replay Lite upload attempts"); } public void checkConfirm(UUID pluginServerUuid) { - consume("confirm:" + pluginServerUuid, MAX_CONFIRMS_PER_HOUR, Duration.ofHours(1), "Too many Replay Lite confirm attempts"); + consume(CONFIRM_NAMESPACE, pluginServerUuid.toString(), MAX_CONFIRMS_PER_HOUR, HOURLY_WINDOW, + "Too many Replay Lite confirm attempts"); } public void checkLabel(String replayId) { - consume("label:" + replayId, MAX_LABELS_PER_HOUR, Duration.ofHours(1), "Too many Replay Lite label submissions"); + consume(LABEL_NAMESPACE, replayId, MAX_LABELS_PER_HOUR, HOURLY_WINDOW, + "Too many Replay Lite label submissions"); } public void checkDownload(String replayId) { - consume("download:" + replayId, MAX_DOWNLOADS_PER_REPLAY_PER_HOUR, Duration.ofHours(1), "Too many Replay Lite download requests"); + consume(DOWNLOAD_NAMESPACE, replayId, MAX_DOWNLOADS_PER_REPLAY_PER_HOUR, HOURLY_WINDOW, + "Too many Replay Lite download requests"); } public void checkIp(String clientIp) { - consume("ip:" + clientIp, MAX_REQUESTS_PER_IP_PER_MINUTE, Duration.ofMinutes(1), "Too many Replay Lite requests"); + consume(IP_NAMESPACE, clientIp, MAX_REQUESTS_PER_IP_PER_MINUTE, PER_MINUTE_WINDOW, + "Too many Replay Lite requests"); } public void checkPendingUploads(long activePendingCount) { @@ -61,20 +60,10 @@ public void checkPendingUploads(long activePendingCount) { } } - private synchronized void consume(String key, int limit, Duration window, String message) { - Instant now = clock.instant(); - WindowCounter counter = counters.get(key); - if (counter == null || !now.isBefore(counter.expiresAt())) { - counters.put(key, new WindowCounter(1, now.plus(window))); - return; - } - - if (counter.count() >= limit) { + private void consume(String namespace, String key, int limit, Duration window, String message) { + Bucket bucket = bucketPool.resolveBucket(namespace, key, limit, window); + if (!bucket.tryConsume(1)) { throw new ValidationException(message); } - - counters.put(key, new WindowCounter(counter.count() + 1, counter.expiresAt())); } - - private record WindowCounter(int count, Instant expiresAt) {} } diff --git a/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteCleanupService.java b/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteCleanupService.java index a469d59..b8b149c 100644 --- a/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteCleanupService.java +++ b/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteCleanupService.java @@ -1,5 +1,6 @@ package gg.modl.backend.replaylite.service; +import gg.modl.backend.infrastructure.scheduling.KeysetDrainer; import gg.modl.backend.infrastructure.scheduling.SchedulerLeaseService; import gg.modl.backend.replaylite.data.ReplayLiteDocument; import gg.modl.backend.replaylite.repository.ReplayLiteMongoRepository; @@ -48,20 +49,16 @@ public void cleanupExpiredReplays() { } private void drain(ReplayLitePageFinder finder, Function sortValue) { - ReplayLiteCursor cursor = null; - int batches = 0; - while (batches++ < MAX_BATCHES_PER_RUN) { - List page = finder.find(cursor, CLEANUP_BATCH_SIZE); - if (page.isEmpty()) { - break; - } - cleanup(page); - ReplayLiteDocument last = page.get(page.size() - 1); - cursor = new ReplayLiteCursor(sortValue.apply(last), last.getId()); - if (page.size() < CLEANUP_BATCH_SIZE) { - break; - } - } + KeysetDrainer.drain(MAX_BATCHES_PER_RUN, CLEANUP_BATCH_SIZE, + finder::find, + page -> { + ReplayLiteDocument last = page.get(page.size() - 1); + return new ReplayLiteCursor(sortValue.apply(last), last.getId()); + }, + page -> { + cleanup(page); + return 0L; + }); } private void cleanup(Iterable documents) { diff --git a/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteService.java b/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteService.java index 3781122..a3b40c2 100644 --- a/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteService.java +++ b/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteService.java @@ -26,7 +26,6 @@ import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @Service @@ -37,6 +36,8 @@ public class ReplayLiteService { private static final Duration DOWNLOAD_PRESIGN_TTL = Duration.ofMinutes(5); private static final int DAILY_CONFIRMED_LIMIT = 100; private static final DateTimeFormatter KEY_DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + private static final String REPLAY_NOT_FOUND = "Replay not found"; + private static final String UPLOAD_NOT_PENDING = "Replay Lite upload is not pending"; private final ReplayLiteMongoRepository repository; private final ReplayLiteQuotaMongoRepository quotaRepository; @@ -44,16 +45,6 @@ public class ReplayLiteService { private final ReplayLiteAbuseGuard abuseGuard; private final Clock clock; - @Autowired - public ReplayLiteService( - ReplayLiteMongoRepository repository, - ReplayLiteQuotaMongoRepository quotaRepository, - ReplayLiteStorageService storageService, - ReplayLiteAbuseGuard abuseGuard - ) { - this(repository, quotaRepository, storageService, abuseGuard, Clock.systemUTC()); - } - public ReplayLiteService( ReplayLiteMongoRepository repository, ReplayLiteQuotaMongoRepository quotaRepository, @@ -114,10 +105,10 @@ public void confirmUpload(Server server, String replayId, String clientIp) { UUID pluginServerUuid = authenticatedServerUuid(server); ReplayLiteDocument document = repository.findByReplayId(replayId) - .orElseThrow(() -> new ResourceNotFoundException("Replay not found")); + .orElseThrow(() -> new ResourceNotFoundException(REPLAY_NOT_FOUND)); if (!pluginServerUuid.equals(document.getPluginServerUuid())) { - throw new ResourceNotFoundException("Replay not found"); + throw new ResourceNotFoundException(REPLAY_NOT_FOUND); } abuseGuard.checkConfirm(pluginServerUuid); @@ -138,33 +129,30 @@ public void confirmUpload(Server server, String replayId, String clientIp) { LocalDate quotaDay = LocalDate.ofInstant(clock.instant(), ZoneOffset.UTC); QuotaReservationResult quotaReservation = reserveDailyQuota(document.getPluginServerUuid(), document.getId(), quotaDay); if (quotaReservation == QuotaReservationResult.ALREADY_RESERVED) { - throw new ConflictException("Replay Lite upload is not pending"); + throw new ConflictException(UPLOAD_NOT_PENDING); } boolean confirmed; try { confirmed = confirmPendingUpload(document, actualSize, clientIp); } catch (RuntimeException e) { - if (quotaReservation == QuotaReservationResult.RESERVED && shouldReleaseFailedConfirmation(document.getId())) { - quotaRepository.releaseConfirmedUpload( - document.getPluginServerUuid(), - quotaDay, - document.getId(), - clock.instant() - ); - } + releaseQuotaOnFailedConfirmation(document, quotaReservation, quotaDay); throw e; } if (!confirmed) { - if (quotaReservation == QuotaReservationResult.RESERVED && shouldReleaseFailedConfirmation(document.getId())) { - quotaRepository.releaseConfirmedUpload( - document.getPluginServerUuid(), - quotaDay, - document.getId(), - clock.instant() - ); - } - throw new ConflictException("Replay Lite upload is not pending"); + releaseQuotaOnFailedConfirmation(document, quotaReservation, quotaDay); + throw new ConflictException(UPLOAD_NOT_PENDING); + } + } + + private void releaseQuotaOnFailedConfirmation(ReplayLiteDocument document, QuotaReservationResult quotaReservation, LocalDate quotaDay) { + if (quotaReservation == QuotaReservationResult.RESERVED && shouldReleaseFailedConfirmation(document.getId())) { + quotaRepository.releaseConfirmedUpload( + document.getPluginServerUuid(), + quotaDay, + document.getId(), + clock.instant() + ); } } @@ -200,11 +188,11 @@ public void submitLabels(String replayId, List labels, String c abuseGuard.checkLabel(replayId); ReplayLiteDocument document = repository.findByReplayId(replayId) - .orElseThrow(() -> new ResourceNotFoundException("Replay not found")); + .orElseThrow(() -> new ResourceNotFoundException(REPLAY_NOT_FOUND)); Instant now = clock.instant(); - if (document.getStatus() != ReplayLiteStatus.CONFIRMED || document.getExpiresAt() == null || !document.getExpiresAt().isAfter(now)) { - throw new ResourceNotFoundException("Replay not found"); + if (!isConfirmedAndUnexpired(document, now)) { + throw new ResourceNotFoundException(REPLAY_NOT_FOUND); } if (document.getLabels() != null && !document.getLabels().isEmpty()) { throw new ConflictException("This replay has already been labeled"); @@ -212,11 +200,8 @@ public void submitLabels(String replayId, List labels, String c if (!repository.claimLabels(document.getId(), now, normalizeLabels(labels), clientIp)) { Optional current = repository.findByReplayId(replayId); - if (current.isEmpty() - || current.get().getStatus() != ReplayLiteStatus.CONFIRMED - || current.get().getExpiresAt() == null - || !current.get().getExpiresAt().isAfter(now)) { - throw new ResourceNotFoundException("Replay not found"); + if (current.isEmpty() || !isConfirmedAndUnexpired(current.get(), now)) { + throw new ResourceNotFoundException(REPLAY_NOT_FOUND); } throw new ConflictException("This replay has already been labeled"); } @@ -235,8 +220,13 @@ public void releaseUnconfirmedDailyQuota(ReplayLiteDocument document) { private Optional findAvailablePublicReplay(String replayId) { Instant now = clock.instant(); return repository.findByReplayId(replayId) - .filter(document -> document.getStatus() == ReplayLiteStatus.CONFIRMED) - .filter(document -> document.getExpiresAt() != null && document.getExpiresAt().isAfter(now)); + .filter(document -> isConfirmedAndUnexpired(document, now)); + } + + private boolean isConfirmedAndUnexpired(ReplayLiteDocument document, Instant now) { + return document.getStatus() == ReplayLiteStatus.CONFIRMED + && document.getExpiresAt() != null + && document.getExpiresAt().isAfter(now); } private List normalizeLabels(List labels) { @@ -271,7 +261,7 @@ private UUID authenticatedServerUuid(Server server) { private void requirePending(ReplayLiteDocument document) { if (document.getStatus() != ReplayLiteStatus.PENDING) { - throw new ValidationException("Replay Lite upload is not pending"); + throw new ValidationException(UPLOAD_NOT_PENDING); } } diff --git a/src/main/java/gg/modl/backend/replaylite/storage/ReplayLiteStorageService.java b/src/main/java/gg/modl/backend/replaylite/storage/ReplayLiteStorageService.java index 1278813..9be0b4a 100644 --- a/src/main/java/gg/modl/backend/replaylite/storage/ReplayLiteStorageService.java +++ b/src/main/java/gg/modl/backend/replaylite/storage/ReplayLiteStorageService.java @@ -1,7 +1,7 @@ package gg.modl.backend.replaylite.storage; import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import java.net.URI; +import gg.modl.backend.storage.config.S3ClientFactory; import java.time.Duration; import java.time.Instant; import java.util.HashMap; @@ -12,9 +12,6 @@ import org.springframework.http.ContentDisposition; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; @@ -60,17 +57,11 @@ private static S3Client createClient(ReplayLiteStorageConfiguration configuratio return null; } - AwsBasicCredentials credentials = AwsBasicCredentials.create( + return S3ClientFactory.createClient( + configuration.getEndpoint(), configuration.getKeyId(), configuration.getApplicationKey() ); - - return S3Client.builder() - .credentialsProvider(StaticCredentialsProvider.create(credentials)) - .endpointOverride(URI.create(configuration.getEndpoint())) - .region(Region.US_EAST_1) - .forcePathStyle(true) - .build(); } private static S3Presigner createPresigner(ReplayLiteStorageConfiguration configuration) { @@ -78,16 +69,11 @@ private static S3Presigner createPresigner(ReplayLiteStorageConfiguration config return null; } - AwsBasicCredentials credentials = AwsBasicCredentials.create( + return S3ClientFactory.createPresigner( + configuration.getEndpoint(), configuration.getKeyId(), configuration.getApplicationKey() ); - - return S3Presigner.builder() - .credentialsProvider(StaticCredentialsProvider.create(credentials)) - .endpointOverride(URI.create(configuration.getEndpoint())) - .region(Region.US_EAST_1) - .build(); } public boolean isConfigured() { @@ -144,14 +130,6 @@ public Optional headObject(String objectKey) { } } - public String getPublicUrl(String objectKey) { - String cdnDomain = configuration.getCdnDomain(); - if (cdnDomain == null || cdnDomain.isBlank()) { - throw new ExternalServiceException("Replay Lite CDN domain is not configured"); - } - return "https://" + cdnDomain + "/" + objectKey; - } - public PresignedDownload createPresignedDownload(String objectKey, String downloadFilename, Duration duration) { if (!isConfigured()) { throw new ExternalServiceException("Replay Lite storage is not configured"); diff --git a/src/main/java/gg/modl/backend/role/controller/MinecraftRolesProtoMapper.java b/src/main/java/gg/modl/backend/role/controller/MinecraftRolesProtoMapper.java index eeedb55..f28bbc2 100644 --- a/src/main/java/gg/modl/backend/role/controller/MinecraftRolesProtoMapper.java +++ b/src/main/java/gg/modl/backend/role/controller/MinecraftRolesProtoMapper.java @@ -1,13 +1,14 @@ package gg.modl.backend.role.controller; -import com.google.protobuf.Timestamp; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalString; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalStrings; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalTimestamp; + import gg.modl.backend.role.dto.response.RoleResponse; import gg.modl.proto.modl.v1.MinecraftRole; import gg.modl.proto.modl.v1.MinecraftRoleDetailResponse; import gg.modl.proto.modl.v1.MinecraftRoleListResponse; -import java.util.Date; import java.util.List; -import java.util.function.Consumer; final class MinecraftRolesProtoMapper { private MinecraftRolesProtoMapper() { @@ -38,40 +39,13 @@ private static MinecraftRole toMinecraftRole(RoleResponse role) { .setIsDefault(role.isDefault()) .setOrder(role.order()); - setIfNotNull(builder::setId, role.id()); - setIfNotNull(builder::setName, role.name()); - setIfNotNull(builder::setDescription, role.description()); - addAllIfNotNull(builder::addAllPermissions, role.permissions()); - setTimestampIfNotNull(builder::setCreatedAt, role.createdAt()); - setTimestampIfNotNull(builder::setUpdatedAt, role.updatedAt()); + setOptionalString(builder::setId, role.id()); + setOptionalString(builder::setName, role.name()); + setOptionalString(builder::setDescription, role.description()); + setOptionalStrings(builder::addAllPermissions, role.permissions()); + setOptionalTimestamp(builder::setCreatedAt, role.createdAt()); + setOptionalTimestamp(builder::setUpdatedAt, role.updatedAt()); return builder.build(); } - - private static void setIfNotNull(Consumer setter, String value) { - if (value != null) { - setter.accept(value); - } - } - - private static void addAllIfNotNull( - Consumer> setter, - List values - ) { - if (values != null) { - setter.accept(values); - } - } - - private static void setTimestampIfNotNull( - Consumer setter, - Date date - ) { - if (date != null) { - setter.accept(Timestamp.newBuilder() - .setSeconds(date.getTime() / 1_000L) - .setNanos((int) (date.getTime() % 1_000L) * 1_000_000) - .build()); - } - } } diff --git a/src/main/java/gg/modl/backend/role/controller/MinecraftRolesV3Controller.java b/src/main/java/gg/modl/backend/role/controller/MinecraftRolesV3Controller.java index 48b560c..74cf2d1 100644 --- a/src/main/java/gg/modl/backend/role/controller/MinecraftRolesV3Controller.java +++ b/src/main/java/gg/modl/backend/role/controller/MinecraftRolesV3Controller.java @@ -8,7 +8,6 @@ import gg.modl.backend.role.service.RoleService; import gg.modl.backend.server.data.Server; import gg.modl.proto.modl.v1.ApiError; -import gg.modl.proto.modl.v1.MinecraftRoleDetailResponse; import gg.modl.proto.modl.v1.MinecraftRoleListResponse; import gg.modl.proto.modl.v1.MinecraftRoleMutationResponse; import gg.modl.proto.modl.v1.UpdateRolePermissionsRequest; diff --git a/src/main/java/gg/modl/backend/role/controller/PanelRoleController.java b/src/main/java/gg/modl/backend/role/controller/PanelRoleController.java index c95599e..c376191 100644 --- a/src/main/java/gg/modl/backend/role/controller/PanelRoleController.java +++ b/src/main/java/gg/modl/backend/role/controller/PanelRoleController.java @@ -1,161 +1,154 @@ -package gg.modl.backend.role.controller; - -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.log.service.PanelActionAuditor; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.role.data.Permission; -import gg.modl.backend.role.dto.request.ReorderRolesRequest; -import gg.modl.backend.role.dto.request.RoleRequest; -import gg.modl.backend.role.dto.response.RoleResponse; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.role.service.RoleAuthorization; -import gg.modl.backend.role.service.RoleService; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.PanelRoleListResponse; -import gg.modl.proto.modl.v1.PermissionsResponse; -import gg.modl.proto.modl.v1.RoleDetailResponse; -import gg.modl.proto.modl.v1.RoleMutationResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.Validator; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_ROLES) -@RequiredArgsConstructor -public class PanelRoleController { - private final RoleService roleService; - private final PermissionService permissionService; - private final RoleAuthorization roleAuthorization; - private final RealtimeEventPublisher realtimeEventPublisher; - private final PanelActionAuditor panelActionAuditor; - private final Validator validator; - - @GetMapping - public ResponseEntity getAllRoles(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List roles = roleService.getAllRoles(server); - return ResponseEntity.ok(PanelRoleProtoMapper.toRoleListResponse(roles)); - } - - @GetMapping("/permissions") - public ResponseEntity getPermissions(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List permissions = permissionService.getAllPermissions(server); - Map categories = permissionService.getPermissionCategories(); - return ResponseEntity.ok(PanelRoleProtoMapper.toPermissionsResponse(permissions, categories)); - } - - @GetMapping("/{id}") - public ResponseEntity getRoleById( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - return roleService.getRoleById(server, id) - .map(PanelRoleProtoMapper::toRoleDetailResponse) - .map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); - } - - @PostMapping - public ResponseEntity createRole( - @RequestBody gg.modl.proto.modl.v1.RoleRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String performerEmail = RequestUtil.getSessionEmail(request); - RoleAuthorization.PerformerAuthority performer = roleAuthorization.panelPerformer(server, performerEmail); - - RoleRequest mappedRequest = PanelRoleProtoMapper.toRoleRequest(createRequest); - validate(mappedRequest); - RoleResponse role = roleService.createRole(server, mappedRequest, performer); - invalidateRoles(server, role.id()); - panelActionAuditor.recordStaffAction(server, performerEmail, "Created staff role: " + role.name()); - return ResponseEntity.status(HttpStatus.CREATED) - .body(PanelRoleProtoMapper.toRoleMutationResponse("Role created successfully", role)); - } - - @PutMapping("/{id}") - public ResponseEntity updateRole( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.RoleRequest updateRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - RoleAuthorization.PerformerAuthority performer = - roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); - - RoleRequest mappedRequest = PanelRoleProtoMapper.toRoleRequest(updateRequest); - validate(mappedRequest); - return roleService.updateRole(server, id, mappedRequest, performer) - .map(role -> { - invalidateRoles(server, role.id()); - return ResponseEntity.ok( - PanelRoleProtoMapper.toRoleMutationResponse("Role updated successfully", role)); - }) - .orElse(ResponseEntity.notFound().build()); - } - - @DeleteMapping("/{id}") - public ResponseEntity deleteRole( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - RoleAuthorization.PerformerAuthority performer = - roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); - - boolean deleted = roleService.deleteRole(server, id, performer); - if (deleted) { - invalidateRoles(server, id); - return ResponseEntity.ok( - PanelRoleProtoMapper.toRoleMutationResponse("Role deleted successfully", null)); - } - return ResponseEntity.notFound().build(); - } - - @PostMapping("/reorder") - public ResponseEntity reorderRoles( - @RequestBody gg.modl.proto.modl.v1.ReorderRolesRequest reorderRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - RoleAuthorization.PerformerAuthority performer = - roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); - - ReorderRolesRequest mappedRequest = PanelRoleProtoMapper.toReorderRolesRequest(reorderRequest); - roleService.reorderRoles(server, mappedRequest, performer); - invalidateRoles(server, null); - return ResponseEntity.ok( - PanelRoleProtoMapper.toRoleMutationResponse("Role order updated successfully", null)); - } - - private void invalidateRoles(Server server, String roleId) { - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_ROLES, roleId); - } - - private void validate(T request) { - Set> violations = validator.validate(request); - if (!violations.isEmpty()) { - throw new ValidationException(violations.iterator().next().getMessage()); - } - } -} +package gg.modl.backend.role.controller; + +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.validation.BeanValidationRunner; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.log.service.PanelActionAuditor; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.role.data.Permission; +import gg.modl.backend.role.dto.request.ReorderRolesRequest; +import gg.modl.backend.role.dto.request.RoleRequest; +import gg.modl.backend.role.dto.response.RoleResponse; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.role.service.RoleAuthorization; +import gg.modl.backend.role.service.RoleService; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.PanelRoleListResponse; +import gg.modl.proto.modl.v1.PermissionsResponse; +import gg.modl.proto.modl.v1.RoleDetailResponse; +import gg.modl.proto.modl.v1.RoleMutationResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_ROLES) +@RequiresPanelPermission("admin.staff.manage.roles") +@RequiredArgsConstructor +public class PanelRoleController { + private final RoleService roleService; + private final PermissionService permissionService; + private final RoleAuthorization roleAuthorization; + private final RealtimeEventPublisher realtimeEventPublisher; + private final PanelActionAuditor panelActionAuditor; + private final BeanValidationRunner validationRunner; + + @GetMapping + public ResponseEntity getAllRoles(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List roles = roleService.getAllRoles(server); + return ResponseEntity.ok(PanelRoleProtoMapper.toRoleListResponse(roles)); + } + + @GetMapping("/permissions") + public ResponseEntity getPermissions(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List permissions = permissionService.getAllPermissions(server); + Map categories = permissionService.getPermissionCategories(); + return ResponseEntity.ok(PanelRoleProtoMapper.toPermissionsResponse(permissions, categories)); + } + + @GetMapping("/{id}") + public ResponseEntity getRoleById( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + return roleService.getRoleById(server, id) + .map(PanelRoleProtoMapper::toRoleDetailResponse) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity createRole( + @RequestBody gg.modl.proto.modl.v1.RoleRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String performerEmail = RequestUtil.getSessionEmail(request); + RoleAuthorization.PerformerAuthority performer = roleAuthorization.panelPerformer(server, performerEmail); + + RoleRequest mappedRequest = PanelRoleProtoMapper.toRoleRequest(createRequest); + validationRunner.validate(mappedRequest); + RoleResponse role = roleService.createRole(server, mappedRequest, performer); + invalidateRoles(server, role.id()); + panelActionAuditor.recordStaffAction(server, performerEmail, "Created staff role: " + role.name()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(PanelRoleProtoMapper.toRoleMutationResponse("Role created successfully", role)); + } + + @PutMapping("/{id}") + public ResponseEntity updateRole( + @PathVariable String id, + @RequestBody gg.modl.proto.modl.v1.RoleRequest updateRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + RoleAuthorization.PerformerAuthority performer = + roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); + + RoleRequest mappedRequest = PanelRoleProtoMapper.toRoleRequest(updateRequest); + validationRunner.validate(mappedRequest); + return roleService.updateRole(server, id, mappedRequest, performer) + .map(role -> { + invalidateRoles(server, role.id()); + return ResponseEntity.ok( + PanelRoleProtoMapper.toRoleMutationResponse("Role updated successfully", role)); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteRole( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + RoleAuthorization.PerformerAuthority performer = + roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); + + boolean deleted = roleService.deleteRole(server, id, performer); + if (deleted) { + invalidateRoles(server, id); + return ResponseEntity.ok( + PanelRoleProtoMapper.toRoleMutationResponse("Role deleted successfully", null)); + } + return ResponseEntity.notFound().build(); + } + + @PostMapping("/reorder") + public ResponseEntity reorderRoles( + @RequestBody gg.modl.proto.modl.v1.ReorderRolesRequest reorderRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + RoleAuthorization.PerformerAuthority performer = + roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); + + ReorderRolesRequest mappedRequest = PanelRoleProtoMapper.toReorderRolesRequest(reorderRequest); + roleService.reorderRoles(server, mappedRequest, performer); + invalidateRoles(server, null); + return ResponseEntity.ok( + PanelRoleProtoMapper.toRoleMutationResponse("Role order updated successfully", null)); + } + + private void invalidateRoles(Server server, String roleId) { + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_ROLES, roleId); + } + +} diff --git a/src/main/java/gg/modl/backend/role/controller/PanelRoleProtoMapper.java b/src/main/java/gg/modl/backend/role/controller/PanelRoleProtoMapper.java index bdb3202..6880509 100644 --- a/src/main/java/gg/modl/backend/role/controller/PanelRoleProtoMapper.java +++ b/src/main/java/gg/modl/backend/role/controller/PanelRoleProtoMapper.java @@ -1,5 +1,8 @@ package gg.modl.backend.role.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalEpochMillis; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalString; + import gg.modl.backend.role.data.Permission; import gg.modl.backend.role.dto.request.ReorderRolesRequest; import gg.modl.backend.role.dto.request.RoleRequest; @@ -8,10 +11,8 @@ import gg.modl.proto.modl.v1.PermissionsResponse; import gg.modl.proto.modl.v1.RoleDetailResponse; import gg.modl.proto.modl.v1.RoleMutationResponse; -import java.util.Date; import java.util.List; import java.util.Map; -import java.util.function.Consumer; final class PanelRoleProtoMapper { private PanelRoleProtoMapper() { @@ -63,10 +64,10 @@ static PermissionsResponse toPermissionsResponse(List permissions, M private static PermissionsResponse.Permission toPermission(Permission permission) { PermissionsResponse.Permission.Builder builder = PermissionsResponse.Permission.newBuilder(); - setIfNotNull(builder::setId, permission.id()); - setIfNotNull(builder::setName, permission.name()); - setIfNotNull(builder::setDescription, permission.description()); - setIfNotNull(builder::setCategory, permission.category()); + setOptionalString(builder::setId, permission.id()); + setOptionalString(builder::setName, permission.name()); + setOptionalString(builder::setDescription, permission.description()); + setOptionalString(builder::setCategory, permission.category()); if (permission.parentId() != null) { builder.setParentId(permission.parentId()); } @@ -78,26 +79,14 @@ private static gg.modl.proto.modl.v1.RoleResponse toRoleResponse(RoleResponse ro .setIsDefault(role.isDefault()) .setOrder(role.order()) .setUserCount(role.userCount()); - setIfNotNull(builder::setId, role.id()); - setIfNotNull(builder::setName, role.name()); - setIfNotNull(builder::setDescription, role.description()); + setOptionalString(builder::setId, role.id()); + setOptionalString(builder::setName, role.name()); + setOptionalString(builder::setDescription, role.description()); if (role.permissions() != null) { builder.addAllPermissions(role.permissions()); } - setEpochMillisIfNotNull(builder::setCreatedAt, role.createdAt()); - setEpochMillisIfNotNull(builder::setUpdatedAt, role.updatedAt()); + setOptionalEpochMillis(builder::setCreatedAt, role.createdAt()); + setOptionalEpochMillis(builder::setUpdatedAt, role.updatedAt()); return builder.build(); } - - private static void setIfNotNull(Consumer setter, String value) { - if (value != null) { - setter.accept(value); - } - } - - private static void setEpochMillisIfNotNull(Consumer setter, Date date) { - if (date != null) { - setter.accept(date.getTime()); - } - } } diff --git a/src/main/java/gg/modl/backend/role/data/Permission.java b/src/main/java/gg/modl/backend/role/data/Permission.java index f588c7b..3e9ee0f 100644 --- a/src/main/java/gg/modl/backend/role/data/Permission.java +++ b/src/main/java/gg/modl/backend/role/data/Permission.java @@ -1,13 +1,13 @@ -package gg.modl.backend.role.data; - -public record Permission( - String id, - String name, - String description, - String category, - String parentId -) { - public Permission(String id, String name, String description, String category) { - this(id, name, description, category, null); - } -} +package gg.modl.backend.role.data; + +public record Permission( + String id, + String name, + String description, + String category, + String parentId +) { + public Permission(String id, String name, String description, String category) { + this(id, name, description, category, null); + } +} diff --git a/src/main/java/gg/modl/backend/role/data/StaffRole.java b/src/main/java/gg/modl/backend/role/data/StaffRole.java index 7dd53ec..737a815 100644 --- a/src/main/java/gg/modl/backend/role/data/StaffRole.java +++ b/src/main/java/gg/modl/backend/role/data/StaffRole.java @@ -1,48 +1,48 @@ -package gg.modl.backend.role.data; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@GenerateMongoFields -public class StaffRole { - @Id - private String id; - - @Field("name") - private String name; - - @Field("description") - private String description; - - @Field("permissions") - @Builder.Default - private List permissions = new ArrayList<>(); - - @Field("isDefault") - @Builder.Default - private boolean isDefault = false; - - @Field("order") - @Builder.Default - private int order = 999; - - @Field("createdAt") - private Date createdAt; - - @Field("updatedAt") - private Date updatedAt; -} +package gg.modl.backend.role.data; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@GenerateMongoFields +public class StaffRole { + @Id + private String id; + + @Field("name") + private String name; + + @Field("description") + private String description; + + @Field("permissions") + @Builder.Default + private List permissions = new ArrayList<>(); + + @Field("isDefault") + @Builder.Default + private boolean isDefault = false; + + @Field("order") + @Builder.Default + private int order = 999; + + @Field("createdAt") + private Date createdAt; + + @Field("updatedAt") + private Date updatedAt; +} diff --git a/src/main/java/gg/modl/backend/role/dto/response/RoleResponse.java b/src/main/java/gg/modl/backend/role/dto/response/RoleResponse.java index 64b6836..df6cbdb 100644 --- a/src/main/java/gg/modl/backend/role/dto/response/RoleResponse.java +++ b/src/main/java/gg/modl/backend/role/dto/response/RoleResponse.java @@ -1,17 +1,17 @@ -package gg.modl.backend.role.dto.response; - -import java.util.Date; -import java.util.List; - -public record RoleResponse( - String id, - String name, - String description, - List permissions, - boolean isDefault, - int order, - int userCount, - Date createdAt, - Date updatedAt -) { -} +package gg.modl.backend.role.dto.response; + +import java.util.Date; +import java.util.List; + +public record RoleResponse( + String id, + String name, + String description, + List permissions, + boolean isDefault, + int order, + int userCount, + Date createdAt, + Date updatedAt +) { +} diff --git a/src/main/java/gg/modl/backend/role/service/PermissionService.java b/src/main/java/gg/modl/backend/role/service/PermissionService.java index c85947f..588a3f3 100644 --- a/src/main/java/gg/modl/backend/role/service/PermissionService.java +++ b/src/main/java/gg/modl/backend/role/service/PermissionService.java @@ -1,272 +1,285 @@ -package gg.modl.backend.role.service; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffRoleMongoRepository; -import gg.modl.backend.role.data.Permission; -import gg.modl.backend.role.data.StaffRole; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeService; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PermissionService { - private final StaffRoleMongoRepository staffRoleRepository; - private final PunishmentTypeService punishmentTypeService; - private final StaffMongoRepository staffRepository; - - private final Cache permissionCache = Caffeine.newBuilder() - .maximumSize(2000) - .expireAfterWrite(Duration.ofMinutes(2)) - .build(); - - private static final List BASE_PERMISSIONS = List.of( - // Admin permissions - new Permission("admin.settings.view", "View Settings", "View all system settings (includes all sub-permissions)", "admin"), - new Permission("admin.settings.view.punishments", "View Punishments Config", "View punishment type configuration", "admin", "admin.settings.view"), - new Permission("admin.settings.view.content", "View Content", "View homepage cards, knowledgebase, media", "admin", "admin.settings.view"), - new Permission("admin.settings.view.domain", "View Domain", "View custom domain configuration", "admin", "admin.settings.view"), - new Permission("admin.settings.view.billing", "View Billing", "View billing, subscription, and payment info", "admin", "admin.settings.view"), - new Permission("admin.settings.view.migration", "View Migration", "View import/export data configuration", "admin", "admin.settings.view"), - new Permission("admin.settings.view.storage", "View Storage", "View storage configuration", "admin", "admin.settings.view"), - new Permission("admin.settings.modify", "Modify Settings", "Full control over system settings (includes all sub-permissions)", "admin"), - new Permission("admin.settings.modify.punishments", "Modify Punishments Config", "Create/edit/delete punishment types", "admin", - "admin.settings.modify"), - new Permission("admin.settings.modify.content", "Modify Content", "Edit homepage cards, knowledgebase, media", "admin", "admin.settings.modify"), - new Permission("admin.settings.modify.domain", "Modify Domain", "Change custom domain configuration", "admin", "admin.settings.modify"), - new Permission("admin.settings.modify.billing", "Modify Billing", "Update subscription and payment methods", "admin", "admin.settings.modify"), - new Permission("admin.settings.modify.migration", "Modify Migration", "Import/export data between platforms", "admin", "admin.settings.modify"), - new Permission("admin.settings.modify.storage", "Modify Storage", "Configure storage backends and limits", "admin", "admin.settings.modify"), - new Permission("admin.staff.manage", "Manage Staff", "Full staff management (includes all sub-permissions)", "admin"), - new Permission("admin.staff.manage.members", "Manage Members", "Invite, remove, and reassign staff", "admin", "admin.staff.manage"), - new Permission("admin.staff.manage.roles", "Manage Roles", "Create/edit/delete roles and permissions", "admin", "admin.staff.manage"), - new Permission("admin.audit.view", "View Audit", "Full audit access (includes all sub-permissions)", "admin"), - new Permission("admin.audit.view.dashboard", "View Dashboard", "View dashboard statistics", "admin", "admin.audit.view"), - new Permission("admin.audit.view.analytics", "View Analytics", "View player and ticket analytics", "admin", "admin.audit.view"), - new Permission("admin.audit.view.logs", "View Logs", "View audit trail of staff actions", "admin", "admin.audit.view"), - - // Punishment permissions - new Permission("punishment.view", "View Punishments", "View player profiles, punishments, and linked accounts", "punishment"), - new Permission("punishment.modify", "Modify Punishments", "Full control over existing punishments (includes all sub-permissions)", "punishment"), - new Permission("punishment.modify.pardon", "Pardon Punishments", "Pardon punishments and clear associated points", "punishment", "punishment.modify"), - new Permission("punishment.modify.duration", "Modify Duration", "Change punishment duration", "punishment", "punishment.modify"), - new Permission("punishment.modify.note", "Add Notes", "Add staff notes to punishments", "punishment", "punishment.modify"), - new Permission("punishment.modify.evidence", "Manage Evidence", "Add and view evidence on punishments", "punishment", "punishment.modify"), - new Permission("punishment.modify.options", "Toggle Options", "Toggle alt-blocking and stat-wipe options", "punishment", "punishment.modify"), - - // Staff tool permissions - new Permission("staff.chat.toggle", "Toggle Chat", "Toggle server chat on/off", "staff"), - new Permission("staff.chat.clear", "Clear Chat", "Clear server chat", "staff"), - new Permission("staff.chat.slow", "Slow Chat", "Set slow mode on server chat", "staff"), - new Permission("staff.maintenance", "Maintenance Mode", "Toggle server maintenance mode", "staff"), - new Permission("staff.modactions", "Moderation Actions", "Staff mode, vanish, freeze, and target players", "staff"), - new Permission("staff.intercept", "Intercept Chat", "Intercept and view all network chat", "staff"), - new Permission("staff.chatlogs", "Chat Logs", "View player chat history", "staff"), - new Permission("staff.commandlogs", "Command Logs", "View player command history", "staff"), - - // Ticket permissions - new Permission("ticket.view.all", "View All Tickets", "View all tickets (includes all sub-permissions)", "ticket"), - new Permission("ticket.view.all.notes", "View Staff Notes", "View internal staff notes on tickets", "ticket", "ticket.view.all"), - new Permission("ticket.reply.all", "Reply to All Tickets", "Reply to all ticket types (includes all sub-permissions)", "ticket"), - new Permission("ticket.reply.all.notes", "Add Staff Notes", "Add staff-only internal notes", "ticket", "ticket.reply.all"), - new Permission("appeal.modify", "Modify Appeals", "Reply to and update appeals", "ticket"), - new Permission("ticket.close.all", "Close/Reopen All Tickets", "Close and reopen all ticket types (includes all sub-permissions)", "ticket"), - new Permission("ticket.close.all.lock", "Lock Tickets", "Lock tickets to prevent further replies", "ticket", "ticket.close.all"), - new Permission("ticket.manage", "Manage Tickets", "Advanced ticket management (includes all sub-permissions)", "ticket"), - new Permission("ticket.manage.tags", "Manage Tags", "Add and remove tags from tickets", "ticket", "ticket.manage"), - new Permission("ticket.manage.hide", "Hide Tickets", "Hide tickets from public view", "ticket", "ticket.manage"), - new Permission("ticket.manage.subscribe", "Manage Subscriptions", "Manage ticket notification subscriptions", "ticket", "ticket.manage"), - new Permission("ticket.delete.all", "Delete Tickets", "Delete tickets from the system", "ticket") - ); - - private static final Map PERMISSION_CATEGORIES = Map.of( - "punishment", "Punishment Permissions", - "ticket", "Ticket Permissions", - "admin", "Administrative Permissions", - "staff", "Staff Tool Permissions" - ); - - public List getBasePermissions() { - return BASE_PERMISSIONS; - } - - public Map getPermissionCategories() { - return PERMISSION_CATEGORIES; - } - - public List getAllPermissionIds(Server server) { - return getAllPermissions(server).stream().map(Permission::id).toList(); - } - - public List getAllPermissions(Server server) { - List all = new ArrayList<>(BASE_PERMISSIONS); - all.addAll(getPunishmentPermissions(server)); - return all; - } - - public List getPunishmentPermissions(Server server) { - List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); - List permissions = new ArrayList<>(); - - punishmentTypes.forEach(type -> { - String permId = punishmentApplyPermissionId(type.getName()); - permissions.add(new Permission( - permId, - "Apply " + type.getName(), - "Permission to apply " + type.getName() + " punishments", - "punishment" - )); - }); - - return permissions; - } - - public static String punishmentApplyPermissionId(String typeName) { - return "punishment.apply." + typeName.toLowerCase().replace(" ", "-"); - } - - public void renamePunishmentApplyPermission(Server server, String oldName, String newName) { - if (oldName == null || newName == null) { - return; - } - String oldId = punishmentApplyPermissionId(oldName); - String newId = punishmentApplyPermissionId(newName); - if (oldId.equals(newId)) { - return; - } - - boolean changed = false; - for (StaffRole role : staffRoleRepository.findAllOrdered(server)) { - List permissions = role.getPermissions(); - if (permissions == null || !permissions.contains(oldId)) { - continue; - } - List migrated = new ArrayList<>(); - for (String permission : permissions) { - String replacement = permission.equals(oldId) ? newId : permission; - if (!migrated.contains(replacement)) { - migrated.add(replacement); - } - } - role.setPermissions(migrated); - staffRoleRepository.upsertRole(server, role); - changed = true; - } - - if (changed) { - permissionCache.invalidateAll(); - } - } - - public boolean hasPermission(Server server, String roleId, String permission) { - if (roleId == null || roleId.isBlank()) { - return false; - } - - String trimmedRole = roleId.trim(); - String cacheKey = server.getId() + ":" + trimmedRole + ":" + permission; - return permissionCache.get(cacheKey, key -> computeHasPermission(server, trimmedRole, permission)); - } - - private boolean computeHasPermission(Server server, String roleId, String permission) { - StaffRole role = staffRoleRepository.findById(server, roleId).orElse(null); - if (role == null) { - return false; - } - return RoleAuthorization.roleGrants(role, permission); - } - - public boolean hasAnyPermissionWithPrefix(Server server, String roleId, String prefix) { - if (roleId == null || roleId.isBlank()) { - return false; - } - - String trimmedRole = roleId.trim(); - String cacheKey = server.getId() + ":" + trimmedRole + ":prefix:" + prefix; - return permissionCache.get(cacheKey, key -> computeHasPermissionWithPrefix(server, trimmedRole, prefix)); - } - - private boolean computeHasPermissionWithPrefix(Server server, String roleId, String prefix) { - StaffRole role = staffRoleRepository.findById(server, roleId).orElse(null); - if (role == null) { - return false; - } - if (RoleAuthorization.isSuperAdminRole(role)) { - return true; - } - return role.getPermissions().stream().anyMatch(p -> p.startsWith(prefix)); - } - - public void evictPermissionCache() { - permissionCache.invalidateAll(); - } - - public Optional getRoleByName(Server server, String roleName) { - if (roleName == null || roleName.isBlank()) { - return Optional.empty(); - } - - return staffRoleRepository.findAllByName(server, roleName.trim()).stream() - .min(Comparator.comparingInt(StaffRole::getOrder) - .thenComparing(StaffRole::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) - .thenComparing(StaffRole::getId)); - } - - public Optional getRoleById(Server server, String roleId) { - if (roleId == null || roleId.isBlank()) { - return Optional.empty(); - } - - return staffRoleRepository.findById(server, roleId.trim()); - } - - public Map getRolesByIds(Server server, Collection roleIds) { - Set ids = roleIds.stream() - .filter(id -> id != null && !id.isBlank()) - .collect(Collectors.toSet()); - if (ids.isEmpty()) { - return Map.of(); - } - return staffRoleRepository.findByIds(server, ids).stream() - .collect(Collectors.toMap(StaffRole::getId, Function.identity(), (left, right) -> left, LinkedHashMap::new)); - } - - public Map resolveRoleNames(Server server, Collection roleIds) { - Map names = new LinkedHashMap<>(); - getRolesByIds(server, roleIds).forEach((id, role) -> names.put(id, role.getName())); - return names; - } - - public String resolveRoleName(Server server, String roleId) { - if (roleId == null || roleId.isBlank()) { - return ""; - } - return getRoleById(server, roleId).map(StaffRole::getName).orElse(roleId); - } - - public boolean isSuperAdmin(Server server, String staffEmail) { - return RoleAuthorization.isSuperAdminEmail(server, staffEmail); - } - - public boolean isAuthorizedEmail(Server server, String email) { - if (isSuperAdmin(server, email)) { - return true; - } - return staffRepository.findByEmailIgnoreCase(server, email).isPresent(); - } -} +package gg.modl.backend.role.service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffRoleMongoRepository; +import gg.modl.backend.role.data.Permission; +import gg.modl.backend.role.data.StaffRole; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeService; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PermissionService { + private final StaffRoleMongoRepository staffRoleRepository; + private final PunishmentTypeService punishmentTypeService; + private final StaffMongoRepository staffRepository; + + private final Cache permissionCache = Caffeine.newBuilder() + .maximumSize(2000) + .expireAfterWrite(Duration.ofMinutes(2)) + .build(); + + public static final String ADMIN_SETTINGS_VIEW = "admin.settings.view"; + public static final String ADMIN_STAFF_MANAGE = "admin.staff.manage"; + public static final String ADMIN_AUDIT_VIEW = "admin.audit.view"; + public static final String ADMIN_AUDIT_ROLLBACK = "admin.audit.rollback"; + public static final String PUNISHMENT_VIEW = "punishment.view"; + public static final String PUNISHMENT_MODIFY = "punishment.modify"; + public static final String TICKET_VIEW_ALL = "ticket.view.all"; + public static final String TICKET_REPLY_ALL = "ticket.reply.all"; + public static final String APPEAL_MODIFY = "appeal.modify"; + public static final String TICKET_CLOSE_ALL = "ticket.close.all"; + public static final String STAFF_CHAT_TOGGLE = "staff.chat.toggle"; + public static final String STAFF_CHAT_CLEAR = "staff.chat.clear"; + public static final String STAFF_CHAT_SLOW = "staff.chat.slow"; + public static final String STAFF_MAINTENANCE = "staff.maintenance"; + public static final String STAFF_MODACTIONS = "staff.modactions"; + public static final String STAFF_INTERCEPT = "staff.intercept"; + public static final String STAFF_CHATLOGS = "staff.chatlogs"; + public static final String STAFF_COMMANDLOGS = "staff.commandlogs"; + + private static final List BASE_PERMISSIONS = List.of( + new Permission(ADMIN_SETTINGS_VIEW, "View Settings", "View all system settings (includes all sub-permissions)", "admin"), + new Permission("admin.settings.view.punishments", "View Punishments Config", "View punishment type configuration", "admin", ADMIN_SETTINGS_VIEW), + new Permission("admin.settings.view.content", "View Content", "View homepage cards, knowledgebase, media", "admin", ADMIN_SETTINGS_VIEW), + new Permission("admin.settings.view.domain", "View Domain", "View custom domain configuration", "admin", ADMIN_SETTINGS_VIEW), + new Permission("admin.settings.view.billing", "View Billing", "View billing, subscription, and payment info", "admin", ADMIN_SETTINGS_VIEW), + new Permission("admin.settings.view.migration", "View Migration", "View import/export data configuration", "admin", ADMIN_SETTINGS_VIEW), + new Permission("admin.settings.view.storage", "View Storage", "View storage configuration", "admin", ADMIN_SETTINGS_VIEW), + new Permission("admin.settings.modify", "Modify Settings", "Full control over system settings (includes all sub-permissions)", "admin"), + new Permission("admin.settings.modify.punishments", "Modify Punishments Config", "Create/edit/delete punishment types", "admin", + "admin.settings.modify"), + new Permission("admin.settings.modify.content", "Modify Content", "Edit homepage cards, knowledgebase, media", "admin", "admin.settings.modify"), + new Permission("admin.settings.modify.domain", "Modify Domain", "Change custom domain configuration", "admin", "admin.settings.modify"), + new Permission("admin.settings.modify.billing", "Modify Billing", "Update subscription and payment methods", "admin", "admin.settings.modify"), + new Permission("admin.settings.modify.migration", "Modify Migration", "Import/export data between platforms", "admin", "admin.settings.modify"), + new Permission("admin.settings.modify.storage", "Modify Storage", "Configure storage backends and limits", "admin", "admin.settings.modify"), + new Permission(ADMIN_STAFF_MANAGE, "Manage Staff", "Full staff management (includes all sub-permissions)", "admin"), + new Permission("admin.staff.manage.members", "Manage Members", "Invite, remove, and reassign staff", "admin", ADMIN_STAFF_MANAGE), + new Permission("admin.staff.manage.roles", "Manage Roles", "Create/edit/delete roles and permissions", "admin", ADMIN_STAFF_MANAGE), + new Permission(ADMIN_AUDIT_VIEW, "View Audit", "Full audit access (includes all sub-permissions)", "admin"), + new Permission("admin.audit.view.dashboard", "View Dashboard", "View dashboard statistics", "admin", ADMIN_AUDIT_VIEW), + new Permission("admin.audit.view.analytics", "View Analytics", "View player and ticket analytics", "admin", ADMIN_AUDIT_VIEW), + new Permission("admin.audit.view.logs", "View Logs", "View audit trail of staff actions", "admin", ADMIN_AUDIT_VIEW), + new Permission(ADMIN_AUDIT_ROLLBACK, "Rollback Audit Actions", "Roll back punishments and perform destructive bulk audit operations", "admin"), + new Permission(PUNISHMENT_VIEW, "View Punishments", "View player profiles, punishments, and linked accounts", "punishment"), + new Permission(PUNISHMENT_MODIFY, "Modify Punishments", "Full control over existing punishments (includes all sub-permissions)", "punishment"), + new Permission("punishment.modify.pardon", "Pardon Punishments", "Pardon punishments and clear associated points", "punishment", PUNISHMENT_MODIFY), + new Permission("punishment.modify.duration", "Modify Duration", "Change punishment duration", "punishment", PUNISHMENT_MODIFY), + new Permission("punishment.modify.note", "Add Notes", "Add staff notes to punishments", "punishment", PUNISHMENT_MODIFY), + new Permission("punishment.modify.evidence", "Manage Evidence", "Add and view evidence on punishments", "punishment", PUNISHMENT_MODIFY), + new Permission("punishment.modify.options", "Toggle Options", "Toggle alt-blocking and stat-wipe options", "punishment", PUNISHMENT_MODIFY), + new Permission(STAFF_CHAT_TOGGLE, "Toggle Chat", "Toggle server chat on/off", "staff"), + new Permission(STAFF_CHAT_CLEAR, "Clear Chat", "Clear server chat", "staff"), + new Permission(STAFF_CHAT_SLOW, "Slow Chat", "Set slow mode on server chat", "staff"), + new Permission(STAFF_MAINTENANCE, "Maintenance Mode", "Toggle server maintenance mode", "staff"), + new Permission(STAFF_MODACTIONS, "Moderation Actions", "Staff mode, vanish, freeze, and target players", "staff"), + new Permission(STAFF_INTERCEPT, "Intercept Chat", "Intercept and view all network chat", "staff"), + new Permission(STAFF_CHATLOGS, "Chat Logs", "View player chat history", "staff"), + new Permission(STAFF_COMMANDLOGS, "Command Logs", "View player command history", "staff"), + new Permission(TICKET_VIEW_ALL, "View All Tickets", "View all tickets (includes all sub-permissions)", "ticket"), + new Permission("ticket.view.all.notes", "View Staff Notes", "View internal staff notes on tickets", "ticket", TICKET_VIEW_ALL), + new Permission(TICKET_REPLY_ALL, "Reply to All Tickets", "Reply to all ticket types (includes all sub-permissions)", "ticket"), + new Permission("ticket.reply.all.notes", "Add Staff Notes", "Add staff-only internal notes", "ticket", TICKET_REPLY_ALL), + new Permission(APPEAL_MODIFY, "Modify Appeals", "Reply to and update appeals", "ticket"), + new Permission(TICKET_CLOSE_ALL, "Close/Reopen All Tickets", "Close and reopen all ticket types (includes all sub-permissions)", "ticket"), + new Permission("ticket.close.all.lock", "Lock Tickets", "Lock tickets to prevent further replies", "ticket", TICKET_CLOSE_ALL), + new Permission("ticket.manage", "Manage Tickets", "Advanced ticket management (includes all sub-permissions)", "ticket"), + new Permission("ticket.manage.tags", "Manage Tags", "Add and remove tags from tickets", "ticket", "ticket.manage"), + new Permission("ticket.manage.hide", "Hide Tickets", "Hide tickets from public view", "ticket", "ticket.manage"), + new Permission("ticket.manage.subscribe", "Manage Subscriptions", "Manage ticket notification subscriptions", "ticket", "ticket.manage"), + new Permission("ticket.delete.all", "Delete Tickets", "Delete tickets from the system", "ticket") + ); + + private static final Map PERMISSION_CATEGORIES = Map.of( + "punishment", "Punishment Permissions", + "ticket", "Ticket Permissions", + "admin", "Administrative Permissions", + "staff", "Staff Tool Permissions" + ); + + public List getBasePermissions() { + return BASE_PERMISSIONS; + } + + public Map getPermissionCategories() { + return PERMISSION_CATEGORIES; + } + + public List getAllPermissionIds(Server server) { + return getAllPermissions(server).stream().map(Permission::id).toList(); + } + + public List getAllPermissions(Server server) { + List all = new ArrayList<>(BASE_PERMISSIONS); + all.addAll(getPunishmentPermissions(server)); + return all; + } + + public List getPunishmentPermissions(Server server) { + List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); + List permissions = new ArrayList<>(); + + punishmentTypes.forEach(type -> { + String permId = punishmentApplyPermissionId(type.getName()); + permissions.add(new Permission( + permId, + "Apply " + type.getName(), + "Permission to apply " + type.getName() + " punishments", + "punishment" + )); + }); + + return permissions; + } + + public static String punishmentApplyPermissionId(String typeName) { + return "punishment.apply." + typeName.toLowerCase().replace(" ", "-"); + } + + public void renamePunishmentApplyPermission(Server server, String oldName, String newName) { + if (oldName == null || newName == null) { + return; + } + String oldId = punishmentApplyPermissionId(oldName); + String newId = punishmentApplyPermissionId(newName); + if (oldId.equals(newId)) { + return; + } + + boolean changed = false; + for (StaffRole role : staffRoleRepository.findAllOrdered(server)) { + List permissions = role.getPermissions(); + if (permissions == null || !permissions.contains(oldId)) { + continue; + } + List migrated = new ArrayList<>(); + for (String permission : permissions) { + String replacement = permission.equals(oldId) ? newId : permission; + if (!migrated.contains(replacement)) { + migrated.add(replacement); + } + } + role.setPermissions(migrated); + staffRoleRepository.upsertRole(server, role); + changed = true; + } + + if (changed) { + permissionCache.invalidateAll(); + } + } + + public boolean hasPermission(Server server, String roleId, String permission) { + if (roleId == null || roleId.isBlank()) { + return false; + } + + String trimmedRole = roleId.trim(); + String cacheKey = server.getId() + ":" + trimmedRole + ":" + permission; + return permissionCache.get(cacheKey, key -> computeHasPermission(server, trimmedRole, permission)); + } + + private boolean computeHasPermission(Server server, String roleId, String permission) { + StaffRole role = staffRoleRepository.findById(server, roleId).orElse(null); + if (role == null) { + return false; + } + return RoleAuthorization.roleGrants(role, permission); + } + + public boolean hasAnyPermissionWithPrefix(Server server, String roleId, String prefix) { + if (roleId == null || roleId.isBlank()) { + return false; + } + + String trimmedRole = roleId.trim(); + String cacheKey = server.getId() + ":" + trimmedRole + ":prefix:" + prefix; + return permissionCache.get(cacheKey, key -> computeHasPermissionWithPrefix(server, trimmedRole, prefix)); + } + + private boolean computeHasPermissionWithPrefix(Server server, String roleId, String prefix) { + StaffRole role = staffRoleRepository.findById(server, roleId).orElse(null); + if (role == null) { + return false; + } + if (RoleAuthorization.isSuperAdminRole(role)) { + return true; + } + return role.getPermissions().stream().anyMatch(p -> p.startsWith(prefix)); + } + + public void evictPermissionCache() { + permissionCache.invalidateAll(); + } + + public Optional getRoleByName(Server server, String roleName) { + if (roleName == null || roleName.isBlank()) { + return Optional.empty(); + } + + return staffRoleRepository.findAllByName(server, roleName.trim()).stream() + .min(Comparator.comparingInt(StaffRole::getOrder) + .thenComparing(StaffRole::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(StaffRole::getId)); + } + + public Optional getRoleById(Server server, String roleId) { + if (roleId == null || roleId.isBlank()) { + return Optional.empty(); + } + + return staffRoleRepository.findById(server, roleId.trim()); + } + + public Map getRolesByIds(Server server, Collection roleIds) { + Set ids = roleIds.stream() + .filter(id -> id != null && !id.isBlank()) + .collect(Collectors.toSet()); + if (ids.isEmpty()) { + return Map.of(); + } + return staffRoleRepository.findByIds(server, ids).stream() + .collect(Collectors.toMap(StaffRole::getId, Function.identity(), (left, right) -> left, LinkedHashMap::new)); + } + + public Map resolveRoleNames(Server server, Collection roleIds) { + Map names = new LinkedHashMap<>(); + getRolesByIds(server, roleIds).forEach((id, role) -> names.put(id, role.getName())); + return names; + } + + public String resolveRoleName(Server server, String roleId) { + if (roleId == null || roleId.isBlank()) { + return ""; + } + return getRoleById(server, roleId).map(StaffRole::getName).orElse(roleId); + } + + public boolean isSuperAdmin(Server server, String staffEmail) { + return RoleAuthorization.isSuperAdminEmail(server, staffEmail); + } + + public boolean isAuthorizedEmail(Server server, String email) { + if (isSuperAdmin(server, email)) { + return true; + } + return staffRepository.findByEmailIgnoreCase(server, email).isPresent(); + } +} diff --git a/src/main/java/gg/modl/backend/role/service/RoleAuthorization.java b/src/main/java/gg/modl/backend/role/service/RoleAuthorization.java index 8f760f9..adbb792 100644 --- a/src/main/java/gg/modl/backend/role/service/RoleAuthorization.java +++ b/src/main/java/gg/modl/backend/role/service/RoleAuthorization.java @@ -18,7 +18,7 @@ public class RoleAuthorization { public static final String MANAGE_MEMBERS_PERMISSION = "admin.staff.manage.members"; public static final String MANAGE_ROLES_PERMISSION = "admin.staff.manage.roles"; - private static final String SUPER_ADMIN_ROLE_NAME = "Super Admin"; + public static final String SUPER_ADMIN_ROLE_NAME = "Super Admin"; private static final String NO_AUTHORITY_MESSAGE = "You do not have authority to perform this action"; private static final String NO_GRANT_AUTHORITY_MESSAGE = "You do not have authority to grant this role"; private static final String HIGHER_AUTHORITY_MESSAGE = "You do not have authority over a role at or above your own level"; diff --git a/src/main/java/gg/modl/backend/role/service/RoleService.java b/src/main/java/gg/modl/backend/role/service/RoleService.java index eb7e3c3..24baa5c 100644 --- a/src/main/java/gg/modl/backend/role/service/RoleService.java +++ b/src/main/java/gg/modl/backend/role/service/RoleService.java @@ -1,394 +1,400 @@ -package gg.modl.backend.role.service; - -import gg.modl.backend.infrastructure.exception.ConflictException; -import gg.modl.backend.infrastructure.exception.ForbiddenException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffRoleMongoRepository; -import gg.modl.backend.role.data.Permission; -import gg.modl.backend.role.data.StaffRole; -import gg.modl.backend.role.dto.request.ReorderRolesRequest; -import gg.modl.backend.role.dto.request.RoleRequest; -import gg.modl.backend.role.dto.response.RoleResponse; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.service.ServerTimestampService; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.Date; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Function; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class RoleService { - private final StaffRoleMongoRepository staffRoleRepository; - private final StaffMongoRepository staffRepository; - private final PermissionService permissionService; - private final RoleAuthorization roleAuthorization; - private final ServerTimestampService serverTimestampService; - - private final Set orderingRepairedServers = ConcurrentHashMap.newKeySet(); - - public List getAllRoles(Server server) { - repairCustomRoleOrderingOnce(server); - - List roles = staffRoleRepository.findAllOrdered(server); - Map roleCounts = staffRepository.countByRoleId(server); - - return roles.stream() - .map(role -> toRoleResponse(role, roleCounts.getOrDefault(role.getId(), 0))) - .toList(); - } - - private void repairCustomRoleOrderingOnce(Server server) { - String serverId = server.getId(); - if (serverId != null && orderingRepairedServers.contains(serverId)) { - return; - } - fixCustomRoleOrdering(server); - if (serverId != null) { - orderingRepairedServers.add(serverId); - } - } - - private void fixCustomRoleOrdering(Server server) { - List problematicRoles = staffRoleRepository.findCustomRolesWithOrderZero(server); - if (problematicRoles.isEmpty()) { - return; - } - - StaffRole highestRole = staffRoleRepository.findHighestOrdered(server).orElse(null); - int baseOrder = highestRole != null ? Math.max(highestRole.getOrder(), 3) + 1 : 4; - - List sorted = problematicRoles.stream() - .sorted(Comparator.comparing(StaffRole::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) - .thenComparing(StaffRole::getId)) - .toList(); - - Map orderById = new LinkedHashMap<>(); - int nextOrder = baseOrder; - for (StaffRole role : sorted) { - orderById.put(role.getId(), nextOrder++); - } - staffRoleRepository.bulkRepairOrderFromZero(server, orderById); - } - - private RoleResponse toRoleResponse(StaffRole role, int userCount) { - return new RoleResponse( - role.getId(), - role.getName(), - role.getDescription(), - role.getPermissions(), - role.isDefault(), - role.getOrder(), - userCount, - role.getCreatedAt(), - role.getUpdatedAt() - ); - } - - public Optional getRoleById(Server server, String id) { - StaffRole role = staffRoleRepository.findById(server, id).orElse(null); - - if (role == null) { - return Optional.empty(); - } - - int staffCount = getStaffCountForRole(server, role.getId()); - return Optional.of(toRoleResponse(role, staffCount)); - } - - private int getStaffCountForRole(Server server, String roleId) { - return staffRepository.countByRoleId(server, roleId); - } - - public boolean updateRolePermissions(Server server, String id, List permissions, - RoleAuthorization.PerformerAuthority performer) { - if (RoleAuthorization.isSuperAdminRoleId(id)) { - throw new ForbiddenException("Cannot modify Super Admin role"); - } - - roleAuthorization.requireStaffManage(server, performer, RoleAuthorization.MANAGE_ROLES_PERMISSION); - - StaffRole targetRole = staffRoleRepository.findById(server, id).orElse(null); - if (targetRole == null) { - return false; - } - - Set validPermissions = new HashSet<>(permissionService.getAllPermissionIds(server)); - List requested = permissions != null ? permissions : List.of(); - List filtered = requested.stream() - .filter(validPermissions::contains) - .distinct() - .collect(Collectors.toCollection(ArrayList::new)); - - if (!performer.superAdmin()) { - StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); - roleAuthorization.assertHigherAuthority(performerRole, targetRole); - filtered = filterToGrantableOrExisting(performerRole, targetRole, filtered); - } - - targetRole.setPermissions(filtered); - targetRole.setUpdatedAt(new Date()); - staffRoleRepository.saveEntity(server, targetRole); - invalidatePermissionState(server); - return true; - } - - private void invalidatePermissionState(Server server) { - permissionService.evictPermissionCache(); - serverTimestampService.updateStaffPermissionsTimestamp(server); - } - - public RoleResponse createRole(Server server, RoleRequest request, RoleAuthorization.PerformerAuthority performer) { - String roleName = request.name() != null ? request.name().trim() : ""; - ensureRoleNameAvailable(server, roleName, null); - - Set validPermissions = new HashSet<>(permissionService.getAllPermissionIds(server)); - List filteredPermissions = request.permissions() - .stream() - .filter(validPermissions::contains) - .toList(); - - if (!performer.superAdmin()) { - StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); - filteredPermissions = filterToGrantablePermissions(performerRole, filteredPermissions); - } - - // Generate unique ID - String id = "custom-" + System.currentTimeMillis() + "-" + UUID.randomUUID().toString().substring(0, 8); - - // Find highest order and add 1 - StaffRole highestRole = staffRoleRepository.findHighestOrdered(server).orElse(null); - int nextOrder = highestRole != null ? highestRole.getOrder() + 1 : 4; - - StaffRole newRole = StaffRole.builder() - .id(id) - .name(roleName) - .description(request.description()) - .permissions(new ArrayList<>(filteredPermissions)) - .isDefault(false) - .order(nextOrder) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(); - - staffRoleRepository.saveEntity(server, newRole); - - return toRoleResponse(newRole, 0); - } - - private List filterToGrantablePermissions(StaffRole performerRole, List permissions) { - return permissions.stream() - .filter(p -> RoleAuthorization.roleGrants(performerRole, p)) - .toList(); - } - - private List filterToGrantableOrExisting(StaffRole performerRole, StaffRole targetRole, List permissions) { - Set existing = new HashSet<>(targetRole.getPermissions() != null ? targetRole.getPermissions() : List.of()); - return permissions.stream() - .filter(p -> RoleAuthorization.roleGrants(performerRole, p) || existing.contains(p)) - .collect(Collectors.toCollection(ArrayList::new)); - } - - private void ensureRoleNameAvailable(Server server, String roleName, String excludeRoleId) { - if (roleName == null || roleName.isBlank()) { - throw new ValidationException("Role name cannot be empty"); - } - - boolean exists = excludeRoleId != null && !excludeRoleId.isBlank() - ? staffRoleRepository.existsByNameIgnoreCaseExcludingId(server, roleName, excludeRoleId) - : staffRoleRepository.existsByNameIgnoreCase(server, roleName); - if (exists) { - throw new ConflictException("A role with this name already exists"); - } - } - - public Optional updateRole(Server server, String id, RoleRequest request, RoleAuthorization.PerformerAuthority performer) { - if (RoleAuthorization.isSuperAdminRoleId(id)) { - throw new ForbiddenException("Cannot modify Super Admin role"); - } - - StaffRole updated = staffRoleRepository.findById(server, id).orElse(null); - if (updated == null) { - return Optional.empty(); - } - - StaffRole performerRole = performer.superAdmin() ? null : roleAuthorization.requirePerformerRole(server, performer); - if (performerRole != null) { - roleAuthorization.assertHigherAuthority(performerRole, updated); - } - - String roleName = request.name() != null ? request.name().trim() : ""; - ensureRoleNameAvailable(server, roleName, id); - - Set validPermissions = new HashSet<>(permissionService.getAllPermissionIds(server)); - List filteredPermissions = request.permissions() - .stream() - .filter(validPermissions::contains) - .toList(); - if (performerRole != null) { - filteredPermissions = filterToGrantableOrExisting(performerRole, updated, filteredPermissions); - } - - updated.setName(roleName); - updated.setDescription(request.description()); - updated.setPermissions(new ArrayList<>(filteredPermissions)); - updated.setUpdatedAt(new Date()); - updated = staffRoleRepository.saveEntity(server, updated); - invalidatePermissionState(server); - - int staffCount = getStaffCountForRole(server, updated.getId()); - return Optional.of(toRoleResponse(updated, staffCount)); - } - - public boolean deleteRole(Server server, String id, RoleAuthorization.PerformerAuthority performer) { - if (RoleAuthorization.isSuperAdminRoleId(id)) { - throw new ForbiddenException("Cannot delete Super Admin role"); - } - - StaffRole role = staffRoleRepository.findById(server, id).orElse(null); - if (role == null) { - return false; - } - - if (!performer.superAdmin()) { - StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); - roleAuthorization.assertHigherAuthority(performerRole, role); - } - - int staffCount = getStaffCountForRole(server, role.getId()); - if (staffCount > 0) { - throw new ConflictException("Cannot delete role that is currently assigned to staff members"); - } - - boolean deleted = staffRoleRepository.deleteById(server, id); - if (deleted) { - invalidatePermissionState(server); - } - return deleted; - } - - public void reorderRoles(Server server, ReorderRolesRequest request, RoleAuthorization.PerformerAuthority performer) { - List items = request.roleOrder(); - if (items.isEmpty()) return; - - if (!performer.superAdmin()) { - StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); - int performerOrder = performerRole.getOrder(); - - List ids = items.stream().map(ReorderRolesRequest.RoleOrderItem::id).toList(); - Map rolesById = staffRoleRepository.findByIds(server, ids) - .stream() - .collect(Collectors.toMap(StaffRole::getId, Function.identity())); - - for (ReorderRolesRequest.RoleOrderItem item : items) { - StaffRole targetRole = rolesById.get(item.id()); - if (targetRole == null) continue; - - if (targetRole.getOrder() <= performerOrder) { - throw new ForbiddenException("You do not have authority to reorder this role"); - } - - if (item.order() <= performerOrder) { - throw new ForbiddenException("You cannot promote a role to or above your own authority level"); - } - } - } - - Map orderById = new LinkedHashMap<>(); - for (ReorderRolesRequest.RoleOrderItem item : items) { - orderById.put(item.id(), item.order()); - } - staffRoleRepository.bulkUpdateOrder(server, orderById); - serverTimestampService.updateStaffPermissionsTimestamp(server); - } - - public void createDefaultRoles(Server server) { - List allPunishmentPerms = permissionService.getPunishmentPermissions(server) - .stream() - .map(Permission::id) - .toList(); - List moderatorPunishmentPerms = allPunishmentPerms.stream() - .filter(p -> !p.contains("blacklist")) - .toList(); - - List superAdminPerms = new ArrayList<>(permissionService.getAllPermissionIds(server)); - - List adminPerms = new ArrayList<>(List.of( - "admin.settings.view", "admin.staff.manage", "admin.audit.view", - "punishment.view", "punishment.modify", - "ticket.view.all", "ticket.reply.all", "appeal.modify", "ticket.close.all", - "staff.chat.toggle", "staff.chat.clear", "staff.chat.slow", - "staff.maintenance", "staff.modactions", - "staff.intercept", "staff.chatlogs", "staff.commandlogs" - )); - adminPerms.addAll(allPunishmentPerms); - - List moderatorPerms = new ArrayList<>(List.of( - "punishment.view", "punishment.modify", - "ticket.view.all", "ticket.reply.all", "appeal.modify", "ticket.close.all", - "staff.modactions", - "staff.chatlogs", "staff.commandlogs" - )); - moderatorPerms.addAll(moderatorPunishmentPerms); - - List defaultRoles = List.of( - StaffRole.builder() - .id("super-admin") - .name("Super Admin") - .description("Full access to all features and settings") - .permissions(superAdminPerms) - .isDefault(true) - .order(0) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(), - StaffRole.builder() - .id("admin") - .name("Admin") - .description("Administrative access with some restrictions") - .permissions(adminPerms) - .isDefault(true) - .order(1) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(), - StaffRole.builder() - .id("moderator") - .name("Moderator") - .description("Moderation permissions for punishments and tickets") - .permissions(moderatorPerms) - .isDefault(true) - .order(2) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(), - StaffRole.builder() - .id("helper") - .name("Helper") - .description("Basic support permissions") - .permissions(new ArrayList<>(List.of("ticket.view.all", "ticket.reply.all", "appeal.modify"))) - .isDefault(true) - .order(3) - .createdAt(new Date()) - .updatedAt(new Date()) - .build() - ); - - for (StaffRole role : defaultRoles) { - staffRoleRepository.insertRoleIfAbsent(server, role); - } - permissionService.evictPermissionCache(); - } -} +package gg.modl.backend.role.service; + +import gg.modl.backend.infrastructure.exception.ConflictException; +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffRoleMongoRepository; +import gg.modl.backend.role.data.Permission; +import gg.modl.backend.role.data.StaffRole; +import gg.modl.backend.role.dto.request.ReorderRolesRequest; +import gg.modl.backend.role.dto.request.RoleRequest; +import gg.modl.backend.role.dto.response.RoleResponse; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.service.ServerTimestampService; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RoleService { + private final StaffRoleMongoRepository staffRoleRepository; + private final StaffMongoRepository staffRepository; + private final PermissionService permissionService; + private final RoleAuthorization roleAuthorization; + private final ServerTimestampService serverTimestampService; + + private final Set orderingRepairedServers = ConcurrentHashMap.newKeySet(); + + public List getAllRoles(Server server) { + repairCustomRoleOrderingOnce(server); + + List roles = staffRoleRepository.findAllOrdered(server); + Map roleCounts = staffRepository.countByRoleId(server); + + return roles.stream() + .map(role -> toRoleResponse(role, roleCounts.getOrDefault(role.getId(), 0))) + .toList(); + } + + private void repairCustomRoleOrderingOnce(Server server) { + String serverId = server.getId(); + if (serverId != null && orderingRepairedServers.contains(serverId)) { + return; + } + fixCustomRoleOrdering(server); + if (serverId != null) { + orderingRepairedServers.add(serverId); + } + } + + private void fixCustomRoleOrdering(Server server) { + List problematicRoles = staffRoleRepository.findCustomRolesWithOrderZero(server); + if (problematicRoles.isEmpty()) { + return; + } + + StaffRole highestRole = staffRoleRepository.findHighestOrdered(server).orElse(null); + int baseOrder = highestRole != null ? Math.max(highestRole.getOrder(), 3) + 1 : 4; + + List sorted = problematicRoles.stream() + .sorted(Comparator.comparing(StaffRole::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(StaffRole::getId)) + .toList(); + + Map orderById = new LinkedHashMap<>(); + int nextOrder = baseOrder; + for (StaffRole role : sorted) { + orderById.put(role.getId(), nextOrder++); + } + staffRoleRepository.bulkRepairOrderFromZero(server, orderById); + } + + private RoleResponse toRoleResponse(StaffRole role, int userCount) { + return new RoleResponse( + role.getId(), + role.getName(), + role.getDescription(), + role.getPermissions(), + role.isDefault(), + role.getOrder(), + userCount, + role.getCreatedAt(), + role.getUpdatedAt() + ); + } + + public Optional getRoleById(Server server, String id) { + StaffRole role = staffRoleRepository.findById(server, id).orElse(null); + + if (role == null) { + return Optional.empty(); + } + + int staffCount = getStaffCountForRole(server, role.getId()); + return Optional.of(toRoleResponse(role, staffCount)); + } + + private int getStaffCountForRole(Server server, String roleId) { + return staffRepository.countByRoleId(server, roleId); + } + + public boolean updateRolePermissions(Server server, String id, List permissions, + RoleAuthorization.PerformerAuthority performer) { + if (RoleAuthorization.isSuperAdminRoleId(id)) { + throw new ForbiddenException("Cannot modify Super Admin role"); + } + + roleAuthorization.requireStaffManage(server, performer, RoleAuthorization.MANAGE_ROLES_PERMISSION); + + StaffRole targetRole = staffRoleRepository.findById(server, id).orElse(null); + if (targetRole == null) { + return false; + } + + Set validPermissions = new HashSet<>(permissionService.getAllPermissionIds(server)); + List requested = permissions != null ? permissions : List.of(); + List filtered = requested.stream() + .filter(validPermissions::contains) + .distinct() + .collect(Collectors.toCollection(ArrayList::new)); + + if (!performer.superAdmin()) { + StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); + roleAuthorization.assertHigherAuthority(performerRole, targetRole); + filtered = filterToGrantableOrExisting(performerRole, targetRole, filtered); + } + + targetRole.setPermissions(filtered); + targetRole.setUpdatedAt(new Date()); + staffRoleRepository.saveEntity(server, targetRole); + invalidatePermissionState(server); + return true; + } + + private void invalidatePermissionState(Server server) { + permissionService.evictPermissionCache(); + serverTimestampService.updateStaffPermissionsTimestamp(server); + } + + public RoleResponse createRole(Server server, RoleRequest request, RoleAuthorization.PerformerAuthority performer) { + String roleName = request.name() != null ? request.name().trim() : ""; + ensureRoleNameAvailable(server, roleName, null); + + Set validPermissions = new HashSet<>(permissionService.getAllPermissionIds(server)); + List filteredPermissions = request.permissions() + .stream() + .filter(validPermissions::contains) + .toList(); + + if (!performer.superAdmin()) { + StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); + filteredPermissions = filterToGrantablePermissions(performerRole, filteredPermissions); + } + + String id = generateCustomRoleId(); + int nextOrder = nextRoleOrder(server); + + StaffRole newRole = StaffRole.builder() + .id(id) + .name(roleName) + .description(request.description()) + .permissions(new ArrayList<>(filteredPermissions)) + .isDefault(false) + .order(nextOrder) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(); + + staffRoleRepository.saveEntity(server, newRole); + + return toRoleResponse(newRole, 0); + } + + private static String generateCustomRoleId() { + return "custom-" + System.currentTimeMillis() + "-" + UUID.randomUUID().toString().substring(0, 8); + } + + private int nextRoleOrder(Server server) { + StaffRole highestRole = staffRoleRepository.findHighestOrdered(server).orElse(null); + return highestRole != null ? highestRole.getOrder() + 1 : 4; + } + + private List filterToGrantablePermissions(StaffRole performerRole, List permissions) { + return permissions.stream() + .filter(p -> RoleAuthorization.roleGrants(performerRole, p)) + .toList(); + } + + private List filterToGrantableOrExisting(StaffRole performerRole, StaffRole targetRole, List permissions) { + Set existing = new HashSet<>(targetRole.getPermissions() != null ? targetRole.getPermissions() : List.of()); + return permissions.stream() + .filter(p -> RoleAuthorization.roleGrants(performerRole, p) || existing.contains(p)) + .collect(Collectors.toCollection(ArrayList::new)); + } + + private void ensureRoleNameAvailable(Server server, String roleName, String excludeRoleId) { + if (roleName == null || roleName.isBlank()) { + throw new ValidationException("Role name cannot be empty"); + } + + boolean exists = excludeRoleId != null && !excludeRoleId.isBlank() + ? staffRoleRepository.existsByNameIgnoreCaseExcludingId(server, roleName, excludeRoleId) + : staffRoleRepository.existsByNameIgnoreCase(server, roleName); + if (exists) { + throw new ConflictException("A role with this name already exists"); + } + } + + public Optional updateRole(Server server, String id, RoleRequest request, RoleAuthorization.PerformerAuthority performer) { + if (RoleAuthorization.isSuperAdminRoleId(id)) { + throw new ForbiddenException("Cannot modify Super Admin role"); + } + + StaffRole updated = staffRoleRepository.findById(server, id).orElse(null); + if (updated == null) { + return Optional.empty(); + } + + StaffRole performerRole = performer.superAdmin() ? null : roleAuthorization.requirePerformerRole(server, performer); + if (performerRole != null) { + roleAuthorization.assertHigherAuthority(performerRole, updated); + } + + String roleName = request.name() != null ? request.name().trim() : ""; + ensureRoleNameAvailable(server, roleName, id); + + Set validPermissions = new HashSet<>(permissionService.getAllPermissionIds(server)); + List filteredPermissions = request.permissions() + .stream() + .filter(validPermissions::contains) + .toList(); + if (performerRole != null) { + filteredPermissions = filterToGrantableOrExisting(performerRole, updated, filteredPermissions); + } + + updated.setName(roleName); + updated.setDescription(request.description()); + updated.setPermissions(new ArrayList<>(filteredPermissions)); + updated.setUpdatedAt(new Date()); + updated = staffRoleRepository.saveEntity(server, updated); + invalidatePermissionState(server); + + int staffCount = getStaffCountForRole(server, updated.getId()); + return Optional.of(toRoleResponse(updated, staffCount)); + } + + public boolean deleteRole(Server server, String id, RoleAuthorization.PerformerAuthority performer) { + if (RoleAuthorization.isSuperAdminRoleId(id)) { + throw new ForbiddenException("Cannot delete Super Admin role"); + } + + StaffRole role = staffRoleRepository.findById(server, id).orElse(null); + if (role == null) { + return false; + } + + if (!performer.superAdmin()) { + StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); + roleAuthorization.assertHigherAuthority(performerRole, role); + } + + int staffCount = getStaffCountForRole(server, role.getId()); + if (staffCount > 0) { + throw new ConflictException("Cannot delete role that is currently assigned to staff members"); + } + + boolean deleted = staffRoleRepository.deleteById(server, id); + if (deleted) { + invalidatePermissionState(server); + } + return deleted; + } + + public void reorderRoles(Server server, ReorderRolesRequest request, RoleAuthorization.PerformerAuthority performer) { + List items = request.roleOrder(); + if (items.isEmpty()) return; + + if (!performer.superAdmin()) { + StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); + int performerOrder = performerRole.getOrder(); + + List ids = items.stream().map(ReorderRolesRequest.RoleOrderItem::id).toList(); + Map rolesById = staffRoleRepository.findByIds(server, ids) + .stream() + .collect(Collectors.toMap(StaffRole::getId, Function.identity())); + + for (ReorderRolesRequest.RoleOrderItem item : items) { + StaffRole targetRole = rolesById.get(item.id()); + if (targetRole == null) continue; + + if (targetRole.getOrder() <= performerOrder) { + throw new ForbiddenException("You do not have authority to reorder this role"); + } + + if (item.order() <= performerOrder) { + throw new ForbiddenException("You cannot promote a role to or above your own authority level"); + } + } + } + + Map orderById = new LinkedHashMap<>(); + for (ReorderRolesRequest.RoleOrderItem item : items) { + orderById.put(item.id(), item.order()); + } + staffRoleRepository.bulkUpdateOrder(server, orderById); + serverTimestampService.updateStaffPermissionsTimestamp(server); + } + + public void createDefaultRoles(Server server) { + List allPunishmentPerms = permissionService.getPunishmentPermissions(server) + .stream() + .map(Permission::id) + .toList(); + List moderatorPunishmentPerms = allPunishmentPerms.stream() + .filter(p -> !p.contains("blacklist")) + .toList(); + + List superAdminPerms = new ArrayList<>(permissionService.getAllPermissionIds(server)); + + List adminPerms = new ArrayList<>(List.of( + PermissionService.ADMIN_SETTINGS_VIEW, PermissionService.ADMIN_STAFF_MANAGE, PermissionService.ADMIN_AUDIT_VIEW, + PermissionService.ADMIN_AUDIT_ROLLBACK, + PermissionService.PUNISHMENT_VIEW, PermissionService.PUNISHMENT_MODIFY, + PermissionService.TICKET_VIEW_ALL, PermissionService.TICKET_REPLY_ALL, PermissionService.APPEAL_MODIFY, PermissionService.TICKET_CLOSE_ALL, + PermissionService.STAFF_CHAT_TOGGLE, PermissionService.STAFF_CHAT_CLEAR, PermissionService.STAFF_CHAT_SLOW, + PermissionService.STAFF_MAINTENANCE, PermissionService.STAFF_MODACTIONS, + PermissionService.STAFF_INTERCEPT, PermissionService.STAFF_CHATLOGS, PermissionService.STAFF_COMMANDLOGS + )); + adminPerms.addAll(allPunishmentPerms); + + List moderatorPerms = new ArrayList<>(List.of( + PermissionService.PUNISHMENT_VIEW, PermissionService.PUNISHMENT_MODIFY, + PermissionService.TICKET_VIEW_ALL, PermissionService.TICKET_REPLY_ALL, PermissionService.APPEAL_MODIFY, PermissionService.TICKET_CLOSE_ALL, + PermissionService.STAFF_MODACTIONS, + PermissionService.STAFF_CHATLOGS, PermissionService.STAFF_COMMANDLOGS + )); + moderatorPerms.addAll(moderatorPunishmentPerms); + + List defaultRoles = List.of( + StaffRole.builder() + .id("super-admin") + .name("Super Admin") + .description("Full access to all features and settings") + .permissions(superAdminPerms) + .isDefault(true) + .order(0) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(), + StaffRole.builder() + .id("admin") + .name("Admin") + .description("Administrative access with some restrictions") + .permissions(adminPerms) + .isDefault(true) + .order(1) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(), + StaffRole.builder() + .id("moderator") + .name("Moderator") + .description("Moderation permissions for punishments and tickets") + .permissions(moderatorPerms) + .isDefault(true) + .order(2) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(), + StaffRole.builder() + .id("helper") + .name("Helper") + .description("Basic support permissions") + .permissions(new ArrayList<>(List.of(PermissionService.TICKET_VIEW_ALL, PermissionService.TICKET_REPLY_ALL, PermissionService.APPEAL_MODIFY))) + .isDefault(true) + .order(3) + .createdAt(new Date()) + .updatedAt(new Date()) + .build() + ); + + for (StaffRole role : defaultRoles) { + staffRoleRepository.insertRoleIfAbsent(server, role); + } + permissionService.evictPermissionCache(); + } +} diff --git a/src/main/java/gg/modl/backend/server/ServerField.java b/src/main/java/gg/modl/backend/server/ServerField.java index bfe0eae..73e011b 100644 --- a/src/main/java/gg/modl/backend/server/ServerField.java +++ b/src/main/java/gg/modl/backend/server/ServerField.java @@ -1,5 +1,5 @@ -package gg.modl.backend.server; - +package gg.modl.backend.server; + public final class ServerField { public static final String ADMIN_EMAIL = "adminEmail"; public static final String SUBDOMAIN = "customDomain"; @@ -7,5 +7,4 @@ public final class ServerField { public static final String CUSTOM_DOMAIN = "customDomainOverride"; public static final String CUSTOM_DOMAIN_STATUS = "customDomainStatus"; public static final String CUSTOM_DOMAIN_GRANDFATHERED = "customDomainGrandfathered"; - public static final String API_KEY = "apiKey"; } diff --git a/src/main/java/gg/modl/backend/server/ServerResponseMessage.java b/src/main/java/gg/modl/backend/server/ServerResponseMessage.java deleted file mode 100644 index 7663bb0..0000000 --- a/src/main/java/gg/modl/backend/server/ServerResponseMessage.java +++ /dev/null @@ -1,10 +0,0 @@ -package gg.modl.backend.server; - -public final class ServerResponseMessage { - public static final String REGISTER_SUCCESS = "Registration successful. Please check your email to verify your account."; - public static final String REGISTER_INVALID_SCHEMA = "Invalid registration data provided."; - public static final String REGISTER_EMAIL_EXISTS = "The provided email is already in use!"; - public static final String REGISTER_DOMAIN_EXISTS = "The provided subdomain is already in use!"; - public static final String REGISTER_NAME_EXISTS = "The provided server name is already in use!"; - public static final String REGISTER_RESERVED_SUBDOMAIN = "The provided subdomain is reserved and cannot be used!"; -} diff --git a/src/main/java/gg/modl/backend/server/ServerService.java b/src/main/java/gg/modl/backend/server/ServerService.java index 1d94778..7120501 100644 --- a/src/main/java/gg/modl/backend/server/ServerService.java +++ b/src/main/java/gg/modl/backend/server/ServerService.java @@ -1,311 +1,302 @@ -package gg.modl.backend.server; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import gg.modl.backend.email.EmailAddressUtil; -import gg.modl.backend.infrastructure.config.ModlCorsProperties; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.server.data.ProvisioningStatus; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.data.SubscriptionStatus; -import gg.modl.backend.server.service.ServerProvisioningService; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Duration; -import java.util.Arrays; -import java.util.Date; -import java.util.HexFormat; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -@Service -public class ServerService { - private final ServerMongoRepository serverRepository; - private final ServerProvisioningService provisioningService; - private final Set appDomains; - public static final String SERVER_DATABASE_PREFIX = "server_"; - - private final Cache> serverCache = Caffeine.newBuilder() - .maximumSize(500) - .expireAfterWrite(Duration.ofMinutes(5)) - .build(); - - private final Cache apiKeyCache = Caffeine.newBuilder() - .maximumSize(10_000) - .expireAfterWrite(Duration.ofSeconds(60)) - .build(); - - public ServerService( - ServerMongoRepository serverRepository, - ServerProvisioningService provisioningService, - ModlCorsProperties corsProperties - ) { - this.serverRepository = serverRepository; - this.provisioningService = provisioningService; - this.appDomains = Arrays.stream(corsProperties.getAppDomains().split(",")) - .map(String::trim) - .filter(s -> !s.isBlank()) - .collect(Collectors.toSet()); - } - - @Async - public void createServer(@NotNull Server server) { - serverRepository.saveEntity(server); - evictAllServerCaches(); - } - - public void createServer(@NotNull String serverName, @NotNull String customDomain, @NotNull String adminEmail) { - createServer(serverName, customDomain, adminEmail, null, ServerPlan.FREE); - } - - public Server createServer(@NotNull String serverName, @NotNull String customDomain, @NotNull String adminEmail, - @Nullable String emailVerificationToken, @NotNull ServerPlan plan) { - Date now = new Date(); - String databaseName = generateDatabaseName(customDomain); - - String normalizedEmail = EmailAddressUtil.normalize(adminEmail); - if (normalizedEmail == null) { - normalizedEmail = adminEmail; - } - - Server server = new Server(serverName, customDomain, databaseName, normalizedEmail, false, plan); - server.setProvisioningStatus(ProvisioningStatus.PENDING); - server.setSubscriptionStatus(SubscriptionStatus.INACTIVE); - server.setCreatedAt(now); - server.setUpdatedAt(now); - - if (emailVerificationToken != null) { - server.setEmailVerificationToken(emailVerificationToken); - } - - Server saved = serverRepository.saveEntity(server); - evictAllServerCaches(); - return saved; - } - - public String generateDatabaseName(@NotNull String subdomain) { - return SERVER_DATABASE_PREFIX + subdomain; - } - - @Nullable - public Server getServerFromDomain(@NotNull String domain) { - return serverCache.get(domain, key -> { - String subdomain = extractSubdomain(key); - - if (subdomain != null) { - return serverRepository.findByCustomDomain(subdomain); - } - - return serverRepository.findByActiveCustomDomainOverride(key); - }).orElse(null); - } - - public void evictServerCache(@NotNull String domain) { - serverCache.invalidate(domain); - } - - public void evictAllServerCaches() { - serverCache.invalidateAll(); - apiKeyCache.invalidateAll(); - } - - public void evictApiKey(@NotNull String apiKey) { - if (!apiKey.isBlank()) { - apiKeyCache.invalidate(hashApiKey(apiKey)); - } - } - - public boolean isAdminEmailInUse(String adminEmail, String excludingServerId) { - return serverRepository.existsByAdminEmailExcludingId(EmailAddressUtil.normalize(adminEmail), excludingServerId); - } - - public void changeAdminEmail(Server server, String newAdminEmail) { - serverRepository.updateAdminEmail(server.getId(), EmailAddressUtil.normalize(newAdminEmail)); - evictAllServerCaches(); - } - - @Nullable - private String extractSubdomain(@NotNull String domain) { - AppDomainMatch match = matchAppDomain(domain); - return match != null ? match.subdomain() : null; - } - - @Nullable - public String getAppDomain(@NotNull String domain) { - AppDomainMatch match = matchAppDomain(domain); - return match != null ? match.appDomain() : null; - } - - @Nullable - private AppDomainMatch matchAppDomain(@NotNull String domain) { - for (String appDomain : appDomains) { - String suffix = "." + appDomain; - if (domain.endsWith(suffix)) { - String subdomain = domain.substring(0, domain.length() - suffix.length()); - if (!subdomain.isBlank() && !subdomain.contains(".")) { - return new AppDomainMatch(appDomain, subdomain); - } - } - } - return null; - } - - private record AppDomainMatch(String appDomain, String subdomain) {} - - public ServerExistResult doesServerExist(@NotNull String email, @NotNull String serverName, @NotNull String subdomain) { - String normalizedEmail = EmailAddressUtil.normalize(email); - if (normalizedEmail == null) { - normalizedEmail = email; - } - - Server found = serverRepository.findMatchingIdentity(normalizedEmail, serverName, subdomain).orElse(null); - if (found == null) { - return new ServerExistResult(false, false, false); - } - - boolean emailMatch = false, nameMatch = false, domainMatch = false; - - if (found.getAdminEmail().equalsIgnoreCase(normalizedEmail)) { - emailMatch = true; - } - - if (found.getServerName().equals(serverName)) { - nameMatch = true; - } - - if (found.getCustomDomain().equals(subdomain)) { - domainMatch = true; - } - - return new ServerExistResult(emailMatch, nameMatch, domainMatch); - } - - @Nullable - public Server getServerByDatabaseName(@NotNull String databaseName) { - return serverRepository.findByDatabaseName(databaseName).orElse(null); - } - - @Nullable - public Server getServerByApiKey(@NotNull String apiKey) { - if (apiKey.isBlank()) { - return null; - } - - String cacheKey = hashApiKey(apiKey); - Server cached = apiKeyCache.getIfPresent(cacheKey); - if (cached != null) { - return cached; - } - - Server server = serverRepository.findByApiKey(apiKey).orElse(null); - if (server != null) { - apiKeyCache.put(cacheKey, server); - } - return server; - } - - private String hashApiKey(@NotNull String apiKey) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(digest.digest(apiKey.getBytes(StandardCharsets.UTF_8))); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 algorithm is not available", e); - } - } - - @Nullable - public Server getServerByEmailVerificationToken(@NotNull String token) { - return serverRepository.findByEmailVerificationToken(token).orElse(null); - } - - @Nullable - public Server verifyEmailToken(@NotNull String token) { - Server server = serverRepository.verifyEmailTokenAtomically(token).orElse(null); - - if (server == null) { - return null; - } - - boolean provisioned; - try { - provisioningService.provision(server); - provisioned = true; - } catch (Exception e) { - provisioned = false; - } - - if (provisioned) { - serverRepository.markProvisioningCompleted(server.getId()); - server.setProvisioningStatus(ProvisioningStatus.COMPLETED); - } else { - serverRepository.markProvisioningFailed(server.getId(), "Provisioning failed; awaiting retry."); - server.setProvisioningStatus(ProvisioningStatus.FAILED); - } - - evictAllServerCaches(); - - return server; - } - - @Nullable - public Server getServerByAutoLoginToken(@NotNull String token) { - return serverRepository.findByProvisioningSignInToken(token).orElse(null); - } - - @Nullable - public Server consumeAutoLoginToken(@NotNull String token) { - Server server = serverRepository.consumeProvisioningSignInToken(token, new Date()).orElse(null); - if (server != null) { - evictAllServerCaches(); - } - return server; - } - - public Server setAutoLoginToken(@NotNull Server server, @NotNull String token, @NotNull Date expiresAt) { - server.setProvisioningSignInToken(token); - server.setProvisioningSignInTokenExpiresAt(expiresAt); - server.setUpdatedAt(new Date()); - Server saved = serverRepository.saveEntity(server); - evictAllServerCaches(); - return saved; - } - - public Server clearAutoLoginToken(@NotNull Server server) { - server.setProvisioningSignInToken(null); - server.setProvisioningSignInTokenExpiresAt(null); - server.setUpdatedAt(new Date()); - Server saved = serverRepository.saveEntity(server); - evictAllServerCaches(); - return saved; - } - - @Nullable - public Server getServerByCliSetupToken(@NotNull String token) { - return serverRepository.findByCliSetupToken(token).orElse(null); - } - - public Server setCliSetupToken(@NotNull Server server, @NotNull String token) { - server.setCliSetupToken(token); - server.setUpdatedAt(new Date()); - Server saved = serverRepository.saveEntity(server); - evictAllServerCaches(); - return saved; - } - - public Server clearCliSetupToken(@NotNull Server server) { - server.setCliSetupToken(null); - server.setUpdatedAt(new Date()); - Server saved = serverRepository.saveEntity(server); - evictAllServerCaches(); - return saved; - } - - public record ServerExistResult(boolean emailMatch, boolean nameMatch, boolean domainMatch) {} -} +package gg.modl.backend.server; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import gg.modl.backend.email.EmailAddressUtil; +import gg.modl.backend.infrastructure.config.ModlCorsProperties; +import gg.modl.backend.infrastructure.util.DigestUtils; +import gg.modl.backend.database.mongo.repository.ServerCredentialRepository; +import gg.modl.backend.database.mongo.repository.ServerLookupRepository; +import gg.modl.backend.database.mongo.repository.ServerProvisioningRepository; +import gg.modl.backend.server.data.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import gg.modl.backend.server.service.ServerProvisioningService; +import java.time.Duration; +import java.util.Arrays; +import java.util.Date; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +public class ServerService { + private final ServerLookupRepository serverLookupRepository; + private final ServerProvisioningRepository serverProvisioningRepository; + private final ServerCredentialRepository serverCredentialRepository; + private final ServerProvisioningService provisioningService; + private final Set appDomains; + public static final String SERVER_DATABASE_PREFIX = "server_"; + + private final Cache> serverCache = Caffeine.newBuilder() + .maximumSize(500) + .expireAfterWrite(Duration.ofMinutes(5)) + .build(); + + private final Cache apiKeyCache = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofSeconds(60)) + .build(); + + public ServerService( + ServerLookupRepository serverLookupRepository, + ServerProvisioningRepository serverProvisioningRepository, + ServerCredentialRepository serverCredentialRepository, + ServerProvisioningService provisioningService, + ModlCorsProperties corsProperties + ) { + this.serverLookupRepository = serverLookupRepository; + this.serverProvisioningRepository = serverProvisioningRepository; + this.serverCredentialRepository = serverCredentialRepository; + this.provisioningService = provisioningService; + this.appDomains = Arrays.stream(corsProperties.getAppDomains().split(",")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .collect(Collectors.toSet()); + } + + @Async + public void createServer(@NotNull Server server) { + serverLookupRepository.saveEntity(server); + evictAllServerCaches(); + } + + public void createServer(@NotNull String serverName, @NotNull String customDomain, @NotNull String adminEmail) { + createServer(serverName, customDomain, adminEmail, null, ServerPlan.FREE); + } + + public Server createServer(@NotNull String serverName, @NotNull String customDomain, @NotNull String adminEmail, + @Nullable String emailVerificationToken, @NotNull ServerPlan plan) { + Date now = new Date(); + String databaseName = generateDatabaseName(customDomain); + + String normalizedEmail = EmailAddressUtil.normalize(adminEmail); + if (normalizedEmail == null) { + normalizedEmail = adminEmail; + } + + Server server = new Server(serverName, customDomain, databaseName, normalizedEmail, false, plan); + server.setProvisioningStatus(ProvisioningStatus.PENDING); + server.setSubscriptionStatus(SubscriptionStatus.INACTIVE); + server.setCreatedAt(now); + server.setUpdatedAt(now); + + if (emailVerificationToken != null) { + server.setEmailVerificationToken(emailVerificationToken); + } + + Server saved = serverLookupRepository.saveEntity(server); + evictAllServerCaches(); + return saved; + } + + public String generateDatabaseName(@NotNull String subdomain) { + return SERVER_DATABASE_PREFIX + subdomain; + } + + @Nullable + public Server getServerFromDomain(@NotNull String domain) { + return serverCache.get(domain, key -> { + String subdomain = extractSubdomain(key); + + if (subdomain != null) { + return serverLookupRepository.findByCustomDomain(subdomain); + } + + return serverLookupRepository.findByActiveCustomDomainOverride(key); + }).orElse(null); + } + + public void evictServerCache(@NotNull String domain) { + serverCache.invalidate(domain); + } + + public void evictAllServerCaches() { + serverCache.invalidateAll(); + apiKeyCache.invalidateAll(); + } + + public void evictApiKey(@NotNull String apiKey) { + if (!apiKey.isBlank()) { + apiKeyCache.invalidate(hashApiKey(apiKey)); + } + } + + public boolean isAdminEmailInUse(String adminEmail, String excludingServerId) { + return serverLookupRepository.existsByAdminEmailExcludingId(EmailAddressUtil.normalize(adminEmail), excludingServerId); + } + + public void changeAdminEmail(Server server, String newAdminEmail) { + serverCredentialRepository.updateAdminEmail(server.getId(), EmailAddressUtil.normalize(newAdminEmail)); + evictAllServerCaches(); + } + + @Nullable + private String extractSubdomain(@NotNull String domain) { + AppDomainMatch match = matchAppDomain(domain); + return match != null ? match.subdomain() : null; + } + + @Nullable + public String getAppDomain(@NotNull String domain) { + AppDomainMatch match = matchAppDomain(domain); + return match != null ? match.appDomain() : null; + } + + @Nullable + private AppDomainMatch matchAppDomain(@NotNull String domain) { + for (String appDomain : appDomains) { + String suffix = "." + appDomain; + if (domain.endsWith(suffix)) { + String subdomain = domain.substring(0, domain.length() - suffix.length()); + if (!subdomain.isBlank() && !subdomain.contains(".")) { + return new AppDomainMatch(appDomain, subdomain); + } + } + } + return null; + } + + private record AppDomainMatch(String appDomain, String subdomain) {} + + public ServerExistResult doesServerExist(@NotNull String email, @NotNull String serverName, @NotNull String subdomain) { + String normalizedEmail = EmailAddressUtil.normalize(email); + if (normalizedEmail == null) { + normalizedEmail = email; + } + + Server found = serverLookupRepository.findMatchingIdentity(normalizedEmail, serverName, subdomain).orElse(null); + if (found == null) { + return new ServerExistResult(false, false, false); + } + + boolean emailMatch = false, nameMatch = false, domainMatch = false; + + if (found.getAdminEmail().equalsIgnoreCase(normalizedEmail)) { + emailMatch = true; + } + + if (found.getServerName().equals(serverName)) { + nameMatch = true; + } + + if (found.getCustomDomain().equals(subdomain)) { + domainMatch = true; + } + + return new ServerExistResult(emailMatch, nameMatch, domainMatch); + } + + @Nullable + public Server getServerByDatabaseName(@NotNull String databaseName) { + return serverLookupRepository.findByDatabaseName(databaseName).orElse(null); + } + + @Nullable + public Server getServerByApiKey(@NotNull String apiKey) { + if (apiKey.isBlank()) { + return null; + } + + String cacheKey = hashApiKey(apiKey); + return apiKeyCache.get(cacheKey, key -> serverLookupRepository.findByApiKey(apiKey).orElse(null)); + } + + private String hashApiKey(@NotNull String apiKey) { + return DigestUtils.sha256Hex(apiKey); + } + + @Nullable + public Server getServerByEmailVerificationToken(@NotNull String token) { + return serverLookupRepository.findByEmailVerificationToken(token).orElse(null); + } + + @Nullable + public Server verifyEmailToken(@NotNull String token) { + Server server = serverProvisioningRepository.verifyEmailTokenAtomically(token).orElse(null); + + if (server == null) { + return null; + } + + boolean provisioned; + try { + provisioningService.provision(server); + provisioned = true; + } catch (Exception e) { + provisioned = false; + } + + if (provisioned) { + serverProvisioningRepository.markProvisioningCompleted(server.getId()); + server.setProvisioningStatus(ProvisioningStatus.COMPLETED); + } else { + serverProvisioningRepository.markProvisioningFailed(server.getId(), Server.boundProvisioningNotes("Provisioning failed; awaiting retry.")); + server.setProvisioningStatus(ProvisioningStatus.FAILED); + } + + evictAllServerCaches(); + + return server; + } + + @Nullable + public Server getServerByAutoLoginToken(@NotNull String token) { + return serverLookupRepository.findByProvisioningSignInToken(token).orElse(null); + } + + @Nullable + public Server consumeAutoLoginToken(@NotNull String token) { + Server server = serverProvisioningRepository.consumeProvisioningSignInToken(token, new Date()).orElse(null); + if (server != null) { + evictAllServerCaches(); + } + return server; + } + + public Server setAutoLoginToken(@NotNull Server server, @NotNull String token, @NotNull Date expiresAt) { + server.setProvisioningSignInToken(token); + server.setProvisioningSignInTokenExpiresAt(expiresAt); + server.setUpdatedAt(new Date()); + Server saved = serverLookupRepository.saveEntity(server); + evictAllServerCaches(); + return saved; + } + + public Server clearAutoLoginToken(@NotNull Server server) { + server.setProvisioningSignInToken(null); + server.setProvisioningSignInTokenExpiresAt(null); + server.setUpdatedAt(new Date()); + Server saved = serverLookupRepository.saveEntity(server); + evictAllServerCaches(); + return saved; + } + + @Nullable + public Server getServerByCliSetupToken(@NotNull String token) { + return serverLookupRepository.findByCliSetupToken(token).orElse(null); + } + + public Server setCliSetupToken(@NotNull Server server, @NotNull String token) { + server.setCliSetupToken(token); + server.setUpdatedAt(new Date()); + Server saved = serverLookupRepository.saveEntity(server); + evictAllServerCaches(); + return saved; + } + + public Server clearCliSetupToken(@NotNull Server server) { + server.setCliSetupToken(null); + server.setUpdatedAt(new Date()); + Server saved = serverLookupRepository.saveEntity(server); + evictAllServerCaches(); + return saved; + } + + public record ServerExistResult(boolean emailMatch, boolean nameMatch, boolean domainMatch) {} +} diff --git a/src/main/java/gg/modl/backend/server/controller/PanelServerController.java b/src/main/java/gg/modl/backend/server/controller/PanelServerController.java index 140f8b1..d63f9d1 100644 --- a/src/main/java/gg/modl/backend/server/controller/PanelServerController.java +++ b/src/main/java/gg/modl/backend/server/controller/PanelServerController.java @@ -1,31 +1,24 @@ -package gg.modl.backend.server.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.ProvisioningStatusResponse; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.core.env.Environment; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_SERVER) -@RequiredArgsConstructor -public class PanelServerController { - private final Environment environment; - - @GetMapping("/test") - public String getTest() { - return environment.getProperty("spring.application.name"); - } - - @GetMapping("/provisioning-status") - public ResponseEntity getProvisioningStatus(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelServerProtoMapper.toProvisioningStatusResponse(server)); - } -} +package gg.modl.backend.server.controller; + +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.ProvisioningStatusResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_SERVER) +@RequiresPanelPermission(view = "admin.settings.view", modify = "admin.settings.modify") +public class PanelServerController { + + @GetMapping("/provisioning-status") + public ResponseEntity getProvisioningStatus(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelServerProtoMapper.toProvisioningStatusResponse(server)); + } +} diff --git a/src/main/java/gg/modl/backend/server/controller/PublicServerController.java b/src/main/java/gg/modl/backend/server/controller/PublicServerController.java index 7a409ff..851841f 100644 --- a/src/main/java/gg/modl/backend/server/controller/PublicServerController.java +++ b/src/main/java/gg/modl/backend/server/controller/PublicServerController.java @@ -1,36 +1,36 @@ -package gg.modl.backend.server.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.proto.modl.v1.ServerAvailabilityRequest; -import gg.modl.proto.modl.v1.ServerAvailabilityResponse; -import gg.modl.proto.modl.v1.ServerRegisterRequest; -import gg.modl.proto.modl.v1.ServerRegisterResponse; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PUBLIC_SERVER) -public class PublicServerController { - - @PostMapping("/register") - public ResponseEntity register(@RequestBody ServerRegisterRequest request) { - return ResponseEntity.status(HttpStatus.GONE).body(ServerRegisterResponse.newBuilder() - .setSuccess(false) - .setMessage("Use /v1/public/registration instead.") - .build()); - } - - @PostMapping("/check-availability") - public ResponseEntity checkAvailability(@RequestBody ServerAvailabilityRequest request) { - return ResponseEntity.status(HttpStatus.GONE).body(ServerAvailabilityResponse.newBuilder() - .setEmailAvailable(false) - .setNameAvailable(false) - .setSubdomainAvailable(false) - .setMessage("Use /v1/public/registration/check-availability instead.") - .build()); - } -} +package gg.modl.backend.server.controller; + +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.proto.modl.v1.ServerAvailabilityRequest; +import gg.modl.proto.modl.v1.ServerAvailabilityResponse; +import gg.modl.proto.modl.v1.ServerRegisterRequest; +import gg.modl.proto.modl.v1.ServerRegisterResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PUBLIC_SERVER) +public class PublicServerController { + + @PostMapping("/register") + public ResponseEntity register(@RequestBody ServerRegisterRequest request) { + return ResponseEntity.status(HttpStatus.GONE).body(ServerRegisterResponse.newBuilder() + .setSuccess(false) + .setMessage("Use /v1/public/registration instead.") + .build()); + } + + @PostMapping("/check-availability") + public ResponseEntity checkAvailability(@RequestBody ServerAvailabilityRequest request) { + return ResponseEntity.status(HttpStatus.GONE).body(ServerAvailabilityResponse.newBuilder() + .setEmailAvailable(false) + .setNameAvailable(false) + .setSubdomainAvailable(false) + .setMessage("Use /v1/public/registration/check-availability instead.") + .build()); + } +} diff --git a/src/main/java/gg/modl/backend/server/data/Server.java b/src/main/java/gg/modl/backend/server/data/Server.java index ecf4900..aa15aff 100644 --- a/src/main/java/gg/modl/backend/server/data/Server.java +++ b/src/main/java/gg/modl/backend/server/data/Server.java @@ -1,205 +1,224 @@ -package gg.modl.backend.server.data; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import gg.modl.backend.server.ServerField; -import java.util.Date; -import lombok.Data; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.data.annotation.Id; - -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; -import org.springframework.data.mongodb.core.mapping.FieldType; - -@Document(collection = CollectionName.MODL_SERVERS) -@Data -@RequiredArgsConstructor -@GenerateMongoFields -public class Server implements ServerBillingUpdate { - @NotNull - @Field(name = ServerField.SERVER_NAME, targetType = FieldType.STRING) - private final String serverName; - - @NotNull - @Field(name = ServerField.SUBDOMAIN, targetType = FieldType.STRING) - private final String customDomain; - - @Nullable - @Field(name = "databaseName", targetType = FieldType.STRING) - private final String databaseName; - - @Id - @Field(targetType = FieldType.OBJECT_ID) - private String id; - - @NotNull - @Field(name = ServerField.ADMIN_EMAIL, targetType = FieldType.STRING) - private String adminEmail; - - @NotNull - @Field(name = "emailVerified", targetType = FieldType.BOOLEAN) - private Boolean emailVerified; - - @Nullable - @Field(name = "emailVerificationToken", targetType = FieldType.STRING) - private String emailVerificationToken; - - @Nullable - @Field(name = "cleanupClaimId", targetType = FieldType.STRING) - private String cleanupClaimId; - - @Nullable - @Field(name = "cleanupClaimedAt", targetType = FieldType.DATE_TIME) - private Date cleanupClaimedAt; - - @Nullable - @Field(name = "provisioningStatus", targetType = FieldType.STRING) - private ProvisioningStatus provisioningStatus; - - @Nullable - @Field(name = "provisioningNotes", targetType = FieldType.STRING) - private String provisioningNotes; - - @Nullable - @Field(name = "provisioningSignInToken", targetType = FieldType.STRING) - private String provisioningSignInToken; - - @Nullable - @Field(name = "provisioningSignInTokenExpiresAt", targetType = FieldType.DATE_TIME) - private Date provisioningSignInTokenExpiresAt; - - @NotNull - @Field(name = "plan", targetType = FieldType.STRING) - private ServerPlan plan; - - @Nullable - @Field(name = "subscriptionStatus", targetType = FieldType.STRING) - private SubscriptionStatus subscriptionStatus; - - @Nullable - @Field(name = "currentPeriodStart", targetType = FieldType.DATE_TIME) - private Date currentPeriodStart; - - @Nullable - @Field(name = "currentPeriodEnd", targetType = FieldType.DATE_TIME) - private Date currentPeriodEnd; - - @Nullable - @Field(name = "stripeCustomerId", targetType = FieldType.STRING) - private String stripeCustomerId; - - @Nullable - @Field(name = "stripeSubscriptionId", targetType = FieldType.STRING) - private String stripeSubscriptionId; - - @Nullable - @Field(name = "aiRequestsCurrentPeriod", targetType = FieldType.INT64) - private Long aiRequestsCurrentPeriod; // AI requests used in current billing period - - @Nullable - @Field(name = "usageBillingEnabled", targetType = FieldType.BOOLEAN) - private Boolean usageBillingEnabled; // Whether to charge for overages - - @Nullable - @Field(name = "usageBillingUpdatedAt", targetType = FieldType.DATE_TIME) - private Date usageBillingUpdatedAt; - - @Nullable - @Field(name = "storageUsedBytes", targetType = FieldType.INT64) - private Long storageUsedBytes; - - @Nullable - @Field(name = "maxStorageLimitBytes", targetType = FieldType.INT64) - private Long maxStorageLimitBytes; - - @Nullable - @Field(name = "maxAiOverageRequests", targetType = FieldType.INT64) - private Long maxAiOverageRequests; - - @Nullable - @Field(name = "migrationFileSizeLimit", targetType = FieldType.INT64) - private Long migrationFileSizeLimit; // Custom migration file size limit in bytes - - @Nullable - @Field(name = ServerField.CUSTOM_DOMAIN, targetType = FieldType.STRING) - private String customDomainOverride; - - @Nullable - @Field(name = ServerField.CUSTOM_DOMAIN_STATUS, targetType = FieldType.STRING) - private CustomDomainStatus customDomainStatus; - - @Nullable - @Field(name = "customDomainLastChecked", targetType = FieldType.DATE_TIME) - private Date customDomainLastChecked; - - @Nullable - @Field(name = "customDomainError", targetType = FieldType.STRING) - private String customDomainError; - - @Nullable - @Field(name = "customDomainCloudflareId", targetType = FieldType.STRING) - private String customDomainCloudflareId; - - @Nullable - @Field(name = ServerField.CUSTOM_DOMAIN_GRANDFATHERED, targetType = FieldType.BOOLEAN) - private Boolean customDomainGrandfathered; - - @Nullable - @Field(name = "betaTester", targetType = FieldType.BOOLEAN) - private Boolean betaTester; - - @Nullable - @Field(name = "betaTesterCreatedAt", targetType = FieldType.DATE_TIME) - private Date betaTesterCreatedAt; - - @Nullable - @Field(name = "betaTesterCreatedBy", targetType = FieldType.STRING) - private String betaTesterCreatedBy; - - @Nullable - @Field(name = "cliSetupToken", targetType = FieldType.STRING) - private String cliSetupToken; - - @Nullable - @Field(name = "apiKey", targetType = FieldType.STRING) - private String apiKey; - - @Nullable - @Field(name = "onlinePlayerCount", targetType = FieldType.INT64) - private Long onlinePlayerCount; - - @Nullable - @Field(name = "userCount", targetType = FieldType.INT64) - private Long userCount; - - @Nullable - @Field(name = "ticketCount", targetType = FieldType.INT64) - private Long ticketCount; - - @Nullable - @Field(name = "lastStatsUpdatedAt", targetType = FieldType.DATE_TIME) - private Date lastStatsUpdatedAt; - - @Nullable - @Field(name = "lastActivityAt", targetType = FieldType.DATE_TIME) - private Date lastActivityAt; - - @Nullable - @Field(name = "createdAt", targetType = FieldType.DATE_TIME) - private Date createdAt; - - @Nullable - @Field(name = "updatedAt", targetType = FieldType.DATE_TIME) - private Date updatedAt; - - @Nullable - @Field(name = "staffPermissionsUpdatedAt", targetType = FieldType.DATE_TIME) - private Date staffPermissionsUpdatedAt; - - @Nullable - @Field(name = "punishmentTypesUpdatedAt", targetType = FieldType.DATE_TIME) - private Date punishmentTypesUpdatedAt; -} +package gg.modl.backend.server.data; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import gg.modl.backend.server.ServerField; +import java.util.Date; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.data.annotation.Id; + +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.mapping.FieldType; + +@Document(collection = CollectionName.MODL_SERVERS) +@Getter +@Setter +@ToString +@RequiredArgsConstructor +@GenerateMongoFields +public class Server implements ServerBillingUpdate { + @NotNull + @Field(name = ServerField.SERVER_NAME, targetType = FieldType.STRING) + private final String serverName; + + @NotNull + @Field(name = ServerField.SUBDOMAIN, targetType = FieldType.STRING) + private final String customDomain; + + @Nullable + @Field(name = "databaseName", targetType = FieldType.STRING) + private final String databaseName; + + @Id + @Field(targetType = FieldType.OBJECT_ID) + private String id; + + @NotNull + @Field(name = ServerField.ADMIN_EMAIL, targetType = FieldType.STRING) + private String adminEmail; + + @NotNull + @Field(name = "emailVerified", targetType = FieldType.BOOLEAN) + private Boolean emailVerified; + + @Nullable + @ToString.Exclude + @Field(name = "emailVerificationToken", targetType = FieldType.STRING) + private String emailVerificationToken; + + @Nullable + @Field(name = "cleanupClaimId", targetType = FieldType.STRING) + private String cleanupClaimId; + + @Nullable + @Field(name = "cleanupClaimedAt", targetType = FieldType.DATE_TIME) + private Date cleanupClaimedAt; + + @Nullable + @Field(name = "provisioningStatus", targetType = FieldType.STRING) + private ProvisioningStatus provisioningStatus; + + @Nullable + @Field(name = "provisioningNotes", targetType = FieldType.STRING) + private String provisioningNotes; + + @Nullable + @ToString.Exclude + @Field(name = "provisioningSignInToken", targetType = FieldType.STRING) + private String provisioningSignInToken; + + @Nullable + @Field(name = "provisioningSignInTokenExpiresAt", targetType = FieldType.DATE_TIME) + private Date provisioningSignInTokenExpiresAt; + + @NotNull + @Field(name = "plan", targetType = FieldType.STRING) + private ServerPlan plan; + + @Nullable + @Field(name = "subscriptionStatus", targetType = FieldType.STRING) + private SubscriptionStatus subscriptionStatus; + + @Nullable + @Field(name = "currentPeriodStart", targetType = FieldType.DATE_TIME) + private Date currentPeriodStart; + + @Nullable + @Field(name = "currentPeriodEnd", targetType = FieldType.DATE_TIME) + private Date currentPeriodEnd; + + @Nullable + @ToString.Exclude + @Field(name = "stripeCustomerId", targetType = FieldType.STRING) + private String stripeCustomerId; + + @Nullable + @ToString.Exclude + @Field(name = "stripeSubscriptionId", targetType = FieldType.STRING) + private String stripeSubscriptionId; + + @Nullable + @Field(name = "aiRequestsCurrentPeriod", targetType = FieldType.INT64) + private Long aiRequestsCurrentPeriod; + + @Nullable + @Field(name = "usageBillingEnabled", targetType = FieldType.BOOLEAN) + private Boolean usageBillingEnabled; + + @Nullable + @Field(name = "usageBillingUpdatedAt", targetType = FieldType.DATE_TIME) + private Date usageBillingUpdatedAt; + + @Nullable + @Field(name = "storageUsedBytes", targetType = FieldType.INT64) + private Long storageUsedBytes; + + @Nullable + @Field(name = "maxStorageLimitBytes", targetType = FieldType.INT64) + private Long maxStorageLimitBytes; + + @Nullable + @Field(name = "maxAiOverageRequests", targetType = FieldType.INT64) + private Long maxAiOverageRequests; + + @Nullable + @Field(name = "migrationFileSizeLimit", targetType = FieldType.INT64) + private Long migrationFileSizeLimit; + + @Nullable + @Field(name = ServerField.CUSTOM_DOMAIN, targetType = FieldType.STRING) + private String customDomainOverride; + + @Nullable + @Field(name = ServerField.CUSTOM_DOMAIN_STATUS, targetType = FieldType.STRING) + private CustomDomainStatus customDomainStatus; + + @Nullable + @Field(name = "customDomainLastChecked", targetType = FieldType.DATE_TIME) + private Date customDomainLastChecked; + + @Nullable + @Field(name = "customDomainError", targetType = FieldType.STRING) + private String customDomainError; + + @Nullable + @Field(name = "customDomainCloudflareId", targetType = FieldType.STRING) + private String customDomainCloudflareId; + + @Nullable + @Field(name = ServerField.CUSTOM_DOMAIN_GRANDFATHERED, targetType = FieldType.BOOLEAN) + private Boolean customDomainGrandfathered; + + @Nullable + @Field(name = "betaTester", targetType = FieldType.BOOLEAN) + private Boolean betaTester; + + @Nullable + @Field(name = "betaTesterCreatedAt", targetType = FieldType.DATE_TIME) + private Date betaTesterCreatedAt; + + @Nullable + @Field(name = "betaTesterCreatedBy", targetType = FieldType.STRING) + private String betaTesterCreatedBy; + + @Nullable + @ToString.Exclude + @Field(name = "cliSetupToken", targetType = FieldType.STRING) + private String cliSetupToken; + + @Nullable + @ToString.Exclude + @Field(name = "apiKey", targetType = FieldType.STRING) + private String apiKey; + + @Nullable + @Field(name = "onlinePlayerCount", targetType = FieldType.INT64) + private Long onlinePlayerCount; + + @Nullable + @Field(name = "userCount", targetType = FieldType.INT64) + private Long userCount; + + @Nullable + @Field(name = "ticketCount", targetType = FieldType.INT64) + private Long ticketCount; + + @Nullable + @Field(name = "lastStatsUpdatedAt", targetType = FieldType.DATE_TIME) + private Date lastStatsUpdatedAt; + + @Nullable + @Field(name = "lastActivityAt", targetType = FieldType.DATE_TIME) + private Date lastActivityAt; + + @Nullable + @Field(name = "createdAt", targetType = FieldType.DATE_TIME) + private Date createdAt; + + @Nullable + @Field(name = "updatedAt", targetType = FieldType.DATE_TIME) + private Date updatedAt; + + @Nullable + @Field(name = "staffPermissionsUpdatedAt", targetType = FieldType.DATE_TIME) + private Date staffPermissionsUpdatedAt; + + @Nullable + @Field(name = "punishmentTypesUpdatedAt", targetType = FieldType.DATE_TIME) + private Date punishmentTypesUpdatedAt; + + private static final int MAX_PROVISIONING_NOTES_LENGTH = 500; + + @Nullable + public static String boundProvisioningNotes(@Nullable String notes) { + return notes != null && notes.length() > MAX_PROVISIONING_NOTES_LENGTH + ? notes.substring(0, MAX_PROVISIONING_NOTES_LENGTH) + : notes; + } +} diff --git a/src/main/java/gg/modl/backend/server/data/ServerBillingUpdate.java b/src/main/java/gg/modl/backend/server/data/ServerBillingUpdate.java index bceb796..099a189 100644 --- a/src/main/java/gg/modl/backend/server/data/ServerBillingUpdate.java +++ b/src/main/java/gg/modl/backend/server/data/ServerBillingUpdate.java @@ -1,29 +1,29 @@ -package gg.modl.backend.server.data; - -import java.util.Date; - -public interface ServerBillingUpdate { - String getStripeSubscriptionId(); - - void setStripeCustomerId(String stripeCustomerId); - - void setStripeSubscriptionId(String stripeSubscriptionId); - - void setSubscriptionStatus(SubscriptionStatus subscriptionStatus); - - void setPlan(ServerPlan plan); - - void setCurrentPeriodStart(Date currentPeriodStart); - - void setCurrentPeriodEnd(Date currentPeriodEnd); - - void setUsageBillingEnabled(Boolean usageBillingEnabled); - - void setUsageBillingUpdatedAt(Date usageBillingUpdatedAt); - - void setMaxStorageLimitBytes(Long maxStorageLimitBytes); - - void setMaxAiOverageRequests(Long maxAiOverageRequests); - - void setMigrationFileSizeLimit(Long migrationFileSizeLimit); -} +package gg.modl.backend.server.data; + +import java.util.Date; + +public interface ServerBillingUpdate { + String getStripeSubscriptionId(); + + void setStripeCustomerId(String stripeCustomerId); + + void setStripeSubscriptionId(String stripeSubscriptionId); + + void setSubscriptionStatus(SubscriptionStatus subscriptionStatus); + + void setPlan(ServerPlan plan); + + void setCurrentPeriodStart(Date currentPeriodStart); + + void setCurrentPeriodEnd(Date currentPeriodEnd); + + void setUsageBillingEnabled(Boolean usageBillingEnabled); + + void setUsageBillingUpdatedAt(Date usageBillingUpdatedAt); + + void setMaxStorageLimitBytes(Long maxStorageLimitBytes); + + void setMaxAiOverageRequests(Long maxAiOverageRequests); + + void setMigrationFileSizeLimit(Long migrationFileSizeLimit); +} diff --git a/src/main/java/gg/modl/backend/server/service/PanelDomainResolver.java b/src/main/java/gg/modl/backend/server/service/PanelDomainResolver.java new file mode 100644 index 0000000..dec4fd7 --- /dev/null +++ b/src/main/java/gg/modl/backend/server/service/PanelDomainResolver.java @@ -0,0 +1,21 @@ +package gg.modl.backend.server.service; + +import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.server.data.Server; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PanelDomainResolver { + + private final ModlProperties modlProperties; + + public String panelDomain(Server server) { + String override = server.getCustomDomainOverride(); + if (override != null && !override.isBlank()) { + return override; + } + return server.getCustomDomain() + "." + modlProperties.getDomain(); + } +} diff --git a/src/main/java/gg/modl/backend/server/service/ServerMutationHelper.java b/src/main/java/gg/modl/backend/server/service/ServerMutationHelper.java index 7f3f3c3..4a7be61 100644 --- a/src/main/java/gg/modl/backend/server/service/ServerMutationHelper.java +++ b/src/main/java/gg/modl/backend/server/service/ServerMutationHelper.java @@ -1,7 +1,7 @@ package gg.modl.backend.server.service; import gg.modl.backend.database.mongo.fields.ServerFields; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerAdminRepository; import gg.modl.backend.server.ServerService; import gg.modl.backend.server.data.Server; import gg.modl.backend.server.data.ServerBillingUpdate; @@ -16,7 +16,7 @@ @RequiredArgsConstructor public class ServerMutationHelper { - private final ServerMongoRepository serverRepository; + private final ServerAdminRepository serverAdminRepository; private final ServerService serverService; public void mutate(Server server, Consumer mutator) { @@ -87,7 +87,7 @@ public void mutate(Server server, Consumer mutator) { } update.set(ServerFields.UPDATED_AT, new Date()); - serverRepository.applyFieldUpdate(server.getId(), update); + serverAdminRepository.applyFieldUpdate(server.getId(), update); serverService.evictAllServerCaches(); } } diff --git a/src/main/java/gg/modl/backend/server/service/ServerProvisioningService.java b/src/main/java/gg/modl/backend/server/service/ServerProvisioningService.java index d35ee13..0255055 100644 --- a/src/main/java/gg/modl/backend/server/service/ServerProvisioningService.java +++ b/src/main/java/gg/modl/backend/server/service/ServerProvisioningService.java @@ -1,595 +1,600 @@ -package gg.modl.backend.server.service; - -import gg.modl.backend.database.MongoIndexBootstrapService; -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.repository.HomepageCardMongoRepository; -import gg.modl.backend.database.mongo.repository.KnowledgebaseCategoryMongoRepository; -import gg.modl.backend.database.mongo.repository.SettingsMongoRepository; -import gg.modl.backend.homepage.data.HomepageCard; -import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; -import gg.modl.backend.role.service.RoleService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.Settings; -import java.util.ArrayList; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class ServerProvisioningService { - private static final String AI_CHAT_ABUSE_CONFIG_ID = "6"; - private static final String AI_ANTI_SOCIAL_CONFIG_ID = "7"; - - private final TenantMongoAccess tenantMongoAccess; - private final MongoIndexBootstrapService mongoIndexBootstrapService; - private final SettingsMongoRepository settingsRepository; - private final KnowledgebaseCategoryMongoRepository knowledgebaseCategoryRepository; - private final HomepageCardMongoRepository homepageCardRepository; - private final RoleService roleService; - - public void provision(Server server) { - List failures = new ArrayList<>(); - Exception firstError = null; - - for (Map.Entry step : provisioningSteps(server).entrySet()) { - try { - step.getValue().run(); - } catch (Exception e) { - log.error("[Provisioning] Step '{}' failed for server: {}", step.getKey(), server.getCustomDomain(), e); - failures.add(step.getKey() + ": " + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); - if (firstError == null) { - firstError = e; - } - } - } - - if (!failures.isEmpty()) { - throw new ProvisioningException( - "Provisioning failed for " + server.getCustomDomain() + " [" + String.join("; ", failures) + "]", - firstError); - } - } - - private Map provisioningSteps(Server server) { - Map steps = new LinkedHashMap<>(); - steps.put("createTenantIndexes", () -> mongoIndexBootstrapService.createTenantIndexes(tenantMongoAccess.forServer(server))); - steps.put("seedAIModerationSettings", () -> seedAIModerationSettings(server)); - steps.put("seedTicketForms", () -> seedTicketForms(server)); - steps.put("seedQuickResponses", () -> seedQuickResponses(server)); - steps.put("seedGeneralSettings", () -> seedGeneralSettings(server)); - steps.put("seedTicketLabelSettings", () -> seedTicketLabelSettings(server)); - steps.put("seedKnowledgebaseCategories", () -> seedKnowledgebaseCategories(server)); - steps.put("seedHomepageCards", () -> seedHomepageCards(server)); - steps.put("createDefaultRoles", () -> roleService.createDefaultRoles(server)); - return steps; - } - - private void seedAIModerationSettings(Server server) { - if (settingsExist(server, "aiModerationSettings")) { - return; - } - - Map chatAbuseConfig = new LinkedHashMap<>(); - chatAbuseConfig.put("id", AI_CHAT_ABUSE_CONFIG_ID); - chatAbuseConfig.put("name", "Chat Abuse"); - chatAbuseConfig.put("aiDescription", - "Chat abuse is the act of spamming, excessive profanity, abusive language, inappropriate topics or jokes, and misleading information"); - chatAbuseConfig.put("enabled", true); - - Map antiSocialConfig = new LinkedHashMap<>(); - antiSocialConfig.put("id", AI_ANTI_SOCIAL_CONFIG_ID); - antiSocialConfig.put("name", "Anti Social"); - antiSocialConfig.put("aiDescription", - "Anti social is the act of harassing, threatening, black-mailing, or otherwise abusing another player or group of players. This includes bigotry and other forms of discrimination against protected classes."); - antiSocialConfig.put("enabled", true); - - Map aiPunishmentConfigs = new LinkedHashMap<>(); - aiPunishmentConfigs.put(AI_CHAT_ABUSE_CONFIG_ID, chatAbuseConfig); - aiPunishmentConfigs.put(AI_ANTI_SOCIAL_CONFIG_ID, antiSocialConfig); - - Map data = new LinkedHashMap<>(); - data.put("enableAIReview", false); - data.put("enableAutomatedActions", false); - data.put("aiPunishmentConfigs", aiPunishmentConfigs); - - settingsRepository.saveEntity(server, newSettingsDocument("aiModerationSettings", data)); - } - - private boolean settingsExist(Server server, String type) { - return settingsRepository.existsByType(server, type); - } - - private Settings newSettingsDocument(String type, Map data) { - return new Settings(null, type, data, 0L, new Date()); - } - - private void seedTicketForms(Server server) { - if (settingsExist(server, "ticketForms")) { - return; - } - - // Bug report form - Map bugForm = new LinkedHashMap<>(); - bugForm.put("fields", List.of( - formField("1753243804677", "textarea", "Bug Description", "Describe the bug in full detail", true, 3, "1753243782799"), - formField("1753243846548", "textarea", "Environment", "Game/server, client version, and any other relevant conditions", true, 3, "1753243782799"), - formField("1753243865490", "textarea", "Steps to reproduce", "Detailed description on how we can reproduce the bug", true, 2, "1753243782799"), - formField("1753243883567", "textarea", "Any other information?", null, false, 3, "1753243782799"), - formField("1753243946458", "file_upload", "Attachments", "Upload relevant attachments to help us squash this bug.", false, 4, "1753243782799") - )); - bugForm.put("sections", List.of( - formSection("1753243782799", "General", 0, false) - )); - - // Support form - Map supportForm = new LinkedHashMap<>(); - supportForm.put("fields", List.of( - formField("1753243961223", "textarea", "Description", "How can we assist you?", true, 0, "1753243900648"), - formField("1753243997358", "file_upload", "Attachments", "Upload any relevant attachments.", false, 1, "1753243900648") - )); - supportForm.put("sections", List.of( - formSection("1753243900648", "General", 0, false) - )); - - // Application form - Map applicationForm = buildApplicationForm(); - - Map data = new LinkedHashMap<>(); - data.put("bug", bugForm); - data.put("support", supportForm); - data.put("application", applicationForm); - - settingsRepository.saveEntity(server, newSettingsDocument("ticketForms", data)); - } - - private Map buildApplicationForm() { - List> fields = new ArrayList<>(); - - // General section fields - fields.add(formField("1753244313811", "text", "First Name", null, true, 0, "1753244011186")); - fields.add(formField("1753244038340", "text", "Discord username", "Please use the new username format, starting with an @.", true, 1, "1753244011186")); - fields.add(formField("1753244070995", "text", "Age", null, true, 2, "1753244011186")); - fields.add(formField("1753244166086", "text", "Region & Timezone", "Ex: NA, Eastern Time", true, 3, "1753244011186")); - fields.add( - formField("1753244525756", "text", "What languages can you speak?", "If you speak more than one, please list your level of fluency in each.", true, - 4, "1753244011186")); - fields.add( - formField("1753244114967", "checkbox", "Do you have access to both a working microphone and recording software?", null, true, 5, "1753244011186")); - - // Position dropdown with section mapping - Map positionField = new LinkedHashMap<>(); - positionField.put("id", "1753244244863"); - positionField.put("type", "dropdown"); - positionField.put("label", "Position"); - positionField.put("description", "What position are you applying for?"); - positionField.put("required", true); - positionField.put("options", List.of("Moderator", "Builder", "Developer", "Media")); - positionField.put("order", 6); - positionField.put("sectionId", "1753244011186"); - positionField.put("optionSectionMapping", Map.of( - "Moderator", "1753244183109", - "Builder", "1753244277605", - "Developer", "1753244282540", - "Media", "1753244286527" - )); - fields.add(positionField); - - // Moderator section fields - fields.add(formField("1753244506417", "textarea", "Have you ever been banned or muted on this server? If yes, what have you learned moving forward?", - "If so, please explain each occurrence.", true, 0, "1753244183109")); - fields.add(formField("1753244551193", "textarea", "Describe your moderation background and previous experience.", - "The more detail the better. This doesn't have to be limited to Minecraft servers, as we welcome any previous experience in moderating Discord servers or even other game communities. Please provide references and proof for your more notable experiences.", - true, 1, "1753244183109")); - fields.add(formField("1753244585381", "textarea", "Why do you want to become a moderator on this server?", - "Again, the more detail on this question the better. Providing us with as much detail as possible will help us understand your motivation and will to become a moderator!", - true, 9, "1753244183109")); - fields.add(formField("1753244603377", "text", "How much time do you see yourself committing to the server?", null, true, 10, "1753244183109")); - fields.add(formField("1753244687326", "textarea", - "You are a Moderator with the ability to mute and ban. You are playing on the server with a friend and come across a player who you think is hacking. They kill your friend, but then you kill them. What do you do in this situation?", - null, true, 11, "1753244183109")); - fields.add(formField("1753244762984", "textarea", - "You are a Moderator with the ability to mute and ban. You are spectating a player who you believe is hacking, but multiple chat reports come in about a player in another gamemode who is being violently disruptive in chat. Somehow, you are the only moderator online. How do you handle the two situations?", - null, true, 12, "1753244183109")); - fields.add(formField("1753244861431", "textarea", - "You are a Moderator with the ability to mute and ban. You see 5+ reports come in accusing the same player of breaking the chat rules. You join the server where the situation is taking place and open the accused player's recent chat history. You see that they were being rude, but haven't actually broken a rule. When you decide that they are not guilty, the same group reports the player again, and sends you multiple private messages calling you a bad moderator for not muting the player. What's the first step in dealing with this situation? Explain how this step will move towards resolving the conflict.", - null, true, 13, "1753244183109")); - fields.add(formField("1753244931272", "textarea", - "You are the newest Moderator on the team. While you are spectating a game, you witness a Sr. Moderator mining suspiciously. In a matter of minutes, you get enough evidence that suggests that the Sr. Moderator may likely be x-raying. Suddenly, they head to the surface and do nothing suspicious for the rest of your time spectating them. How do you proceed?", - null, true, 14, "1753244183109")); - fields.add(formField("1753245023983", "textarea", - "You are a Moderator with the ability to mute and ban. You notice a well-known streamer/YouTuber closely affiliated with the server is nicked. They message a player words encouraging suicide under their disguised alias. What steps do you take to resolve the situation?", - null, true, 15, "1753244183109")); - - // Builder section fields - fields.add(formField("1753245081481", "textarea", "Do you have experience building for other servers?", null, true, 16, "1753244277605")); - fields.add(formField("1753245137086", "textarea", "Please provide proof of previous work in link form here (Imgur, YouTube, etc)", null, true, 17, - "1753244277605")); - fields.add(formField("1753245154307", "textarea", "Anything else you would like to say?", null, false, 23, "1753244277605")); - - // Developer section fields - fields.add(formField("1753245191475", "textarea", "Why do you want to be a developer on this server?", null, true, 0, "1753244282540")); - fields.add(formField("1753245262717", "textarea", "Do you have experience developing for other servers?", null, true, 1, "1753244282540")); - fields.add(formField("1753245280773", "text", "Please provide proof of previous work in the form of a GitHub link", null, true, 2, "1753244282540")); - fields.add(formField("1753245291714", "textarea", "Anything else you would like to say?", null, false, 3, "1753244282540")); - - // Media section fields - fields.add(formField("1753245348514", "text", "Have you ever been banned or muted on this server? If yes, what have you learned moving forward?", - "If so, please explain each occurrence.", true, 23, "1753244286527")); - fields.add(formField("1753245358313", "text", "A link to your YouTube and/or Stream Channel", null, true, 24, "1753244286527")); - fields.add(formField("1753245471763", "checkbox", - "We will email the contact email listed on the channel for proof of ownership, please verify it is accurate and actively monitored.", null, true, - 25, "1753244286527")); - fields.add(formField("1753245511672", "textarea", "Anything else you would like to say?", null, false, 26, "1753244286527")); - - List> sections = List.of( - formSection("1753244011186", "General", 0, false), - formSection("1753244183109", "Moderator", 1, true), - formSection("1753244277605", "Builder", 2, true), - formSection("1753244282540", "Developer", 3, true), - formSection("1753244286527", "Media", 4, true) - ); - - Map form = new LinkedHashMap<>(); - form.put("fields", fields); - form.put("sections", sections); - return form; - } - - private Map formField(String id, String type, String label, String description, boolean required, int order, String sectionId) { - Map field = new LinkedHashMap<>(); - field.put("id", id); - field.put("type", type); - field.put("label", label); - if (description != null) { - field.put("description", description); - } - field.put("required", required); - field.put("order", order); - field.put("sectionId", sectionId); - return field; - } - - private Map formSection(String id, String title, int order, boolean hideByDefault) { - Map section = new LinkedHashMap<>(); - section.put("id", id); - section.put("title", title); - section.put("order", order); - section.put("hideByDefault", hideByDefault); - return section; - } - - private void seedQuickResponses(Server server) { - if (settingsExist(server, "quickResponses")) { - return; - } - - List> categories = new ArrayList<>(); - - // Chat Report Actions - categories.add(quickResponseCategory("chat_report_actions", "Chat Report Actions", List.of("chat_report"), 1, List.of( - quickResponseAction("accept_report", "Accept Report", - "Thank you for creating this report. After careful review, we have accepted this and the reported player will be receiving a punishment.", 1, - true, true, null), - quickResponseAction("reject_insufficient_chat", "More Evidence", - "Thank you for submitting this chat report. After reviewing the evidence provided, we need additional evidence to proceed with action.", 2, - false, false, null), - quickResponseAction("reject_no_violation_chat", "Reject - No Violation", - "Thank you for submitting this chat report. After reviewing the evidence provided, we have determined that this does not violate our community guidelines.", - 3, true, false, null) - ))); - - // Player Report Actions - categories.add(quickResponseCategory("player_report_actions", "Player Report Actions", List.of("player_report"), 2, List.of( - quickResponseAction("accept_report", "Accept Report", - "Thank you for creating this report. After careful review, we have accepted this and the reported player will be receiving a punishment.", 1, - true, true, null), - quickResponseAction("reject_insufficient_player", "More Evidence", - "Thank you for submitting this player report. After reviewing the evidence provided, we need additional evidence to proceed with action.", 2, - false, false, null), - quickResponseAction("reject_no_violation_player", "Reject - No Violation", - "Thank you for submitting this player report. After reviewing the evidence provided, we have determined that this does not violate our community guidelines.", - 3, true, false, null) - ))); - - // Appeal Actions - categories.add(quickResponseCategory("appeal_actions", "Appeal Actions", List.of("appeal"), 3, List.of( - quickResponseAction("pardon_full", "Pardon - Full", - "After reviewing your appeal, we have decided to remove the punishment completely. We apologize for any inconvenience.", 1, true, false, - "pardon"), - quickResponseAction("reduce_punishment", "Reduce Punishment", - "We have reviewed your appeal and decided to reduce the duration of your punishment. Please check your punishment details for the updated duration.", - 2, true, false, "reduce"), - quickResponseAction("reject_upheld", "Reject - Upheld", - "After careful consideration of your appeal, we have decided to uphold the original punishment.", 3, true, false, "reject"), - quickResponseAction("need_more_info_appeal", "Need More Information", - "We need additional information to process your appeal. Please provide more details about your situation.", 4, false, false, null) - ))); - - // Staff Application Actions - categories.add(quickResponseCategory("application_actions", "Staff Application Actions", List.of("application"), 4, List.of( - quickResponseAction("accept_builder", "Accept - Builder", - "Congratulations! Your Builder application has been accepted. Welcome to the Builder team! You will receive further instructions and permissions shortly.", - 1, true, false, null), - quickResponseAction("accept_helper", "Accept - Helper", - "Congratulations! Your Helper application has been accepted. Welcome to the Helper team! You will receive further instructions and permissions shortly.", - 2, true, false, null), - quickResponseAction("accept_developer", "Accept - Developer", - "Congratulations! Your Developer application has been accepted. Welcome to the Developer team! You will receive further instructions and permissions shortly.", - 3, true, false, null), - quickResponseAction("reject_application", "Reject Application", - "Thank you for your interest in joining our team. Unfortunately, we have decided not to move forward with your application at this time. You may reapply in the future.", - 4, true, false, null), - quickResponseAction("pending_review", "Pending Review", - "Thank you for your application. We are currently reviewing it and will get back to you soon.", 5, false, false, null), - quickResponseAction("interview_scheduled", "Interview Scheduled", - "Your application has progressed to the interview stage. Please check your email for interview details.", 6, false, false, null), - quickResponseAction("need_more_info_app", "Need More Information", - "We need additional information about your application. Please provide more details about your experience and qualifications.", 7, false, false, - null) - ))); - - // Bug Report Actions - categories.add(quickResponseCategory("bug_actions", "Bug Report Actions", List.of("bug"), 5, List.of( - quickResponseAction("completed", "Fixed", "Thank you for reporting this bug. We have fixed the issue and it will be included in our next update.", - 1, true, false, null), - quickResponseAction("investigating", "Investigating", - "Thank you for this bug report. We are currently investigating the issue and will provide updates as they become available.", 2, false, false, - null), - quickResponseAction("need_more_info", "Need More Info", - "Thank you for this bug report. We need additional information to investigate this issue. Please provide more details about how to reproduce this bug.", - 3, false, false, null), - quickResponseAction("duplicate", "Duplicate", - "This bug has been identified as a duplicate of an existing issue. We appreciate your report and are working on a fix.", 4, true, false, null), - quickResponseAction("cannot_reproduce", "Cannot Reproduce", - "We were unable to reproduce this issue. If you continue to experience this problem, please provide additional details.", 5, true, false, null) - ))); - - // Support Actions - categories.add(quickResponseCategory("support_actions", "Support Actions", List.of("support"), 6, List.of( - quickResponseAction("resolved", "Resolved", - "Your support request has been resolved. If you need further assistance, please feel free to create a new ticket.", 1, true, false, null), - quickResponseAction("escalated", "Escalated", - "Your support request has been escalated to our specialized team. They will contact you with additional information.", 2, false, false, null), - quickResponseAction("need_info_support", "Need More Info", - "We need additional information to assist you with your request. Please provide more details about your issue.", 3, false, false, null) - ))); - - // General Actions - categories.add( - quickResponseCategory("general_actions", "General Actions", List.of("player_report", "chat_report", "bug", "appeal", "support", "application"), 7, - List.of( - quickResponseAction("acknowledge", "Acknowledge", "Thank you for your message. We have received your ticket and will review it shortly.", 1, - false, false, null), - quickResponseAction("follow_up", "Follow Up", - "We are following up on your ticket. Please let us know if you have any additional information or questions.", 2, false, false, null) - ))); - - Map data = new LinkedHashMap<>(); - data.put("categories", categories); - - settingsRepository.saveEntity(server, newSettingsDocument("quickResponses", data)); - } - - private Map quickResponseCategory(String id, String name, List ticketTypes, int order, List> actions) { - Map category = new LinkedHashMap<>(); - category.put("id", id); - category.put("name", name); - category.put("ticketTypes", ticketTypes); - category.put("order", order); - category.put("actions", actions); - return category; - } - - private Map quickResponseAction(String id, String name, String message, int order, boolean closeTicket, boolean showPunishment, String appealAction) { - Map action = new LinkedHashMap<>(); - action.put("id", id); - action.put("name", name); - action.put("message", message); - action.put("order", order); - action.put("closeTicket", closeTicket); - if (showPunishment) { - action.put("showPunishment", true); - } - if (appealAction != null) { - action.put("appealAction", appealAction); - } - return action; - } - - // Helper methods for building form data structures - - private void seedGeneralSettings(Server server) { - if (settingsExist(server, "general")) { - return; - } - - Map data = new LinkedHashMap<>(); - data.put("serverDisplayName", ""); - data.put("discordWebhookUrl", ""); - data.put("homepageIconUrl", ""); - data.put("panelIconUrl", ""); - - settingsRepository.saveEntity(server, newSettingsDocument("general", data)); - } - - private void seedTicketLabelSettings(Server server) { - if (settingsExist(server, "ticketLabels")) { - return; - } - - List> labels = List.of( - labelMap("high priority", "#e74c3c", "High priority tickets"), - labelMap("needs admin review", "#f39c12", "Tickets that need review"), - labelMap("in progress", "#2ecc71", "Tickets being worked on"), - labelMap("won't fix", "#6b7280", "Issues that won't be fixed"), - labelMap("duplicate", "#6b7280", "Duplicate tickets") - ); - - Map data = new LinkedHashMap<>(); - data.put("labels", labels); - - settingsRepository.saveEntity(server, newSettingsDocument("ticketLabels", data)); - } - - private Map labelMap(String name, String color, String description) { - Map label = new LinkedHashMap<>(); - label.put("id", UUID.randomUUID().toString()); - label.put("name", name); - label.put("color", color); - label.put("description", description); - return label; - } - - private List seedKnowledgebaseCategories(Server server) { - if (knowledgebaseCategoryRepository.hasAny(server)) { - return List.of(); - } - - Date now = new Date(); - List categories = List.of( - KnowledgebaseCategory.builder() - .name("Rules & Policies") - .slug("rules-policies") - .description("Server rules, community guidelines, and policies") - .ordinal(0) - .isVisible(true) - .createdAt(now) - .updatedAt(now) - .build(), - KnowledgebaseCategory.builder() - .name("Guides & Troubleshooting") - .slug("guides-troubleshooting") - .description("How-to guides and troubleshooting help") - .ordinal(1) - .isVisible(true) - .createdAt(now) - .updatedAt(now) - .build(), - KnowledgebaseCategory.builder() - .name("News & Updates") - .slug("news-updates") - .description("Latest announcements, updates, and news") - .ordinal(2) - .isVisible(true) - .createdAt(now) - .updatedAt(now) - .build() - ); - - for (KnowledgebaseCategory category : categories) { - knowledgebaseCategoryRepository.saveEntity(server, category); - } - return categories; - } - - private void seedHomepageCards(Server server) { - if (homepageCardRepository.hasAny(server)) { - return; - } - - List categories = knowledgebaseCategoryRepository.findAllOrdered(server); - - // Find category IDs for category_dropdown cards - String rulesCategoryId = categories.stream() - .filter(c -> "rules-policies".equals(c.getSlug())) - .findFirst().map(KnowledgebaseCategory::getId).orElse(null); - String guidesCategoryId = categories.stream() - .filter(c -> "guides-troubleshooting".equals(c.getSlug())) - .findFirst().map(KnowledgebaseCategory::getId).orElse(null); - String newsCategoryId = categories.stream() - .filter(c -> "news-updates".equals(c.getSlug())) - .findFirst().map(KnowledgebaseCategory::getId).orElse(null); - - Date now = new Date(); - - List cards = List.of( - HomepageCard.builder() - .title("Appeal Punishment") - .description("Submit an appeal if you believe you were unfairly banned or punished") - .icon("Scale") - .iconColor("#f59e0b") - .actionType("url") - .actionUrl("/appeal") - .actionButtonText("Submit Appeal") - .isEnabled(true) - .ordinal(0) - .createdAt(now) - .updatedAt(now) - .build(), - HomepageCard.builder() - .title("Apply for Staff") - .description("Join our staff team and help manage the community") - .icon("UserPlus") - .iconColor("#3b82f6") - .actionType("url") - .actionUrl("/submit-ticket/apply") - .actionButtonText("Apply Now") - .isEnabled(true) - .ordinal(1) - .createdAt(now) - .updatedAt(now) - .build(), - HomepageCard.builder() - .title("Contact Us") - .description("Get help from our support team for any issues") - .icon("MessageCircle") - .iconColor("#10b981") - .actionType("url") - .actionUrl("/submit-ticket/support") - .actionButtonText("Contact Support") - .isEnabled(true) - .ordinal(2) - .createdAt(now) - .updatedAt(now) - .build(), - HomepageCard.builder() - .title("Rules & Policies") - .description("Browse server rules, community guidelines, and policies") - .icon("BookOpen") - .iconColor("#8b5cf6") - .actionType("category_dropdown") - .categoryId(rulesCategoryId) - .isEnabled(true) - .ordinal(3) - .createdAt(now) - .updatedAt(now) - .build(), - HomepageCard.builder() - .title("Guides & Troubleshooting") - .description("Find helpful guides and troubleshooting resources") - .icon("HelpCircle") - .iconColor("#f97316") - .actionType("category_dropdown") - .categoryId(guidesCategoryId) - .isEnabled(true) - .ordinal(4) - .createdAt(now) - .updatedAt(now) - .build(), - HomepageCard.builder() - .title("News & Updates") - .description("Stay up to date with the latest announcements and changes") - .icon("Newspaper") - .iconColor("#6366f1") - .actionType("category_dropdown") - .categoryId(newsCategoryId) - .isEnabled(true) - .ordinal(5) - .createdAt(now) - .updatedAt(now) - .build() - ); - - for (HomepageCard card : cards) { - homepageCardRepository.saveEntity(server, card); - } - } - -} +package gg.modl.backend.server.service; + +import gg.modl.backend.database.MongoIndexReconciler; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.repository.HomepageCardMongoRepository; +import gg.modl.backend.database.mongo.repository.KnowledgebaseCategoryMongoRepository; +import gg.modl.backend.database.mongo.repository.SettingsMongoRepository; +import gg.modl.backend.homepage.data.HomepageCard; +import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; +import gg.modl.backend.role.service.RoleService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.Settings; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ServerProvisioningService { + private static final String AI_CHAT_ABUSE_CONFIG_ID = "6"; + private static final String AI_ANTI_SOCIAL_CONFIG_ID = "7"; + + private final TenantMongoAccess tenantMongoAccess; + private final MongoIndexReconciler mongoIndexReconciler; + private final SettingsMongoRepository settingsRepository; + private final KnowledgebaseCategoryMongoRepository knowledgebaseCategoryRepository; + private final HomepageCardMongoRepository homepageCardRepository; + private final RoleService roleService; + + public void provision(Server server) { + List failures = new ArrayList<>(); + Exception firstError = null; + + for (Map.Entry step : provisioningSteps(server).entrySet()) { + try { + step.getValue().run(); + } catch (Exception e) { + log.error("[Provisioning] Step '{}' failed for server: {}", step.getKey(), server.getCustomDomain(), e); + failures.add(step.getKey() + ": " + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); + if (firstError == null) { + firstError = e; + } + } + } + + if (!failures.isEmpty()) { + throw new ProvisioningException( + "Provisioning failed for " + server.getCustomDomain() + " [" + String.join("; ", failures) + "]", + firstError); + } + } + + public boolean tenantDatabaseExists(Server server) { + try { + return !tenantMongoAccess.forServer(server).getCollectionNames().isEmpty(); + } catch (Exception e) { + log.warn("[Provisioning] Could not determine whether tenant database '{}' already exists for {}; assuming it does", + server.getDatabaseName(), server.getCustomDomain(), e); + return true; + } + } + + public void teardownProvisionedDatabase(Server server, boolean databaseExistedBeforeProvisioning) { + if (databaseExistedBeforeProvisioning) { + return; + } + try { + tenantMongoAccess.forServer(server).getDb().drop(); + log.warn("[Provisioning] Dropped tenant database '{}' created by a failed provisioning attempt for {}", + server.getDatabaseName(), server.getCustomDomain()); + } catch (Exception e) { + log.error("[Provisioning] Failed to drop tenant database '{}' after a failed provisioning attempt for {}", + server.getDatabaseName(), server.getCustomDomain(), e); + } + } + + private Map provisioningSteps(Server server) { + Map steps = new LinkedHashMap<>(); + steps.put("createTenantIndexes", () -> mongoIndexReconciler.createTenantIndexes(tenantMongoAccess.forServer(server))); + steps.put("seedAIModerationSettings", () -> seedAIModerationSettings(server)); + steps.put("seedTicketForms", () -> seedTicketForms(server)); + steps.put("seedQuickResponses", () -> seedQuickResponses(server)); + steps.put("seedGeneralSettings", () -> seedGeneralSettings(server)); + steps.put("seedTicketLabelSettings", () -> seedTicketLabelSettings(server)); + steps.put("seedKnowledgebaseCategories", () -> seedKnowledgebaseCategories(server)); + steps.put("seedHomepageCards", () -> seedHomepageCards(server)); + steps.put("createDefaultRoles", () -> roleService.createDefaultRoles(server)); + return steps; + } + + private void seedAIModerationSettings(Server server) { + if (settingsExist(server, "aiModerationSettings")) { + return; + } + + Map chatAbuseConfig = new LinkedHashMap<>(); + chatAbuseConfig.put("id", AI_CHAT_ABUSE_CONFIG_ID); + chatAbuseConfig.put("name", "Chat Abuse"); + chatAbuseConfig.put("aiDescription", + "Chat abuse is the act of spamming, excessive profanity, abusive language, inappropriate topics or jokes, and misleading information"); + chatAbuseConfig.put("enabled", true); + + Map antiSocialConfig = new LinkedHashMap<>(); + antiSocialConfig.put("id", AI_ANTI_SOCIAL_CONFIG_ID); + antiSocialConfig.put("name", "Anti Social"); + antiSocialConfig.put("aiDescription", + "Anti social is the act of harassing, threatening, black-mailing, or otherwise abusing another player or group of players. This includes bigotry and other forms of discrimination against protected classes."); + antiSocialConfig.put("enabled", true); + + Map aiPunishmentConfigs = new LinkedHashMap<>(); + aiPunishmentConfigs.put(AI_CHAT_ABUSE_CONFIG_ID, chatAbuseConfig); + aiPunishmentConfigs.put(AI_ANTI_SOCIAL_CONFIG_ID, antiSocialConfig); + + Map data = new LinkedHashMap<>(); + data.put("enableAIReview", false); + data.put("enableAutomatedActions", false); + data.put("aiPunishmentConfigs", aiPunishmentConfigs); + + settingsRepository.saveEntity(server, newSettingsDocument("aiModerationSettings", data)); + } + + private boolean settingsExist(Server server, String type) { + return settingsRepository.existsByType(server, type); + } + + private Settings newSettingsDocument(String type, Map data) { + return new Settings(null, type, data, 0L, new Date()); + } + + private void seedTicketForms(Server server) { + if (settingsExist(server, "ticketForms")) { + return; + } + + Map bugForm = new LinkedHashMap<>(); + bugForm.put("fields", List.of( + formField("1753243804677", "textarea", "Bug Description", "Describe the bug in full detail", true, 3, "1753243782799"), + formField("1753243846548", "textarea", "Environment", "Game/server, client version, and any other relevant conditions", true, 3, "1753243782799"), + formField("1753243865490", "textarea", "Steps to reproduce", "Detailed description on how we can reproduce the bug", true, 2, "1753243782799"), + formField("1753243883567", "textarea", "Any other information?", null, false, 3, "1753243782799"), + formField("1753243946458", "file_upload", "Attachments", "Upload relevant attachments to help us squash this bug.", false, 4, "1753243782799") + )); + bugForm.put("sections", List.of( + formSection("1753243782799", "General", 0, false) + )); + + Map supportForm = new LinkedHashMap<>(); + supportForm.put("fields", List.of( + formField("1753243961223", "textarea", "Description", "How can we assist you?", true, 0, "1753243900648"), + formField("1753243997358", "file_upload", "Attachments", "Upload any relevant attachments.", false, 1, "1753243900648") + )); + supportForm.put("sections", List.of( + formSection("1753243900648", "General", 0, false) + )); + + Map applicationForm = buildApplicationForm(); + + Map data = new LinkedHashMap<>(); + data.put("bug", bugForm); + data.put("support", supportForm); + data.put("application", applicationForm); + + settingsRepository.saveEntity(server, newSettingsDocument("ticketForms", data)); + } + + private Map buildApplicationForm() { + List> fields = new ArrayList<>(); + + fields.add(formField("1753244313811", "text", "First Name", null, true, 0, "1753244011186")); + fields.add(formField("1753244038340", "text", "Discord username", "Please use the new username format, starting with an @.", true, 1, "1753244011186")); + fields.add(formField("1753244070995", "text", "Age", null, true, 2, "1753244011186")); + fields.add(formField("1753244166086", "text", "Region & Timezone", "Ex: NA, Eastern Time", true, 3, "1753244011186")); + fields.add( + formField("1753244525756", "text", "What languages can you speak?", "If you speak more than one, please list your level of fluency in each.", true, + 4, "1753244011186")); + fields.add( + formField("1753244114967", "checkbox", "Do you have access to both a working microphone and recording software?", null, true, 5, "1753244011186")); + + Map positionField = new LinkedHashMap<>(); + positionField.put("id", "1753244244863"); + positionField.put("type", "dropdown"); + positionField.put("label", "Position"); + positionField.put("description", "What position are you applying for?"); + positionField.put("required", true); + positionField.put("options", List.of("Moderator", "Builder", "Developer", "Media")); + positionField.put("order", 6); + positionField.put("sectionId", "1753244011186"); + positionField.put("optionSectionMapping", Map.of( + "Moderator", "1753244183109", + "Builder", "1753244277605", + "Developer", "1753244282540", + "Media", "1753244286527" + )); + fields.add(positionField); + + fields.add(formField("1753244506417", "textarea", "Have you ever been banned or muted on this server? If yes, what have you learned moving forward?", + "If so, please explain each occurrence.", true, 0, "1753244183109")); + fields.add(formField("1753244551193", "textarea", "Describe your moderation background and previous experience.", + "The more detail the better. This doesn't have to be limited to Minecraft servers, as we welcome any previous experience in moderating Discord servers or even other game communities. Please provide references and proof for your more notable experiences.", + true, 1, "1753244183109")); + fields.add(formField("1753244585381", "textarea", "Why do you want to become a moderator on this server?", + "Again, the more detail on this question the better. Providing us with as much detail as possible will help us understand your motivation and will to become a moderator!", + true, 9, "1753244183109")); + fields.add(formField("1753244603377", "text", "How much time do you see yourself committing to the server?", null, true, 10, "1753244183109")); + fields.add(formField("1753244687326", "textarea", + "You are a Moderator with the ability to mute and ban. You are playing on the server with a friend and come across a player who you think is hacking. They kill your friend, but then you kill them. What do you do in this situation?", + null, true, 11, "1753244183109")); + fields.add(formField("1753244762984", "textarea", + "You are a Moderator with the ability to mute and ban. You are spectating a player who you believe is hacking, but multiple chat reports come in about a player in another gamemode who is being violently disruptive in chat. Somehow, you are the only moderator online. How do you handle the two situations?", + null, true, 12, "1753244183109")); + fields.add(formField("1753244861431", "textarea", + "You are a Moderator with the ability to mute and ban. You see 5+ reports come in accusing the same player of breaking the chat rules. You join the server where the situation is taking place and open the accused player's recent chat history. You see that they were being rude, but haven't actually broken a rule. When you decide that they are not guilty, the same group reports the player again, and sends you multiple private messages calling you a bad moderator for not muting the player. What's the first step in dealing with this situation? Explain how this step will move towards resolving the conflict.", + null, true, 13, "1753244183109")); + fields.add(formField("1753244931272", "textarea", + "You are the newest Moderator on the team. While you are spectating a game, you witness a Sr. Moderator mining suspiciously. In a matter of minutes, you get enough evidence that suggests that the Sr. Moderator may likely be x-raying. Suddenly, they head to the surface and do nothing suspicious for the rest of your time spectating them. How do you proceed?", + null, true, 14, "1753244183109")); + fields.add(formField("1753245023983", "textarea", + "You are a Moderator with the ability to mute and ban. You notice a well-known streamer/YouTuber closely affiliated with the server is nicked. They message a player words encouraging suicide under their disguised alias. What steps do you take to resolve the situation?", + null, true, 15, "1753244183109")); + + fields.add(formField("1753245081481", "textarea", "Do you have experience building for other servers?", null, true, 16, "1753244277605")); + fields.add(formField("1753245137086", "textarea", "Please provide proof of previous work in link form here (Imgur, YouTube, etc)", null, true, 17, + "1753244277605")); + fields.add(formField("1753245154307", "textarea", "Anything else you would like to say?", null, false, 23, "1753244277605")); + + fields.add(formField("1753245191475", "textarea", "Why do you want to be a developer on this server?", null, true, 0, "1753244282540")); + fields.add(formField("1753245262717", "textarea", "Do you have experience developing for other servers?", null, true, 1, "1753244282540")); + fields.add(formField("1753245280773", "text", "Please provide proof of previous work in the form of a GitHub link", null, true, 2, "1753244282540")); + fields.add(formField("1753245291714", "textarea", "Anything else you would like to say?", null, false, 3, "1753244282540")); + + fields.add(formField("1753245348514", "text", "Have you ever been banned or muted on this server? If yes, what have you learned moving forward?", + "If so, please explain each occurrence.", true, 23, "1753244286527")); + fields.add(formField("1753245358313", "text", "A link to your YouTube and/or Stream Channel", null, true, 24, "1753244286527")); + fields.add(formField("1753245471763", "checkbox", + "We will email the contact email listed on the channel for proof of ownership, please verify it is accurate and actively monitored.", null, true, + 25, "1753244286527")); + fields.add(formField("1753245511672", "textarea", "Anything else you would like to say?", null, false, 26, "1753244286527")); + + List> sections = List.of( + formSection("1753244011186", "General", 0, false), + formSection("1753244183109", "Moderator", 1, true), + formSection("1753244277605", "Builder", 2, true), + formSection("1753244282540", "Developer", 3, true), + formSection("1753244286527", "Media", 4, true) + ); + + Map form = new LinkedHashMap<>(); + form.put("fields", fields); + form.put("sections", sections); + return form; + } + + private Map formField(String id, String type, String label, String description, boolean required, int order, String sectionId) { + Map field = new LinkedHashMap<>(); + field.put("id", id); + field.put("type", type); + field.put("label", label); + if (description != null) { + field.put("description", description); + } + field.put("required", required); + field.put("order", order); + field.put("sectionId", sectionId); + return field; + } + + private Map formSection(String id, String title, int order, boolean hideByDefault) { + Map section = new LinkedHashMap<>(); + section.put("id", id); + section.put("title", title); + section.put("order", order); + section.put("hideByDefault", hideByDefault); + return section; + } + + private void seedQuickResponses(Server server) { + if (settingsExist(server, "quickResponses")) { + return; + } + + List> categories = new ArrayList<>(); + + categories.add(quickResponseCategory("chat_report_actions", "Chat Report Actions", List.of("chat_report"), 1, List.of( + quickResponseAction("accept_report", "Accept Report", + "Thank you for creating this report. After careful review, we have accepted this and the reported player will be receiving a punishment.", 1, + true, true, null), + quickResponseAction("reject_insufficient_chat", "More Evidence", + "Thank you for submitting this chat report. After reviewing the evidence provided, we need additional evidence to proceed with action.", 2, + false, false, null), + quickResponseAction("reject_no_violation_chat", "Reject - No Violation", + "Thank you for submitting this chat report. After reviewing the evidence provided, we have determined that this does not violate our community guidelines.", + 3, true, false, null) + ))); + + categories.add(quickResponseCategory("player_report_actions", "Player Report Actions", List.of("player_report"), 2, List.of( + quickResponseAction("accept_report", "Accept Report", + "Thank you for creating this report. After careful review, we have accepted this and the reported player will be receiving a punishment.", 1, + true, true, null), + quickResponseAction("reject_insufficient_player", "More Evidence", + "Thank you for submitting this player report. After reviewing the evidence provided, we need additional evidence to proceed with action.", 2, + false, false, null), + quickResponseAction("reject_no_violation_player", "Reject - No Violation", + "Thank you for submitting this player report. After reviewing the evidence provided, we have determined that this does not violate our community guidelines.", + 3, true, false, null) + ))); + + categories.add(quickResponseCategory("appeal_actions", "Appeal Actions", List.of("appeal"), 3, List.of( + quickResponseAction("pardon_full", "Pardon - Full", + "After reviewing your appeal, we have decided to remove the punishment completely. We apologize for any inconvenience.", 1, true, false, + "pardon"), + quickResponseAction("reduce_punishment", "Reduce Punishment", + "We have reviewed your appeal and decided to reduce the duration of your punishment. Please check your punishment details for the updated duration.", + 2, true, false, "reduce"), + quickResponseAction("reject_upheld", "Reject - Upheld", + "After careful consideration of your appeal, we have decided to uphold the original punishment.", 3, true, false, "reject"), + quickResponseAction("need_more_info_appeal", "Need More Information", + "We need additional information to process your appeal. Please provide more details about your situation.", 4, false, false, null) + ))); + + categories.add(quickResponseCategory("application_actions", "Staff Application Actions", List.of("application"), 4, List.of( + quickResponseAction("accept_builder", "Accept - Builder", + "Congratulations! Your Builder application has been accepted. Welcome to the Builder team! You will receive further instructions and permissions shortly.", + 1, true, false, null), + quickResponseAction("accept_helper", "Accept - Helper", + "Congratulations! Your Helper application has been accepted. Welcome to the Helper team! You will receive further instructions and permissions shortly.", + 2, true, false, null), + quickResponseAction("accept_developer", "Accept - Developer", + "Congratulations! Your Developer application has been accepted. Welcome to the Developer team! You will receive further instructions and permissions shortly.", + 3, true, false, null), + quickResponseAction("reject_application", "Reject Application", + "Thank you for your interest in joining our team. Unfortunately, we have decided not to move forward with your application at this time. You may reapply in the future.", + 4, true, false, null), + quickResponseAction("pending_review", "Pending Review", + "Thank you for your application. We are currently reviewing it and will get back to you soon.", 5, false, false, null), + quickResponseAction("interview_scheduled", "Interview Scheduled", + "Your application has progressed to the interview stage. Please check your email for interview details.", 6, false, false, null), + quickResponseAction("need_more_info_app", "Need More Information", + "We need additional information about your application. Please provide more details about your experience and qualifications.", 7, false, false, + null) + ))); + + categories.add(quickResponseCategory("bug_actions", "Bug Report Actions", List.of("bug"), 5, List.of( + quickResponseAction("completed", "Fixed", "Thank you for reporting this bug. We have fixed the issue and it will be included in our next update.", + 1, true, false, null), + quickResponseAction("investigating", "Investigating", + "Thank you for this bug report. We are currently investigating the issue and will provide updates as they become available.", 2, false, false, + null), + quickResponseAction("need_more_info", "Need More Info", + "Thank you for this bug report. We need additional information to investigate this issue. Please provide more details about how to reproduce this bug.", + 3, false, false, null), + quickResponseAction("duplicate", "Duplicate", + "This bug has been identified as a duplicate of an existing issue. We appreciate your report and are working on a fix.", 4, true, false, null), + quickResponseAction("cannot_reproduce", "Cannot Reproduce", + "We were unable to reproduce this issue. If you continue to experience this problem, please provide additional details.", 5, true, false, null) + ))); + + categories.add(quickResponseCategory("support_actions", "Support Actions", List.of("support"), 6, List.of( + quickResponseAction("resolved", "Resolved", + "Your support request has been resolved. If you need further assistance, please feel free to create a new ticket.", 1, true, false, null), + quickResponseAction("escalated", "Escalated", + "Your support request has been escalated to our specialized team. They will contact you with additional information.", 2, false, false, null), + quickResponseAction("need_info_support", "Need More Info", + "We need additional information to assist you with your request. Please provide more details about your issue.", 3, false, false, null) + ))); + + categories.add( + quickResponseCategory("general_actions", "General Actions", List.of("player_report", "chat_report", "bug", "appeal", "support", "application"), 7, + List.of( + quickResponseAction("acknowledge", "Acknowledge", "Thank you for your message. We have received your ticket and will review it shortly.", 1, + false, false, null), + quickResponseAction("follow_up", "Follow Up", + "We are following up on your ticket. Please let us know if you have any additional information or questions.", 2, false, false, null) + ))); + + Map data = new LinkedHashMap<>(); + data.put("categories", categories); + + settingsRepository.saveEntity(server, newSettingsDocument("quickResponses", data)); + } + + private Map quickResponseCategory(String id, String name, List ticketTypes, int order, List> actions) { + Map category = new LinkedHashMap<>(); + category.put("id", id); + category.put("name", name); + category.put("ticketTypes", ticketTypes); + category.put("order", order); + category.put("actions", actions); + return category; + } + + private Map quickResponseAction(String id, String name, String message, int order, boolean closeTicket, boolean showPunishment, String appealAction) { + Map action = new LinkedHashMap<>(); + action.put("id", id); + action.put("name", name); + action.put("message", message); + action.put("order", order); + action.put("closeTicket", closeTicket); + if (showPunishment) { + action.put("showPunishment", true); + } + if (appealAction != null) { + action.put("appealAction", appealAction); + } + return action; + } + + private void seedGeneralSettings(Server server) { + if (settingsExist(server, "general")) { + return; + } + + Map data = new LinkedHashMap<>(); + data.put("serverDisplayName", ""); + data.put("discordWebhookUrl", ""); + data.put("homepageIconUrl", ""); + data.put("panelIconUrl", ""); + + settingsRepository.saveEntity(server, newSettingsDocument("general", data)); + } + + private void seedTicketLabelSettings(Server server) { + if (settingsExist(server, "ticketLabels")) { + return; + } + + List> labels = List.of( + labelMap("high priority", "#e74c3c", "High priority tickets"), + labelMap("needs admin review", "#f39c12", "Tickets that need review"), + labelMap("in progress", "#2ecc71", "Tickets being worked on"), + labelMap("won't fix", "#6b7280", "Issues that won't be fixed"), + labelMap("duplicate", "#6b7280", "Duplicate tickets") + ); + + Map data = new LinkedHashMap<>(); + data.put("labels", labels); + + settingsRepository.saveEntity(server, newSettingsDocument("ticketLabels", data)); + } + + private Map labelMap(String name, String color, String description) { + Map label = new LinkedHashMap<>(); + label.put("id", UUID.randomUUID().toString()); + label.put("name", name); + label.put("color", color); + label.put("description", description); + return label; + } + + private List seedKnowledgebaseCategories(Server server) { + if (knowledgebaseCategoryRepository.hasAny(server)) { + return List.of(); + } + + Date now = new Date(); + List categories = List.of( + KnowledgebaseCategory.builder() + .name("Rules & Policies") + .slug("rules-policies") + .description("Server rules, community guidelines, and policies") + .ordinal(0) + .isVisible(true) + .createdAt(now) + .updatedAt(now) + .build(), + KnowledgebaseCategory.builder() + .name("Guides & Troubleshooting") + .slug("guides-troubleshooting") + .description("How-to guides and troubleshooting help") + .ordinal(1) + .isVisible(true) + .createdAt(now) + .updatedAt(now) + .build(), + KnowledgebaseCategory.builder() + .name("News & Updates") + .slug("news-updates") + .description("Latest announcements, updates, and news") + .ordinal(2) + .isVisible(true) + .createdAt(now) + .updatedAt(now) + .build() + ); + + for (KnowledgebaseCategory category : categories) { + knowledgebaseCategoryRepository.saveEntity(server, category); + } + return categories; + } + + private void seedHomepageCards(Server server) { + if (homepageCardRepository.hasAny(server)) { + return; + } + + List categories = knowledgebaseCategoryRepository.findAllOrdered(server); + + String rulesCategoryId = categories.stream() + .filter(c -> "rules-policies".equals(c.getSlug())) + .findFirst().map(KnowledgebaseCategory::getId).orElse(null); + String guidesCategoryId = categories.stream() + .filter(c -> "guides-troubleshooting".equals(c.getSlug())) + .findFirst().map(KnowledgebaseCategory::getId).orElse(null); + String newsCategoryId = categories.stream() + .filter(c -> "news-updates".equals(c.getSlug())) + .findFirst().map(KnowledgebaseCategory::getId).orElse(null); + + Date now = new Date(); + + List cards = List.of( + HomepageCard.builder() + .title("Appeal Punishment") + .description("Submit an appeal if you believe you were unfairly banned or punished") + .icon("Scale") + .iconColor("#f59e0b") + .actionType("url") + .actionUrl("/appeal") + .actionButtonText("Submit Appeal") + .isEnabled(true) + .ordinal(0) + .createdAt(now) + .updatedAt(now) + .build(), + HomepageCard.builder() + .title("Apply for Staff") + .description("Join our staff team and help manage the community") + .icon("UserPlus") + .iconColor("#3b82f6") + .actionType("url") + .actionUrl("/submit-ticket/apply") + .actionButtonText("Apply Now") + .isEnabled(true) + .ordinal(1) + .createdAt(now) + .updatedAt(now) + .build(), + HomepageCard.builder() + .title("Contact Us") + .description("Get help from our support team for any issues") + .icon("MessageCircle") + .iconColor("#10b981") + .actionType("url") + .actionUrl("/submit-ticket/support") + .actionButtonText("Contact Support") + .isEnabled(true) + .ordinal(2) + .createdAt(now) + .updatedAt(now) + .build(), + HomepageCard.builder() + .title("Rules & Policies") + .description("Browse server rules, community guidelines, and policies") + .icon("BookOpen") + .iconColor("#8b5cf6") + .actionType("category_dropdown") + .categoryId(rulesCategoryId) + .isEnabled(true) + .ordinal(3) + .createdAt(now) + .updatedAt(now) + .build(), + HomepageCard.builder() + .title("Guides & Troubleshooting") + .description("Find helpful guides and troubleshooting resources") + .icon("HelpCircle") + .iconColor("#f97316") + .actionType("category_dropdown") + .categoryId(guidesCategoryId) + .isEnabled(true) + .ordinal(4) + .createdAt(now) + .updatedAt(now) + .build(), + HomepageCard.builder() + .title("News & Updates") + .description("Stay up to date with the latest announcements and changes") + .icon("Newspaper") + .iconColor("#6366f1") + .actionType("category_dropdown") + .categoryId(newsCategoryId) + .isEnabled(true) + .ordinal(5) + .createdAt(now) + .updatedAt(now) + .build() + ); + + for (HomepageCard card : cards) { + homepageCardRepository.saveEntity(server, card); + } + } + +} diff --git a/src/main/java/gg/modl/backend/server/service/ServerTimestampService.java b/src/main/java/gg/modl/backend/server/service/ServerTimestampService.java index a5cc109..3ae33f5 100644 --- a/src/main/java/gg/modl/backend/server/service/ServerTimestampService.java +++ b/src/main/java/gg/modl/backend/server/service/ServerTimestampService.java @@ -1,6 +1,6 @@ package gg.modl.backend.server.service; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerSettingsTimestampRepository; import gg.modl.backend.realtime.dispatch.RealtimeEventDispatcher; import gg.modl.backend.realtime.dispatch.RealtimeOutboundEvent; import gg.modl.backend.server.data.Server; @@ -9,6 +9,7 @@ import gg.modl.proto.modl.v1.RealtimeEnvelope; import gg.modl.proto.modl.v1.Topic; import java.util.Date; +import java.util.function.Consumer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; @@ -21,41 +22,37 @@ public class ServerTimestampService { private static final String STAFF_PERMISSIONS_TIMESTAMP_FIELD = "staffPermissionsUpdatedAt"; private static final String PUNISHMENT_TYPES_TIMESTAMP_FIELD = "punishmentTypesUpdatedAt"; - private final ServerMongoRepository serverRepository; + private final ServerSettingsTimestampRepository serverSettingsTimestampRepository; private final RealtimeEventDispatcher realtimeEventDispatcher; private long lastTimestampMillis; public void updateStaffPermissionsTimestamp(@NotNull Server server) { Date timestamp = nextTimestamp(); - serverRepository.updateStaffPermissionsTimestamp(server.getId(), timestamp); - try { - realtimeEventDispatcher.publish(new RealtimeOutboundEvent( - server.getId(), - Topic.TOPIC_MINECRAFT_PERMISSIONS, - RealtimeEnvelope.newBuilder() - .setEventId(eventId(server, STAFF_PERMISSIONS_TIMESTAMP_FIELD, timestamp)) - .setPermissionInvalidated(PermissionInvalidatedEvent.newBuilder()) - .build() - )); - } catch (Exception e) { - log.warn("Failed to dispatch realtime staff permissions invalidation", e); - } + serverSettingsTimestampRepository.updateStaffPermissionsTimestamp(server.getId(), timestamp); + publishInvalidation(server, timestamp, STAFF_PERMISSIONS_TIMESTAMP_FIELD, + Topic.TOPIC_MINECRAFT_PERMISSIONS, + envelope -> envelope.setPermissionInvalidated(PermissionInvalidatedEvent.newBuilder()), + "Failed to dispatch realtime staff permissions invalidation"); } public void updatePunishmentTypesTimestamp(@NotNull Server server) { Date timestamp = nextTimestamp(); - serverRepository.updatePunishmentTypesTimestamp(server.getId(), timestamp); + serverSettingsTimestampRepository.updatePunishmentTypesTimestamp(server.getId(), timestamp); + publishInvalidation(server, timestamp, PUNISHMENT_TYPES_TIMESTAMP_FIELD, + Topic.TOPIC_MINECRAFT_PUNISHMENT_TYPES, + envelope -> envelope.setPunishmentTypeInvalidated(PunishmentTypeInvalidatedEvent.newBuilder()), + "Failed to dispatch realtime punishment type invalidation"); + } + + private void publishInvalidation(Server server, Date timestamp, String timestampField, Topic topic, + Consumer payloadSetter, String failureMessage) { try { - realtimeEventDispatcher.publish(new RealtimeOutboundEvent( - server.getId(), - Topic.TOPIC_MINECRAFT_PUNISHMENT_TYPES, - RealtimeEnvelope.newBuilder() - .setEventId(eventId(server, PUNISHMENT_TYPES_TIMESTAMP_FIELD, timestamp)) - .setPunishmentTypeInvalidated(PunishmentTypeInvalidatedEvent.newBuilder()) - .build() - )); + RealtimeEnvelope.Builder envelope = RealtimeEnvelope.newBuilder() + .setEventId(eventId(server, timestampField, timestamp)); + payloadSetter.accept(envelope); + realtimeEventDispatcher.publish(new RealtimeOutboundEvent(server.getId(), topic, envelope.build())); } catch (Exception e) { - log.warn("Failed to dispatch realtime punishment type invalidation", e); + log.warn(failureMessage, e); } } diff --git a/src/main/java/gg/modl/backend/settings/config/CustomDomainReconciliationProperties.java b/src/main/java/gg/modl/backend/settings/config/CustomDomainReconciliationProperties.java new file mode 100644 index 0000000..4d324eb --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/config/CustomDomainReconciliationProperties.java @@ -0,0 +1,20 @@ +package gg.modl.backend.settings.config; + +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "modl.custom-domain.reconciliation") +public class CustomDomainReconciliationProperties { + private boolean enabled = true; + + private boolean orphanGarbageCollection = true; + + @Min(60_000) + private long intervalMs = 21_600_000L; +} diff --git a/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeController.java b/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeController.java index d2aca2b..bac30ef 100644 --- a/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeController.java +++ b/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeController.java @@ -1,33 +1,33 @@ -package gg.modl.backend.settings.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeService; -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.MINECRAFT_PUNISHMENTS) -@RequiredArgsConstructor -public class MinecraftPunishmentTypeController { - private final PunishmentTypeService punishmentTypeService; - - @GetMapping("/types") - public ResponseEntity> getPunishmentTypes(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List types = punishmentTypeService.getPunishmentTypes(server); - - return ResponseEntity.ok(Map.of( - "status", 200, - "data", types - )); - } -} +package gg.modl.backend.settings.controller; + +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeService; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.MINECRAFT_PUNISHMENTS) +@RequiredArgsConstructor +public class MinecraftPunishmentTypeController { + private final PunishmentTypeService punishmentTypeService; + + @GetMapping("/types") + public ResponseEntity> getPunishmentTypes(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List types = punishmentTypeService.getPunishmentTypes(server); + + return ResponseEntity.ok(Map.of( + "status", 200, + "data", types + )); + } +} diff --git a/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeProtoMapper.java b/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeProtoMapper.java index 8db2477..d390847 100644 --- a/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeProtoMapper.java +++ b/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeProtoMapper.java @@ -1,5 +1,8 @@ package gg.modl.backend.settings.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.intValue; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; + import com.google.protobuf.Struct; import com.google.protobuf.Value; import gg.modl.backend.settings.data.DurationDetail; @@ -113,12 +116,4 @@ private static void putNumber(Struct.Builder builder, String key, int value) { private static void putString(Struct.Builder builder, String key, String value) { builder.putFields(key, Value.newBuilder().setStringValue(stringValue(value)).build()); } - - private static int intValue(Integer value) { - return value == null ? 0 : value; - } - - private static String stringValue(String value) { - return value == null ? "" : value; - } } diff --git a/src/main/java/gg/modl/backend/settings/controller/PanelAiSuggestionController.java b/src/main/java/gg/modl/backend/settings/controller/PanelAiSuggestionController.java new file mode 100644 index 0000000..7d844b8 --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/controller/PanelAiSuggestionController.java @@ -0,0 +1,54 @@ +package gg.modl.backend.settings.controller; + +import gg.modl.backend.ai.service.AITicketAnalysisService; +import gg.modl.backend.infrastructure.authorization.PanelAccessRule; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.AISuggestionActionResponse; +import gg.modl.proto.modl.v1.ApplyAIPunishmentRequest; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_SETTINGS) +@RequiresPanelPermission(view = "admin.settings.view.punishments", modify = "admin.settings.modify.punishments") +@RequiredArgsConstructor +public class PanelAiSuggestionController { + private final AITicketAnalysisService aiTicketAnalysisService; + + @PostMapping("/ai-apply-punishment/{ticketId}") + @RequiresPanelPermission(rule = PanelAccessRule.PERMIT_ALL) + public AISuggestionActionResponse applyAIPunishment( + @PathVariable String ticketId, + @RequestBody ApplyAIPunishmentRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String email = RequestUtil.getSessionEmail(request); + + AITicketAnalysisService.AISuggestionResult result = aiTicketAnalysisService.applyAISuggestion(server, ticketId, email); + return toAISuggestionResponse(result); + } + + @PostMapping("/ai-dismiss-suggestion/{ticketId}") + public AISuggestionActionResponse dismissAISuggestion( + @PathVariable String ticketId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + AITicketAnalysisService.AISuggestionResult result = aiTicketAnalysisService.dismissAISuggestion(server, ticketId); + return toAISuggestionResponse(result); + } + + private AISuggestionActionResponse toAISuggestionResponse(AITicketAnalysisService.AISuggestionResult result) { + return PanelSettingsProtoMapper.toAISuggestionActionResponse(result.success(), result.error(), null); + } +} diff --git a/src/main/java/gg/modl/backend/settings/controller/PanelApiKeyController.java b/src/main/java/gg/modl/backend/settings/controller/PanelApiKeyController.java new file mode 100644 index 0000000..ca2e04c --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/controller/PanelApiKeyController.java @@ -0,0 +1,95 @@ +package gg.modl.backend.settings.controller; + +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.service.ApiKeySettingsService; +import gg.modl.proto.modl.v1.ApiKeyDeleteResponse; +import gg.modl.proto.modl.v1.ApiKeyExistsResponse; +import gg.modl.proto.modl.v1.ApiKeyGenerateResponse; +import gg.modl.proto.modl.v1.ApiKeyRevealResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_SETTINGS) +@RequiresPanelPermission(view = "admin.settings.view", modify = "admin.settings.modify") +@RequiredArgsConstructor +public class PanelApiKeyController { + private final ApiKeySettingsService apiKeySettingsService; + private final PermissionService permissionService; + private final SettingsInvalidationPublisher settingsInvalidationPublisher; + + @PostMapping("/api-keys/{type}/generate") + public ApiKeyGenerateResponse generateApiKey( + @PathVariable String type, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + String apiKey = apiKeySettingsService.generateApiKey(server, type); + settingsInvalidationPublisher.invalidateSettings(server); + return PanelSettingsProtoMapper.toApiKeyGenerateResponse("API key generated successfully", apiKey); + } + + @GetMapping("/api-keys/{type}/reveal") + public ResponseEntity revealApiKey( + @PathVariable String type, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + String apiKey = apiKeySettingsService.revealApiKey(server, type); + + if (apiKey == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(PanelSettingsProtoMapper.toApiKeyRevealResponse(apiKey)); + } + + @DeleteMapping("/api-keys/{type}") + public ResponseEntity deleteApiKey( + @PathVariable String type, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + boolean deleted = apiKeySettingsService.deleteApiKey(server, type); + + if (!deleted) { + return ResponseEntity.notFound().build(); + } + + settingsInvalidationPublisher.invalidateSettings(server); + return ResponseEntity.ok(PanelSettingsProtoMapper.toApiKeyDeleteResponse("API key deleted successfully")); + } + + @GetMapping("/api-keys/{type}/exists") + public ApiKeyExistsResponse checkApiKeyExists( + @PathVariable String type, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + boolean exists = apiKeySettingsService.hasApiKey(server, type); + return PanelSettingsProtoMapper.toApiKeyExistsResponse(exists); + } + + private void requireSuperAdmin(Server server, HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (!permissionService.isSuperAdmin(server, email)) { + throw new ForbiddenException("Only super admins can manage API keys"); + } + } +} diff --git a/src/main/java/gg/modl/backend/settings/controller/PanelDomainSettingsController.java b/src/main/java/gg/modl/backend/settings/controller/PanelDomainSettingsController.java index 43b5d38..cf11d9c 100644 --- a/src/main/java/gg/modl/backend/settings/controller/PanelDomainSettingsController.java +++ b/src/main/java/gg/modl/backend/settings/controller/PanelDomainSettingsController.java @@ -1,98 +1,94 @@ -package gg.modl.backend.settings.controller; - -import gg.modl.backend.infrastructure.exception.ForbiddenException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.DomainSettings; -import gg.modl.backend.settings.service.CustomDomainAccessService; -import gg.modl.backend.settings.service.DomainSettingsService; -import gg.modl.proto.modl.v1.ConfigureDomainRequest; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.RemoveDomainResponse; -import gg.modl.proto.modl.v1.VerifyDomainRequest; -import gg.modl.proto.modl.v1.VerifyDomainResponse; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_SETTINGS + "/domain") -@RequiredArgsConstructor -public class PanelDomainSettingsController { - private final DomainSettingsService domainSettingsService; - private final CustomDomainAccessService customDomainAccessService; - private final RealtimeEventPublisher realtimeEventPublisher; - - @GetMapping - public gg.modl.proto.modl.v1.DomainSettings getDomainSettings(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - String host = request.getHeader("Host"); - DomainSettings settings = domainSettingsService.getDomainSettings(server, host); - return PanelSettingsProtoMapper.toDomainSettings(settings); - } - - @PostMapping - public gg.modl.proto.modl.v1.DomainSettings configureDomain( - @RequestBody ConfigureDomainRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireCustomDomainWriteAccess(server); - - DomainSettings settings = domainSettingsService.configureDomain(server, body.getCustomDomain().trim()); - invalidateSettings(server); - return PanelSettingsProtoMapper.toDomainSettings(settings); - } - - private void requireCustomDomainWriteAccess(Server server) { - if (!customDomainAccessService.canManageCustomDomain(server)) { - throw new ForbiddenException("Custom domains require Premium unless your server is grandfathered."); - } - } - - @PostMapping("/verify") - public VerifyDomainResponse verifyDomain( - @RequestBody VerifyDomainRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireCustomDomainWriteAccess(server); - - DomainSettings settings = domainSettingsService.verifyDomain(server, body.getDomain().trim()); - DomainSettings.DomainStatus status = settings.getStatus(); - - String message = switch (status.getStatus()) { - case "active" -> status.getSslStatus().equals("active") - ? "Domain verified successfully with active SSL!" - : "Domain verified! SSL certificate is being provisioned."; - case "error" -> status.getError() != null - ? status.getError() - : "Domain verification failed"; - default -> "Domain verification pending. Please ensure your CNAME is configured correctly."; - }; - - invalidateSettings(server); - return PanelSettingsProtoMapper.toVerifyDomainResponse(settings, message); - } - - @DeleteMapping - public RemoveDomainResponse removeDomain(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - requireCustomDomainWriteAccess(server); - - domainSettingsService.removeDomain(server); - invalidateSettings(server); - return PanelSettingsProtoMapper.toRemoveDomainResponse("Domain removed successfully"); - } - - private void invalidateSettings(Server server) { - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_SETTINGS); - } -} +package gg.modl.backend.settings.controller; + +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.DomainSettings; +import gg.modl.backend.settings.service.CustomDomainAccessService; +import gg.modl.backend.settings.service.DomainSettingsService; +import gg.modl.proto.modl.v1.ConfigureDomainRequest; +import gg.modl.proto.modl.v1.RemoveDomainResponse; +import gg.modl.proto.modl.v1.VerifyDomainRequest; +import gg.modl.proto.modl.v1.VerifyDomainResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_SETTINGS + "/domain") +@RequiresPanelPermission(view = "admin.settings.view.domain", modify = "admin.settings.modify.domain") +@RequiredArgsConstructor +public class PanelDomainSettingsController { + private final DomainSettingsService domainSettingsService; + private final CustomDomainAccessService customDomainAccessService; + private final SettingsInvalidationPublisher settingsInvalidationPublisher; + + @GetMapping + public gg.modl.proto.modl.v1.DomainSettings getDomainSettings(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + String host = request.getHeader("Host"); + DomainSettings settings = domainSettingsService.getDomainSettings(server, host); + return PanelSettingsProtoMapper.toDomainSettings(settings); + } + + @PostMapping + public gg.modl.proto.modl.v1.DomainSettings configureDomain( + @RequestBody ConfigureDomainRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireCustomDomainWriteAccess(server); + + DomainSettings settings = domainSettingsService.configureDomain(server, body.getCustomDomain().trim()); + settingsInvalidationPublisher.invalidateSettings(server); + return PanelSettingsProtoMapper.toDomainSettings(settings); + } + + private void requireCustomDomainWriteAccess(Server server) { + if (!customDomainAccessService.canManageCustomDomain(server)) { + throw new ForbiddenException("Custom domains require Premium unless your server is grandfathered."); + } + } + + @PostMapping("/verify") + public VerifyDomainResponse verifyDomain( + @RequestBody VerifyDomainRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireCustomDomainWriteAccess(server); + + DomainSettings settings = domainSettingsService.verifyDomain(server, body.getDomain().trim()); + DomainSettings.DomainStatus status = settings.getStatus(); + + String message = switch (status.getStatus()) { + case "active" -> status.getSslStatus().equals("active") + ? "Domain verified successfully with active SSL!" + : "Domain verified! SSL certificate is being provisioned."; + case "error" -> status.getError() != null + ? status.getError() + : "Domain verification failed"; + default -> "Domain verification pending. Please ensure your CNAME is configured correctly."; + }; + + settingsInvalidationPublisher.invalidateSettings(server); + return PanelSettingsProtoMapper.toVerifyDomainResponse(settings, message); + } + + @DeleteMapping + public RemoveDomainResponse removeDomain(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + requireCustomDomainWriteAccess(server); + + domainSettingsService.removeDomain(server); + settingsInvalidationPublisher.invalidateSettings(server); + return PanelSettingsProtoMapper.toRemoveDomainResponse("Domain removed successfully"); + } +} diff --git a/src/main/java/gg/modl/backend/settings/controller/PanelPunishmentTypeController.java b/src/main/java/gg/modl/backend/settings/controller/PanelPunishmentTypeController.java index 719f916..d03a41d 100644 --- a/src/main/java/gg/modl/backend/settings/controller/PanelPunishmentTypeController.java +++ b/src/main/java/gg/modl/backend/settings/controller/PanelPunishmentTypeController.java @@ -1,137 +1,132 @@ -package gg.modl.backend.settings.controller; - -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeService; -import gg.modl.proto.modl.v1.PanelPunishmentTypesResponse; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.PunishmentTypeRequest; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.Validator; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_SETTINGS + "/punishment-types") -@RequiredArgsConstructor -public class PanelPunishmentTypeController { - private final PunishmentTypeService punishmentTypeService; - private final RealtimeEventPublisher realtimeEventPublisher; - private final PermissionService permissionService; - private final Validator validator; - - @GetMapping - public PanelPunishmentTypesResponse getPunishmentTypes(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List types = punishmentTypeService.getPunishmentTypes(server); - return PanelSettingsProtoMapper.toPunishmentTypesResponse(types); - } - - @GetMapping("/{ordinal}") - public ResponseEntity getPunishmentType( - @PathVariable int ordinal, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return punishmentTypeService.getPunishmentTypeByOrdinal(server, ordinal) - .map(PanelSettingsProtoMapper::toPunishmentType) - .map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); - } - - @PatchMapping("/{ordinal}") - public gg.modl.proto.modl.v1.PunishmentType updatePunishmentType( - @PathVariable int ordinal, - @RequestBody PunishmentTypeRequest requestBody, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - PunishmentType updatedType = PanelSettingsProtoMapper.fromPunishmentTypeRequest(requestBody); - validate(updatedType); - - String previousName = punishmentTypeService.getPunishmentTypeByOrdinal(server, ordinal) - .map(PunishmentType::getName) - .orElse(null); - - PunishmentType result = punishmentTypeService.updatePunishmentType(server, ordinal, updatedType); - - if (previousName != null && !previousName.equals(result.getName())) { - permissionService.renamePunishmentApplyPermission(server, previousName, result.getName()); - } - - invalidatePunishmentTypes(server, ordinal); - return PanelSettingsProtoMapper.toPunishmentType(result); - } - - @PostMapping - public gg.modl.proto.modl.v1.PunishmentType createPunishmentType( - @RequestBody PunishmentTypeRequest requestBody, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - PunishmentType newType = PanelSettingsProtoMapper.fromPunishmentTypeRequest(requestBody); - validate(newType); - PunishmentType created = punishmentTypeService.createPunishmentType(server, newType); - invalidatePunishmentTypes(server, created.getOrdinal()); - return PanelSettingsProtoMapper.toPunishmentType(created); - } - - @PostMapping("/reset") - public PanelPunishmentTypesResponse resetPunishmentTypes(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List types = punishmentTypeService.initializeDefaultTypes(server); - invalidatePunishmentTypes(server, null); - return PanelSettingsProtoMapper.toPunishmentTypesResponse(types); - } - - @DeleteMapping("/{ordinal}") - public ResponseEntity deletePunishmentType( - @PathVariable int ordinal, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - if (ordinal < 6) { - throw new ValidationException("Cannot delete core administrative punishment types"); - } - - boolean deleted = punishmentTypeService.deletePunishmentType(server, ordinal); - if (!deleted) { - return ResponseEntity.notFound().build(); - } - invalidatePunishmentTypes(server, ordinal); - return ResponseEntity.ok(Map.of("message", "Punishment type deleted successfully")); - } - - private void invalidatePunishmentTypes(Server server, Integer ordinal) { - realtimeEventPublisher.invalidatePanel( - server, - PanelResource.PANEL_RESOURCE_PUNISHMENT_TYPES, - ordinal != null ? String.valueOf(ordinal) : null - ); - } - - private void validate(T target) { - Set> violations = validator.validate(target); - if (!violations.isEmpty()) { - throw new ValidationException(violations.iterator().next().getMessage()); - } - } -} +package gg.modl.backend.settings.controller; + +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.validation.BeanValidationRunner; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.PunishmentCategory; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.proto.modl.v1.PanelPunishmentTypesResponse; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.PunishmentTypeRequest; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_SETTINGS + "/punishment-types") +@RequiresPanelPermission(view = "admin.settings.view.punishments", modify = "admin.settings.modify.punishments") +@RequiredArgsConstructor +public class PanelPunishmentTypeController { + private final PunishmentTypeService punishmentTypeService; + private final RealtimeEventPublisher realtimeEventPublisher; + private final PermissionService permissionService; + private final BeanValidationRunner validationRunner; + + @GetMapping + public PanelPunishmentTypesResponse getPunishmentTypes(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List types = punishmentTypeService.getPunishmentTypes(server); + return PanelSettingsProtoMapper.toPunishmentTypesResponse(types); + } + + @GetMapping("/{ordinal}") + public ResponseEntity getPunishmentType( + @PathVariable int ordinal, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return punishmentTypeService.getPunishmentTypeByOrdinal(server, ordinal) + .map(PanelSettingsProtoMapper::toPunishmentType) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PatchMapping("/{ordinal}") + public gg.modl.proto.modl.v1.PunishmentType updatePunishmentType( + @PathVariable int ordinal, + @RequestBody PunishmentTypeRequest requestBody, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + PunishmentType updatedType = PanelSettingsProtoMapper.fromPunishmentTypeRequest(requestBody); + validationRunner.validate(updatedType); + + String previousName = punishmentTypeService.getPunishmentTypeByOrdinal(server, ordinal) + .map(PunishmentType::getName) + .orElse(null); + + PunishmentType result = punishmentTypeService.updatePunishmentType(server, ordinal, updatedType); + + if (previousName != null && !previousName.equals(result.getName())) { + permissionService.renamePunishmentApplyPermission(server, previousName, result.getName()); + } + + invalidatePunishmentTypes(server, ordinal); + return PanelSettingsProtoMapper.toPunishmentType(result); + } + + @PostMapping + public gg.modl.proto.modl.v1.PunishmentType createPunishmentType( + @RequestBody PunishmentTypeRequest requestBody, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + PunishmentType newType = PanelSettingsProtoMapper.fromPunishmentTypeRequest(requestBody); + validationRunner.validate(newType); + PunishmentType created = punishmentTypeService.createPunishmentType(server, newType); + invalidatePunishmentTypes(server, created.getOrdinal()); + return PanelSettingsProtoMapper.toPunishmentType(created); + } + + @PostMapping("/reset") + public PanelPunishmentTypesResponse resetPunishmentTypes(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List types = punishmentTypeService.initializeDefaultTypes(server); + invalidatePunishmentTypes(server, null); + return PanelSettingsProtoMapper.toPunishmentTypesResponse(types); + } + + @DeleteMapping("/{ordinal}") + public ResponseEntity deletePunishmentType( + @PathVariable int ordinal, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + if (ordinal <= PunishmentCategory.MAX_CORE_ORDINAL) { + throw new ValidationException("Cannot delete core administrative punishment types"); + } + + boolean deleted = punishmentTypeService.deletePunishmentType(server, ordinal); + if (!deleted) { + return ResponseEntity.notFound().build(); + } + invalidatePunishmentTypes(server, ordinal); + return ResponseEntity.ok(Map.of("message", "Punishment type deleted successfully")); + } + + private void invalidatePunishmentTypes(Server server, Integer ordinal) { + realtimeEventPublisher.invalidatePanel( + server, + PanelResource.PANEL_RESOURCE_PUNISHMENT_TYPES, + ordinal != null ? String.valueOf(ordinal) : null + ); + } + +} diff --git a/src/main/java/gg/modl/backend/settings/controller/PanelSettingsController.java b/src/main/java/gg/modl/backend/settings/controller/PanelSettingsController.java index d9acc3a..c0947f3 100644 --- a/src/main/java/gg/modl/backend/settings/controller/PanelSettingsController.java +++ b/src/main/java/gg/modl/backend/settings/controller/PanelSettingsController.java @@ -1,12 +1,10 @@ package gg.modl.backend.settings.controller; -import gg.modl.backend.ai.service.AITicketAnalysisService; -import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.validation.BeanValidationRunner; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.role.service.PermissionService; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.AIModerationSettings; import gg.modl.backend.settings.data.GeneralSettings; @@ -19,7 +17,6 @@ import gg.modl.backend.settings.dto.request.PatchReplayRetentionSettingsRequest; import gg.modl.backend.settings.dto.request.UpdateQuickResponsesRequest; import gg.modl.backend.settings.service.AIModerationSettingsService; -import gg.modl.backend.settings.service.ApiKeySettingsService; import gg.modl.backend.settings.service.GeneralSettingsService; import gg.modl.backend.settings.service.IconUploadService; import gg.modl.backend.settings.service.OffenderThresholdSettingsService; @@ -29,14 +26,8 @@ import gg.modl.backend.settings.service.TicketLabelSettingsService; import gg.modl.backend.settings.service.VersionedSettings; import gg.modl.backend.settings.service.WebhookSettingsService; -import gg.modl.proto.modl.v1.AISuggestionActionResponse; -import gg.modl.proto.modl.v1.ApiKeyDeleteResponse; -import gg.modl.proto.modl.v1.ApiKeyExistsResponse; -import gg.modl.proto.modl.v1.ApiKeyGenerateResponse; -import gg.modl.proto.modl.v1.ApiKeyRevealResponse; import gg.modl.proto.modl.v1.GeneralSettingsEnvelope; import gg.modl.proto.modl.v1.OffenderThresholdSettingsEnvelope; -import gg.modl.proto.modl.v1.PanelResource; import gg.modl.proto.modl.v1.PatchGeneralSettingsRequest; import gg.modl.proto.modl.v1.PatchQuickResponsesRequest; import gg.modl.proto.modl.v1.PatchStatusThresholdSettingsRequest; @@ -51,13 +42,9 @@ import gg.modl.proto.modl.v1.UpdateWebhookSettingsRequest; import gg.modl.proto.modl.v1.WebhookTestResponse; import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.ConstraintViolation; import jakarta.validation.Valid; -import jakarta.validation.Validator; -import java.util.Set; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -70,22 +57,20 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_SETTINGS) +@RequiresPanelPermission(view = "admin.settings.view", modify = "admin.settings.modify") @RequiredArgsConstructor public class PanelSettingsController { private final GeneralSettingsService generalSettingsService; private final TicketLabelSettingsService ticketLabelSettingsService; - private final ApiKeySettingsService apiKeySettingsService; private final AIModerationSettingsService aiModerationSettingsService; private final WebhookSettingsService webhookSettingsService; private final TicketFormSettingsService ticketFormSettingsService; private final QuickResponseSettingsService quickResponseSettingsService; private final IconUploadService iconUploadService; - private final AITicketAnalysisService aiTicketAnalysisService; private final OffenderThresholdSettingsService offenderThresholdSettingsService; - private final PermissionService permissionService; private final ReplayRetentionSettingsService replayRetentionSettingsService; - private final RealtimeEventPublisher realtimeEventPublisher; - private final Validator validator; + private final SettingsInvalidationPublisher settingsInvalidationPublisher; + private final BeanValidationRunner validationRunner; @GetMapping("/general") public GeneralSettingsEnvelope getGeneralSettings(HttpServletRequest request) { @@ -104,7 +89,7 @@ public GeneralSettingsEnvelope patchGeneralSettings( body.getExpectedVersion(), PanelSettingsProtoMapper.fromPatchGeneralSettingsRequest(body) ); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toGeneralSettingsEnvelope(updated); } @@ -126,11 +111,12 @@ public TicketLabelSettingsEnvelope patchTicketLabelSettings( body.getExpectedVersion(), PanelSettingsProtoMapper.fromPatchTicketLabelSettingsRequest(body) ); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toTicketLabelSettingsEnvelope(updated); } @GetMapping("/status-thresholds") + @RequiresPanelPermission(view = "admin.settings.view.punishments", modify = "admin.settings.modify.punishments") public OffenderThresholdSettingsEnvelope getStatusThresholds(HttpServletRequest request) { Server server = RequestUtil.getRequestServer(request); return PanelSettingsProtoMapper.toOffenderThresholdSettingsEnvelope( @@ -138,6 +124,7 @@ public OffenderThresholdSettingsEnvelope getStatusThresholds(HttpServletRequest } @PatchMapping("/status-thresholds") + @RequiresPanelPermission(view = "admin.settings.view.punishments", modify = "admin.settings.modify.punishments") public OffenderThresholdSettingsEnvelope patchStatusThresholds( @RequestBody PatchStatusThresholdSettingsRequest body, HttpServletRequest request @@ -148,7 +135,7 @@ public OffenderThresholdSettingsEnvelope patchStatusThresholds( body.getExpectedVersion(), PanelSettingsProtoMapper.fromOffenderThresholdSettings(body.getSettings()) ); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toOffenderThresholdSettingsEnvelope(updated); } @@ -171,67 +158,12 @@ public ReplayRetentionSettingsEnvelope patchReplayRetentionSettings( body.enabled(), body.days() ); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toReplayRetentionSettingsEnvelope(updated); } - @PostMapping("/api-keys/{type}/generate") - public ApiKeyGenerateResponse generateApiKey( - @PathVariable String type, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - String apiKey = apiKeySettingsService.generateApiKey(server, type); - invalidateSettings(server); - return PanelSettingsProtoMapper.toApiKeyGenerateResponse("API key generated successfully", apiKey); - } - - @GetMapping("/api-keys/{type}/reveal") - public ResponseEntity revealApiKey( - @PathVariable String type, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - String apiKey = apiKeySettingsService.revealApiKey(server, type); - - if (apiKey == null) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.ok(PanelSettingsProtoMapper.toApiKeyRevealResponse(apiKey)); - } - - @DeleteMapping("/api-keys/{type}") - public ResponseEntity deleteApiKey( - @PathVariable String type, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - boolean deleted = apiKeySettingsService.deleteApiKey(server, type); - - if (!deleted) { - return ResponseEntity.notFound().build(); - } - - invalidateSettings(server); - return ResponseEntity.ok(PanelSettingsProtoMapper.toApiKeyDeleteResponse("API key deleted successfully")); - } - - @GetMapping("/api-keys/{type}/exists") - public ApiKeyExistsResponse checkApiKeyExists( - @PathVariable String type, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - boolean exists = apiKeySettingsService.hasApiKey(server, type); - return PanelSettingsProtoMapper.toApiKeyExistsResponse(exists); - } - @GetMapping("/ai-moderation") + @RequiresPanelPermission(view = "admin.settings.view.punishments", modify = "admin.settings.modify.punishments") public gg.modl.proto.modl.v1.AIModerationSettings getAIModerationSettings(HttpServletRequest request) { Server server = RequestUtil.getRequestServer(request); AIModerationSettings settings = aiModerationSettingsService.getAIModerationSettings(server); @@ -239,15 +171,16 @@ public gg.modl.proto.modl.v1.AIModerationSettings getAIModerationSettings(HttpSe } @PatchMapping("/ai-moderation") + @RequiresPanelPermission(view = "admin.settings.view.punishments", modify = "admin.settings.modify.punishments") public gg.modl.proto.modl.v1.AIModerationSettings updateAIModerationSettings( @RequestBody UpdateAIModerationSettingsRequest requestBody, HttpServletRequest request ) { Server server = RequestUtil.getRequestServer(request); AIModerationSettings settings = PanelSettingsProtoMapper.fromUpdateAIModerationSettingsRequest(requestBody); - validate(settings); + validationRunner.validate(settings); AIModerationSettings updated = aiModerationSettingsService.updateAIModerationSettings(server, settings); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toAIModerationSettings(updated); } @@ -266,7 +199,7 @@ public gg.modl.proto.modl.v1.WebhookSettings updateWebhookSettings( Server server = RequestUtil.getRequestServer(request); WebhookSettings settings = PanelSettingsProtoMapper.fromUpdateWebhookSettingsRequest(requestBody); WebhookSettings updated = webhookSettingsService.updateWebhookSettings(server, settings); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toWebhookSettings(updated); } @@ -299,7 +232,7 @@ public TicketFormSettingsEnvelope patchTicketFormSettings( body.getExpectedVersion(), PanelSettingsProtoMapper.fromTicketFormSettings(body.getSettings()) ); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toTicketFormSettingsEnvelope(updated); } @@ -333,13 +266,13 @@ public QuickResponseSettingsEnvelope patchQuickResponses( Server server = RequestUtil.getRequestServer(request); UpdateQuickResponsesRequest quickResponses = new UpdateQuickResponsesRequest( PanelSettingsProtoMapper.fromPatchQuickResponsesRequest(body)); - validate(quickResponses); + validationRunner.validate(quickResponses); VersionedSettings updated = quickResponseSettingsService.patchQuickResponseSettings( server, body.getExpectedVersion(), quickResponses ); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toQuickResponseSettingsEnvelope(updated); } @@ -352,55 +285,8 @@ public ResponseEntity uploadIcon( Server server = RequestUtil.getRequestServer(request); ResponseEntity response = iconUploadService.uploadIcon(server, file, iconType); if (response.getStatusCode().is2xxSuccessful()) { - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); } return response; } - - @PostMapping("/ai-apply-punishment/{ticketId}") - public AISuggestionActionResponse applyAIPunishment( - @PathVariable String ticketId, - @RequestBody gg.modl.proto.modl.v1.ApplyAIPunishmentRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String email = RequestUtil.getSessionEmail(request); - - AITicketAnalysisService.AISuggestionResult result = aiTicketAnalysisService.applyAISuggestion(server, ticketId, email); - return toAISuggestionResponse(result); - } - - @PostMapping("/ai-dismiss-suggestion/{ticketId}") - public AISuggestionActionResponse dismissAISuggestion( - @PathVariable String ticketId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - AITicketAnalysisService.AISuggestionResult result = aiTicketAnalysisService.dismissAISuggestion(server, ticketId); - return toAISuggestionResponse(result); - } - - private AISuggestionActionResponse toAISuggestionResponse(AITicketAnalysisService.AISuggestionResult result) { - return PanelSettingsProtoMapper.toAISuggestionActionResponse(result.success(), result.error(), null); - } - - private void invalidateSettings(Server server) { - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_SETTINGS); - } - - private void requireSuperAdmin(Server server, HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (!permissionService.isSuperAdmin(server, email)) { - throw new ForbiddenException("Only super admins can manage API keys"); - } - } - - private void validate(T target) { - Set> violations = validator.validate(target); - if (!violations.isEmpty()) { - throw new ValidationException(violations.iterator().next().getMessage()); - } - } - } diff --git a/src/main/java/gg/modl/backend/settings/controller/PanelSettingsProtoMapper.java b/src/main/java/gg/modl/backend/settings/controller/PanelSettingsProtoMapper.java index efb6a2a..2c01aa1 100644 --- a/src/main/java/gg/modl/backend/settings/controller/PanelSettingsProtoMapper.java +++ b/src/main/java/gg/modl/backend/settings/controller/PanelSettingsProtoMapper.java @@ -1,7 +1,5 @@ package gg.modl.backend.settings.controller; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.booleanValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.intValue; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalBoolean; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalInt; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; @@ -44,8 +42,10 @@ import gg.modl.proto.modl.v1.TicketLabelSettingsEnvelope; import gg.modl.proto.modl.v1.VerifyDomainResponse; import gg.modl.proto.modl.v1.WebhookTestResponse; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; final class PanelSettingsProtoMapper { @@ -734,11 +734,11 @@ static AIModerationSettings fromUpdateAIModerationSettingsRequest( .enableAutomatedActions(request.getEnableAutomatedActions()); Map configs = request.getAiPunishmentConfigsMap().entrySet().stream() - .collect(java.util.stream.Collectors.toMap( + .collect(Collectors.toMap( Map.Entry::getKey, entry -> fromAIPunishmentConfigRequest(entry.getValue()), (left, right) -> right, - java.util.LinkedHashMap::new + LinkedHashMap::new )); return builder.aiPunishmentConfigs(configs).build(); } diff --git a/src/main/java/gg/modl/backend/settings/controller/PublicSettingsController.java b/src/main/java/gg/modl/backend/settings/controller/PublicSettingsController.java index d1cff29..30dd3fe 100644 --- a/src/main/java/gg/modl/backend/settings/controller/PublicSettingsController.java +++ b/src/main/java/gg/modl/backend/settings/controller/PublicSettingsController.java @@ -1,70 +1,70 @@ -package gg.modl.backend.settings.controller; - -import gg.modl.backend.admin.data.SystemConfig; -import gg.modl.backend.admin.service.GlobalSystemService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.GeneralSettings; -import gg.modl.backend.settings.data.SupportedLanguages; -import gg.modl.backend.settings.data.TicketFormSettings; -import gg.modl.backend.settings.service.GeneralSettingsService; -import gg.modl.backend.settings.service.TicketFormSettingsService; -import gg.modl.proto.modl.v1.PublicSettingsResponse; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.util.StringUtils; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PUBLIC_SETTINGS) -@RequiredArgsConstructor -public class PublicSettingsController { - private final GeneralSettingsService generalSettingsService; - private final TicketFormSettingsService ticketFormSettingsService; - private final GlobalSystemService globalSystemService; - - @GetMapping - public PublicSettingsResponse getPublicSettings(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - - if (server == null) { - return getNotFoundSettings(); - } - - GeneralSettings generalSettings = generalSettingsService.getGeneralSettings(server); - TicketFormSettings ticketForms = ticketFormSettingsService.getTicketFormSettings(server); - SystemConfig.GeneralConfig globalConfig = getGlobalMaintenanceConfig(); - return PanelSettingsProtoMapper.toPublicSettingsResponse(new gg.modl.backend.settings.dto.response.PublicSettingsResponse( - true, - StringUtils.hasText(generalSettings.getServerDisplayName()) ? generalSettings.getServerDisplayName() : "modl", - generalSettings.getPanelIconUrl(), - generalSettings.getHomepageIconUrl(), - ticketFormSettingsService.buildTicketFormsResponse(ticketForms), - globalConfig.isMaintenanceMode(), - globalConfig.getMaintenanceMessage(), - generalSettings.getDefaultLanguage() - )); - } - - private PublicSettingsResponse getNotFoundSettings() { - SystemConfig.GeneralConfig globalConfig = getGlobalMaintenanceConfig(); - return PanelSettingsProtoMapper.toPublicSettingsResponse(new gg.modl.backend.settings.dto.response.PublicSettingsResponse( - false, - null, - null, - null, - Map.of(), - globalConfig.isMaintenanceMode(), - globalConfig.getMaintenanceMessage(), - SupportedLanguages.DEFAULT - )); - } - - private SystemConfig.GeneralConfig getGlobalMaintenanceConfig() { - return globalSystemService.getGeneralConfigOrDefault(); - } -} +package gg.modl.backend.settings.controller; + +import gg.modl.backend.admin.data.SystemConfig; +import gg.modl.backend.admin.service.GlobalSystemService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.GeneralSettings; +import gg.modl.backend.settings.data.SupportedLanguages; +import gg.modl.backend.settings.data.TicketFormSettings; +import gg.modl.backend.settings.service.GeneralSettingsService; +import gg.modl.backend.settings.service.TicketFormSettingsService; +import gg.modl.proto.modl.v1.PublicSettingsResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PUBLIC_SETTINGS) +@RequiredArgsConstructor +public class PublicSettingsController { + private final GeneralSettingsService generalSettingsService; + private final TicketFormSettingsService ticketFormSettingsService; + private final GlobalSystemService globalSystemService; + + @GetMapping + public PublicSettingsResponse getPublicSettings(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + + if (server == null) { + return getNotFoundSettings(); + } + + GeneralSettings generalSettings = generalSettingsService.getGeneralSettings(server); + TicketFormSettings ticketForms = ticketFormSettingsService.getTicketFormSettings(server); + SystemConfig.GeneralConfig globalConfig = getGlobalMaintenanceConfig(); + return PanelSettingsProtoMapper.toPublicSettingsResponse(new gg.modl.backend.settings.dto.response.PublicSettingsResponse( + true, + StringUtils.hasText(generalSettings.getServerDisplayName()) ? generalSettings.getServerDisplayName() : "modl", + generalSettings.getPanelIconUrl(), + generalSettings.getHomepageIconUrl(), + ticketFormSettingsService.buildTicketFormsResponse(ticketForms), + globalConfig.isMaintenanceMode(), + globalConfig.getMaintenanceMessage(), + generalSettings.getDefaultLanguage() + )); + } + + private PublicSettingsResponse getNotFoundSettings() { + SystemConfig.GeneralConfig globalConfig = getGlobalMaintenanceConfig(); + return PanelSettingsProtoMapper.toPublicSettingsResponse(new gg.modl.backend.settings.dto.response.PublicSettingsResponse( + false, + null, + null, + null, + Map.of(), + globalConfig.isMaintenanceMode(), + globalConfig.getMaintenanceMessage(), + SupportedLanguages.DEFAULT + )); + } + + private SystemConfig.GeneralConfig getGlobalMaintenanceConfig() { + return globalSystemService.getGeneralConfigOrDefault(); + } +} diff --git a/src/main/java/gg/modl/backend/settings/controller/SettingsInvalidationPublisher.java b/src/main/java/gg/modl/backend/settings/controller/SettingsInvalidationPublisher.java new file mode 100644 index 0000000..1ebbf53 --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/controller/SettingsInvalidationPublisher.java @@ -0,0 +1,17 @@ +package gg.modl.backend.settings.controller; + +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.PanelResource; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +class SettingsInvalidationPublisher { + private final RealtimeEventPublisher realtimeEventPublisher; + + void invalidateSettings(Server server) { + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_SETTINGS); + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/AIModerationSettings.java b/src/main/java/gg/modl/backend/settings/data/AIModerationSettings.java index cbe1be3..43c70a4 100644 --- a/src/main/java/gg/modl/backend/settings/data/AIModerationSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/AIModerationSettings.java @@ -1,41 +1,41 @@ -package gg.modl.backend.settings.data; - -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import java.util.HashMap; -import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AIModerationSettings { - private boolean enableAIReview; - private boolean enableAutomatedActions; - @Builder.Default - @Size(max = RequestValidationLimits.AI_PUNISHMENT_CONFIGS_MAX_ENTRIES) - private Map aiPunishmentConfigs = new HashMap<>(); - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class AIPunishmentConfig { - @NotBlank - @Size(max = RequestValidationLimits.AI_PUNISHMENT_ID_MAX_LENGTH) - private String id; - @NotBlank - @Size(max = RequestValidationLimits.AI_PUNISHMENT_NAME_MAX_LENGTH) - private String name; - @NotBlank - @Size(max = RequestValidationLimits.AI_PUNISHMENT_DESCRIPTION_MAX_LENGTH) - private String aiDescription; - private boolean enabled; - } -} +package gg.modl.backend.settings.data; + +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.HashMap; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AIModerationSettings { + private boolean enableAIReview; + private boolean enableAutomatedActions; + @Builder.Default + @Size(max = RequestValidationLimits.AI_PUNISHMENT_CONFIGS_MAX_ENTRIES) + private Map aiPunishmentConfigs = new HashMap<>(); + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class AIPunishmentConfig { + @NotBlank + @Size(max = RequestValidationLimits.AI_PUNISHMENT_ID_MAX_LENGTH) + private String id; + @NotBlank + @Size(max = RequestValidationLimits.AI_PUNISHMENT_NAME_MAX_LENGTH) + private String name; + @NotBlank + @Size(max = RequestValidationLimits.AI_PUNISHMENT_DESCRIPTION_MAX_LENGTH) + private String aiDescription; + private boolean enabled; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/AppealForm.java b/src/main/java/gg/modl/backend/settings/data/AppealForm.java index 1f27153..be148b0 100644 --- a/src/main/java/gg/modl/backend/settings/data/AppealForm.java +++ b/src/main/java/gg/modl/backend/settings/data/AppealForm.java @@ -1,21 +1,21 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.ArrayList; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class AppealForm { - @Builder.Default - private List fields = new ArrayList<>(); - @Builder.Default - private List sections = new ArrayList<>(); -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class AppealForm { + @Builder.Default + private List fields = new ArrayList<>(); + @Builder.Default + private List sections = new ArrayList<>(); +} diff --git a/src/main/java/gg/modl/backend/settings/data/AppealFormField.java b/src/main/java/gg/modl/backend/settings/data/AppealFormField.java index 2126bcf..1a361ba 100644 --- a/src/main/java/gg/modl/backend/settings/data/AppealFormField.java +++ b/src/main/java/gg/modl/backend/settings/data/AppealFormField.java @@ -1,22 +1,22 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class AppealFormField { - private String id; - private String type; - private String label; - private String description; - private Boolean required; - private Integer order; - private String sectionId; -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class AppealFormField { + private String id; + private String type; + private String label; + private String description; + private Boolean required; + private Integer order; + private String sectionId; +} diff --git a/src/main/java/gg/modl/backend/settings/data/AppealFormSection.java b/src/main/java/gg/modl/backend/settings/data/AppealFormSection.java index 0416ea8..90aa15e 100644 --- a/src/main/java/gg/modl/backend/settings/data/AppealFormSection.java +++ b/src/main/java/gg/modl/backend/settings/data/AppealFormSection.java @@ -1,19 +1,19 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class AppealFormSection { - private String id; - private String title; - private String description; - private Integer order; -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class AppealFormSection { + private String id; + private String title; + private String description; + private Integer order; +} diff --git a/src/main/java/gg/modl/backend/settings/data/DefaultPunishmentTypes.java b/src/main/java/gg/modl/backend/settings/data/DefaultPunishmentTypes.java index 9afca6f..c12acde 100644 --- a/src/main/java/gg/modl/backend/settings/data/DefaultPunishmentTypes.java +++ b/src/main/java/gg/modl/backend/settings/data/DefaultPunishmentTypes.java @@ -1,541 +1,541 @@ -package gg.modl.backend.settings.data; - -import java.util.ArrayList; -import java.util.List; - -public final class DefaultPunishmentTypes { - private DefaultPunishmentTypes() {} - - public static List getAll() { - List types = new ArrayList<>(); - types.addAll(getAdministrativeTypes()); - types.addAll(getSocialTypes()); - types.addAll(getGameplayTypes()); - return types; - } - - public static List getAdministrativeTypes() { - return List.of( - createKick(), - createManualMute(), - createManualBan(), - createSecurityBan(), - createLinkedBan(), - createBlacklist() - ); - } - - private static PunishmentType createKick() { - return PunishmentType.builder() - .id(0) - .name("Kick") - .category("Administrative") - .customizable(false) - .ordinal(0) - .staffDescription("Kick a player.") - .playerDescription("BOOT!") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(false) - .build(); - } - - private static PunishmentType createManualMute() { - return PunishmentType.builder() - .id(1) - .name("Manual Mute") - .category("Administrative") - .customizable(false) - .ordinal(1) - .staffDescription("Manually mute a player.") - .playerDescription("You have been silenced.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static AppealForm createDefaultAppealForm() { - return AppealForm.builder() - .sections(List.of( - AppealFormSection.builder() - .id("appeal_reason_section") - .title("Appeal Information") - .description("Explain why you believe this punishment should be amended") - .order(0) - .build() - )) - .fields(List.of( - AppealFormField.builder() - .id("why") - .type("textarea") - .label("Why should this punishment be amended?") - .description("Please provide context and any relevant information to support your appeal") - .required(true) - .order(1) - .sectionId("appeal_reason_section") - .build() - )) - .build(); - } - - private static PunishmentType createManualBan() { - return PunishmentType.builder() - .id(2) - .name("Manual Ban") - .category("Administrative") - .customizable(false) - .ordinal(2) - .staffDescription("Manually ban a player.") - .playerDescription("The ban hammer has spoken.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createSecurityBan() { - return PunishmentType.builder() - .id(3) - .name("Security Ban") - .category("Administrative") - .customizable(false) - .ordinal(3) - .staffDescription("Compromised or potentially compromised account.") - .playerDescription("Suspicious activity has been detected on your account. Please secure your account and appeal this ban.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createLinkedBan() { - return PunishmentType.builder() - .id(4) - .name("Linked Ban") - .category("Administrative") - .customizable(false) - .ordinal(4) - .staffDescription("Usually automatically applied due to ban evasion.") - .playerDescription("Evading bans through the use of alternate accounts or sharing your account is strictly prohibited.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createBlacklist() { - return PunishmentType.builder() - .id(5) - .name("Blacklist") - .category("Administrative") - .customizable(false) - .ordinal(5) - .staffDescription("Remove a player (unappealable).") - .playerDescription("You are blacklisted from the server.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(false) - .build(); - } - - public static List getSocialTypes() { - return List.of( - createChatAbuse(), - createAntiSocial(), - createTargeting(), - createBadContent(), - createBadUsername(), - createBadSkin() - ); - } - - private static PunishmentType createChatAbuse() { - return PunishmentType.builder() - .id(8) - .name("Chat Abuse") - .category("Social") - .customizable(true) - .ordinal(6) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(6, "hours", "mute"), - new DurationDetail(1, "days", "mute"), - new DurationDetail(3, "days", "mute") - ), - new OffenseLevelDurations( - new DurationDetail(1, "days", "mute"), - new DurationDetail(3, "days", "mute"), - new DurationDetail(7, "days", "mute") - ), - new OffenseLevelDurations( - new DurationDetail(3, "days", "mute"), - new DurationDetail(7, "days", "mute"), - new DurationDetail(14, "days", "mute") - ) - )) - .points(new PunishmentPoints(1, 1, 2)) - .staffDescription("Inappropriate language, excessive caps, or disruptive chat behavior.") - .playerDescription("Public chat channels are reserved for decent messages.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createAntiSocial() { - return PunishmentType.builder() - .id(9) - .name("Anti Social") - .category("Social") - .customizable(true) - .ordinal(7) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(3, "days", "mute"), - new DurationDetail(7, "days", "mute"), - new DurationDetail(14, "days", "mute") - ), - new OffenseLevelDurations( - new DurationDetail(7, "days", "mute"), - new DurationDetail(30, "days", "mute"), - new DurationDetail(90, "days", "mute") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "mute"), - new DurationDetail(90, "days", "mute"), - new DurationDetail(180, "days", "mute") - ) - )) - .points(new PunishmentPoints(2, 3, 4)) - .staffDescription("Hostile, toxic, or antisocial behavior that creates a negative environment.") - .playerDescription("Anti-social and disruptive behavior is strictly prohibited from public channels.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createTargeting() { - return PunishmentType.builder() - .id(10) - .name("Targeting") - .category("Social") - .customizable(true) - .ordinal(8) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(7, "days", "ban"), - new DurationDetail(14, "days", "ban"), - new DurationDetail(30, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "ban"), - new DurationDetail(90, "days", "ban"), - new DurationDetail(180, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(90, "days", "ban"), - new DurationDetail(180, "days", "ban"), - new DurationDetail(365, "days", "ban") - ) - )) - .points(new PunishmentPoints(4, 6, 10)) - .staffDescription("Persistent harassment, bullying, or targeting of specific players with malicious intent.") - .playerDescription("This server has a zero tolerance policy on targeting individuals.") - .canBeAltBlocking(true) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createBadContent() { - return PunishmentType.builder() - .id(11) - .name("Bad Content") - .category("Social") - .customizable(true) - .ordinal(9) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(1, "days", "ban"), - new DurationDetail(7, "days", "ban"), - new DurationDetail(14, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(7, "days", "ban"), - new DurationDetail(14, "days", "ban"), - new DurationDetail(30, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "ban"), - new DurationDetail(60, "days", "ban"), - new DurationDetail(90, "days", "ban") - ) - )) - .points(new PunishmentPoints(3, 4, 5)) - .staffDescription("Inappropriate content including sexual references, doxxing, links to harmful sites.") - .playerDescription("Sharing inappropriate content of any kind is strictly prohibited.") - .canBeAltBlocking(true) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createBadUsername() { - return PunishmentType.builder() - .id(18) - .name("Bad Username") - .category("Social") - .customizable(true) - .ordinal(10) - .permanentUntilUsernameChange(true) - .staffDescription("Username violates server guidelines (inappropriate, offensive, or misleading).") - .playerDescription("Your username violates our community guidelines. Please change your username to something appropriate to continue playing.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createBadSkin() { - return PunishmentType.builder() - .id(19) - .name("Bad Skin") - .category("Social") - .customizable(true) - .ordinal(11) - .permanentUntilSkinChange(true) - .staffDescription("Player skin violates server guidelines (inappropriate, offensive, or misleading).") - .playerDescription("Your Minecraft skin violates our community guidelines. Please change your skin to something appropriate to continue playing.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - public static List getGameplayTypes() { - return List.of( - createTeamAbuse(), - createGameAbuse(), - createCheating(), - createGameTrading(), - createAccountAbuse(), - createSystemsAbuse() - ); - } - - private static PunishmentType createTeamAbuse() { - return PunishmentType.builder() - .id(12) - .name("Team Abuse") - .category("Gameplay") - .customizable(true) - .ordinal(12) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(6, "hours", "ban"), - new DurationDetail(12, "hours", "ban"), - new DurationDetail(3, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(12, "hours", "ban"), - new DurationDetail(3, "days", "ban"), - new DurationDetail(7, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(3, "days", "ban"), - new DurationDetail(7, "days", "ban"), - new DurationDetail(14, "days", "ban") - ) - )) - .points(new PunishmentPoints(2, 2, 3)) - .staffDescription("Intentionally harming teammates, cross-teaming, or aiding cheaters.") - .playerDescription("Please be considerate to fellow players by not team-griefing, aiding cheaters, or cross-teaming.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createGameAbuse() { - return PunishmentType.builder() - .id(13) - .name("Game Abuse") - .category("Gameplay") - .customizable(true) - .ordinal(13) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(1, "days", "ban"), - new DurationDetail(3, "days", "ban"), - new DurationDetail(7, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(7, "days", "ban"), - new DurationDetail(14, "days", "ban"), - new DurationDetail(14, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "ban"), - new DurationDetail(30, "days", "ban"), - new DurationDetail(90, "days", "ban") - ) - )) - .points(new PunishmentPoints(2, 3, 5)) - .staffDescription("Violating game specific rules for fair play.") - .playerDescription("Violating game specific rules for competitive fair-play.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createCheating() { - return PunishmentType.builder() - .id(14) - .name("Cheating") - .category("Gameplay") - .customizable(true) - .ordinal(14) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(3, "days", "ban"), - new DurationDetail(14, "days", "ban"), - new DurationDetail(30, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(14, "days", "ban"), - new DurationDetail(60, "days", "ban"), - new DurationDetail(180, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "ban"), - new DurationDetail(90, "days", "ban"), - new DurationDetail(0, "days", "permanent ban") - ) - )) - .points(new PunishmentPoints(5, 7, 9)) - .staffDescription("Using hacks, mods, exploits, or other software to gain an unfair advantage.") - .playerDescription("Cheating through the use of client-side modifications or game exploits is strictly prohibited.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createGameTrading() { - return PunishmentType.builder() - .id(15) - .name("Game Trading") - .category("Gameplay") - .customizable(true) - .ordinal(15) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(14, "days", "ban"), - new DurationDetail(30, "days", "ban"), - new DurationDetail(60, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "ban"), - new DurationDetail(90, "days", "ban"), - new DurationDetail(180, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(0, "days", "permanent ban"), - new DurationDetail(0, "days", "permanent ban"), - new DurationDetail(0, "days", "permanent ban") - ) - )) - .points(new PunishmentPoints(4, 6, 10)) - .staffDescription("Trading or selling in-game items, content, or services on unauthorized third-party platforms.") - .playerDescription("Trading or selling in-game items on unauthorized third-party platforms is strictly prohibited.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createAccountAbuse() { - return PunishmentType.builder() - .id(16) - .name("Account Abuse") - .category("Gameplay") - .customizable(true) - .ordinal(16) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(14, "days", "ban"), - new DurationDetail(30, "days", "ban"), - new DurationDetail(60, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "ban"), - new DurationDetail(90, "days", "ban"), - new DurationDetail(180, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(0, "days", "permanent ban"), - new DurationDetail(0, "days", "permanent ban"), - new DurationDetail(0, "days", "permanent ban") - ) - )) - .points(new PunishmentPoints(4, 6, 10)) - .staffDescription("Account sharing, alt-account boosting, selling/trading accounts.") - .playerDescription("Misuse of accounts for financial or levelling gain is prohibited.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createSystemsAbuse() { - return PunishmentType.builder() - .id(17) - .name("Systems Abuse") - .category("Gameplay") - .customizable(true) - .ordinal(17) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(3, "days", "ban"), - new DurationDetail(7, "days", "ban"), - new DurationDetail(14, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(14, "days", "ban"), - new DurationDetail(30, "days", "ban"), - new DurationDetail(90, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(90, "days", "ban"), - new DurationDetail(180, "days", "ban"), - new DurationDetail(365, "days", "ban") - ) - )) - .points(new PunishmentPoints(2, 3, 5)) - .staffDescription("Abusing server functions by opening redundant tickets, creating lag machines, etc.") - .playerDescription("Using server systems in an unintended and harmful way is strictly prohibited.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } -} +package gg.modl.backend.settings.data; + +import java.util.ArrayList; +import java.util.List; + +public final class DefaultPunishmentTypes { + private DefaultPunishmentTypes() {} + + public static List getAll() { + List types = new ArrayList<>(); + types.addAll(getAdministrativeTypes()); + types.addAll(getSocialTypes()); + types.addAll(getGameplayTypes()); + return types; + } + + public static List getAdministrativeTypes() { + return List.of( + createKick(), + createManualMute(), + createManualBan(), + createSecurityBan(), + createLinkedBan(), + createBlacklist() + ); + } + + private static PunishmentType createKick() { + return PunishmentType.builder() + .id(0) + .name("Kick") + .category("Administrative") + .customizable(false) + .ordinal(0) + .staffDescription("Kick a player.") + .playerDescription("BOOT!") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(false) + .build(); + } + + private static PunishmentType createManualMute() { + return PunishmentType.builder() + .id(1) + .name("Manual Mute") + .category("Administrative") + .customizable(false) + .ordinal(1) + .staffDescription("Manually mute a player.") + .playerDescription("You have been silenced.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static AppealForm createDefaultAppealForm() { + return AppealForm.builder() + .sections(List.of( + AppealFormSection.builder() + .id("appeal_reason_section") + .title("Appeal Information") + .description("Explain why you believe this punishment should be amended") + .order(0) + .build() + )) + .fields(List.of( + AppealFormField.builder() + .id("why") + .type("textarea") + .label("Why should this punishment be amended?") + .description("Please provide context and any relevant information to support your appeal") + .required(true) + .order(1) + .sectionId("appeal_reason_section") + .build() + )) + .build(); + } + + private static PunishmentType createManualBan() { + return PunishmentType.builder() + .id(2) + .name("Manual Ban") + .category("Administrative") + .customizable(false) + .ordinal(2) + .staffDescription("Manually ban a player.") + .playerDescription("The ban hammer has spoken.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createSecurityBan() { + return PunishmentType.builder() + .id(3) + .name("Security Ban") + .category("Administrative") + .customizable(false) + .ordinal(3) + .staffDescription("Compromised or potentially compromised account.") + .playerDescription("Suspicious activity has been detected on your account. Please secure your account and appeal this ban.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createLinkedBan() { + return PunishmentType.builder() + .id(4) + .name("Linked Ban") + .category("Administrative") + .customizable(false) + .ordinal(4) + .staffDescription("Usually automatically applied due to ban evasion.") + .playerDescription("Evading bans through the use of alternate accounts or sharing your account is strictly prohibited.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createBlacklist() { + return PunishmentType.builder() + .id(5) + .name("Blacklist") + .category("Administrative") + .customizable(false) + .ordinal(5) + .staffDescription("Remove a player (unappealable).") + .playerDescription("You are blacklisted from the server.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(false) + .build(); + } + + public static List getSocialTypes() { + return List.of( + createChatAbuse(), + createAntiSocial(), + createTargeting(), + createBadContent(), + createBadUsername(), + createBadSkin() + ); + } + + private static PunishmentType createChatAbuse() { + return PunishmentType.builder() + .id(8) + .name("Chat Abuse") + .category("Social") + .customizable(true) + .ordinal(6) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(6, "hours", "mute"), + new DurationDetail(1, "days", "mute"), + new DurationDetail(3, "days", "mute") + ), + new OffenseLevelDurations( + new DurationDetail(1, "days", "mute"), + new DurationDetail(3, "days", "mute"), + new DurationDetail(7, "days", "mute") + ), + new OffenseLevelDurations( + new DurationDetail(3, "days", "mute"), + new DurationDetail(7, "days", "mute"), + new DurationDetail(14, "days", "mute") + ) + )) + .points(new PunishmentPoints(1, 1, 2)) + .staffDescription("Inappropriate language, excessive caps, or disruptive chat behavior.") + .playerDescription("Public chat channels are reserved for decent messages.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createAntiSocial() { + return PunishmentType.builder() + .id(9) + .name("Anti Social") + .category("Social") + .customizable(true) + .ordinal(7) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(3, "days", "mute"), + new DurationDetail(7, "days", "mute"), + new DurationDetail(14, "days", "mute") + ), + new OffenseLevelDurations( + new DurationDetail(7, "days", "mute"), + new DurationDetail(30, "days", "mute"), + new DurationDetail(90, "days", "mute") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "mute"), + new DurationDetail(90, "days", "mute"), + new DurationDetail(180, "days", "mute") + ) + )) + .points(new PunishmentPoints(2, 3, 4)) + .staffDescription("Hostile, toxic, or antisocial behavior that creates a negative environment.") + .playerDescription("Anti-social and disruptive behavior is strictly prohibited from public channels.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createTargeting() { + return PunishmentType.builder() + .id(10) + .name("Targeting") + .category("Social") + .customizable(true) + .ordinal(8) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(7, "days", "ban"), + new DurationDetail(14, "days", "ban"), + new DurationDetail(30, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "ban"), + new DurationDetail(90, "days", "ban"), + new DurationDetail(180, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(90, "days", "ban"), + new DurationDetail(180, "days", "ban"), + new DurationDetail(365, "days", "ban") + ) + )) + .points(new PunishmentPoints(4, 6, 10)) + .staffDescription("Persistent harassment, bullying, or targeting of specific players with malicious intent.") + .playerDescription("This server has a zero tolerance policy on targeting individuals.") + .canBeAltBlocking(true) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createBadContent() { + return PunishmentType.builder() + .id(11) + .name("Bad Content") + .category("Social") + .customizable(true) + .ordinal(9) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(1, "days", "ban"), + new DurationDetail(7, "days", "ban"), + new DurationDetail(14, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(7, "days", "ban"), + new DurationDetail(14, "days", "ban"), + new DurationDetail(30, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "ban"), + new DurationDetail(60, "days", "ban"), + new DurationDetail(90, "days", "ban") + ) + )) + .points(new PunishmentPoints(3, 4, 5)) + .staffDescription("Inappropriate content including sexual references, doxxing, links to harmful sites.") + .playerDescription("Sharing inappropriate content of any kind is strictly prohibited.") + .canBeAltBlocking(true) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createBadUsername() { + return PunishmentType.builder() + .id(18) + .name("Bad Username") + .category("Social") + .customizable(true) + .ordinal(10) + .permanentUntilUsernameChange(true) + .staffDescription("Username violates server guidelines (inappropriate, offensive, or misleading).") + .playerDescription("Your username violates our community guidelines. Please change your username to something appropriate to continue playing.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createBadSkin() { + return PunishmentType.builder() + .id(19) + .name("Bad Skin") + .category("Social") + .customizable(true) + .ordinal(11) + .permanentUntilSkinChange(true) + .staffDescription("Player skin violates server guidelines (inappropriate, offensive, or misleading).") + .playerDescription("Your Minecraft skin violates our community guidelines. Please change your skin to something appropriate to continue playing.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + public static List getGameplayTypes() { + return List.of( + createTeamAbuse(), + createGameAbuse(), + createCheating(), + createGameTrading(), + createAccountAbuse(), + createSystemsAbuse() + ); + } + + private static PunishmentType createTeamAbuse() { + return PunishmentType.builder() + .id(12) + .name("Team Abuse") + .category("Gameplay") + .customizable(true) + .ordinal(12) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(6, "hours", "ban"), + new DurationDetail(12, "hours", "ban"), + new DurationDetail(3, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(12, "hours", "ban"), + new DurationDetail(3, "days", "ban"), + new DurationDetail(7, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(3, "days", "ban"), + new DurationDetail(7, "days", "ban"), + new DurationDetail(14, "days", "ban") + ) + )) + .points(new PunishmentPoints(2, 2, 3)) + .staffDescription("Intentionally harming teammates, cross-teaming, or aiding cheaters.") + .playerDescription("Please be considerate to fellow players by not team-griefing, aiding cheaters, or cross-teaming.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createGameAbuse() { + return PunishmentType.builder() + .id(13) + .name("Game Abuse") + .category("Gameplay") + .customizable(true) + .ordinal(13) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(1, "days", "ban"), + new DurationDetail(3, "days", "ban"), + new DurationDetail(7, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(7, "days", "ban"), + new DurationDetail(14, "days", "ban"), + new DurationDetail(14, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "ban"), + new DurationDetail(30, "days", "ban"), + new DurationDetail(90, "days", "ban") + ) + )) + .points(new PunishmentPoints(2, 3, 5)) + .staffDescription("Violating game specific rules for fair play.") + .playerDescription("Violating game specific rules for competitive fair-play.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createCheating() { + return PunishmentType.builder() + .id(14) + .name("Cheating") + .category("Gameplay") + .customizable(true) + .ordinal(14) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(3, "days", "ban"), + new DurationDetail(14, "days", "ban"), + new DurationDetail(30, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(14, "days", "ban"), + new DurationDetail(60, "days", "ban"), + new DurationDetail(180, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "ban"), + new DurationDetail(90, "days", "ban"), + new DurationDetail(0, "days", "permanent ban") + ) + )) + .points(new PunishmentPoints(5, 7, 9)) + .staffDescription("Using hacks, mods, exploits, or other software to gain an unfair advantage.") + .playerDescription("Cheating through the use of client-side modifications or game exploits is strictly prohibited.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createGameTrading() { + return PunishmentType.builder() + .id(15) + .name("Game Trading") + .category("Gameplay") + .customizable(true) + .ordinal(15) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(14, "days", "ban"), + new DurationDetail(30, "days", "ban"), + new DurationDetail(60, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "ban"), + new DurationDetail(90, "days", "ban"), + new DurationDetail(180, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(0, "days", "permanent ban"), + new DurationDetail(0, "days", "permanent ban"), + new DurationDetail(0, "days", "permanent ban") + ) + )) + .points(new PunishmentPoints(4, 6, 10)) + .staffDescription("Trading or selling in-game items, content, or services on unauthorized third-party platforms.") + .playerDescription("Trading or selling in-game items on unauthorized third-party platforms is strictly prohibited.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createAccountAbuse() { + return PunishmentType.builder() + .id(16) + .name("Account Abuse") + .category("Gameplay") + .customizable(true) + .ordinal(16) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(14, "days", "ban"), + new DurationDetail(30, "days", "ban"), + new DurationDetail(60, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "ban"), + new DurationDetail(90, "days", "ban"), + new DurationDetail(180, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(0, "days", "permanent ban"), + new DurationDetail(0, "days", "permanent ban"), + new DurationDetail(0, "days", "permanent ban") + ) + )) + .points(new PunishmentPoints(4, 6, 10)) + .staffDescription("Account sharing, alt-account boosting, selling/trading accounts.") + .playerDescription("Misuse of accounts for financial or levelling gain is prohibited.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createSystemsAbuse() { + return PunishmentType.builder() + .id(17) + .name("Systems Abuse") + .category("Gameplay") + .customizable(true) + .ordinal(17) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(3, "days", "ban"), + new DurationDetail(7, "days", "ban"), + new DurationDetail(14, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(14, "days", "ban"), + new DurationDetail(30, "days", "ban"), + new DurationDetail(90, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(90, "days", "ban"), + new DurationDetail(180, "days", "ban"), + new DurationDetail(365, "days", "ban") + ) + )) + .points(new PunishmentPoints(2, 3, 5)) + .staffDescription("Abusing server functions by opening redundant tickets, creating lag machines, etc.") + .playerDescription("Using server systems in an unintended and harmful way is strictly prohibited.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/DomainSettings.java b/src/main/java/gg/modl/backend/settings/data/DomainSettings.java index 33791b6..41b9fd2 100644 --- a/src/main/java/gg/modl/backend/settings/data/DomainSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/DomainSettings.java @@ -1,33 +1,33 @@ -package gg.modl.backend.settings.data; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +package gg.modl.backend.settings.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class DomainSettings { private String customDomain; private DomainStatus status; private boolean accessingFromCustomDomain; private String modlSubdomainUrl; private boolean canManageCustomDomain; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class DomainStatus { - private String domain; - @Builder.Default - private String status = "pending"; // pending, active, error, verifying - private boolean cnameConfigured; - @Builder.Default - private String sslStatus = "pending"; // pending, active, error - private String lastChecked; - private String error; - } -} + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class DomainStatus { + private String domain; + @Builder.Default + private String status = "pending"; + private boolean cnameConfigured; + @Builder.Default + private String sslStatus = "pending"; + private String lastChecked; + private String error; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/DurationDetail.java b/src/main/java/gg/modl/backend/settings/data/DurationDetail.java index 56d91b1..d00d58c 100644 --- a/src/main/java/gg/modl/backend/settings/data/DurationDetail.java +++ b/src/main/java/gg/modl/backend/settings/data/DurationDetail.java @@ -1,51 +1,49 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record DurationDetail( - @Min(MIN_DURATION_VALUE) - @Max(MAX_DURATION_VALUE) - int value, - String unit, - String type -) { - private static final long MIN_DURATION_VALUE = 0L; - private static final long MAX_DURATION_VALUE = 100_000_000L; - - public long toMilliseconds() { - // Handle permanent punishments - if (isPermanent()) { - return -1L; - } - - // Handle null or empty unit - shouldn't happen but be defensive - if (unit == null || unit.isEmpty()) { - return -1L; // Treat as permanent if unit is missing - } - - return switch (unit.toLowerCase()) { - case "seconds", "second" -> value * 1000L; - case "minutes", "minute" -> value * 60L * 1000L; - case "hours", "hour" -> value * 60L * 60L * 1000L; - case "days", "day" -> value * 24L * 60L * 60L * 1000L; - case "weeks", "week" -> value * 7L * 24L * 60L * 60L * 1000L; - case "months", "month" -> value * 30L * 24L * 60L * 60L * 1000L; - default -> -1L; // Treat unknown unit as permanent rather than instant - }; - } - - public boolean isPermanent() { - return "permanent ban".equals(type) || "permanent mute".equals(type); - } - - public boolean isBan() { - return "ban".equals(type) || "permanent ban".equals(type); - } - - public boolean isMute() { - return "mute".equals(type) || "permanent mute".equals(type); - } -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record DurationDetail( + @Min(MIN_DURATION_VALUE) + @Max(MAX_DURATION_VALUE) + int value, + String unit, + String type +) { + private static final long MIN_DURATION_VALUE = 0L; + private static final long MAX_DURATION_VALUE = 100_000_000L; + + public long toMilliseconds() { + if (isPermanent()) { + return -1L; + } + + if (unit == null || unit.isEmpty()) { + return -1L; + } + + return switch (unit.toLowerCase()) { + case "seconds", "second" -> value * 1000L; + case "minutes", "minute" -> value * 60L * 1000L; + case "hours", "hour" -> value * 60L * 60L * 1000L; + case "days", "day" -> value * 24L * 60L * 60L * 1000L; + case "weeks", "week" -> value * 7L * 24L * 60L * 60L * 1000L; + case "months", "month" -> value * 30L * 24L * 60L * 60L * 1000L; + default -> -1L; + }; + } + + public boolean isPermanent() { + return "permanent ban".equals(type) || "permanent mute".equals(type); + } + + public boolean isBan() { + return "ban".equals(type) || "permanent ban".equals(type); + } + + public boolean isMute() { + return "mute".equals(type) || "permanent mute".equals(type); + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/GeneralSettings.java b/src/main/java/gg/modl/backend/settings/data/GeneralSettings.java index 7c24eff..d6570c3 100644 --- a/src/main/java/gg/modl/backend/settings/data/GeneralSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/GeneralSettings.java @@ -1,5 +1,5 @@ -package gg.modl.backend.settings.data; - +package gg.modl.backend.settings.data; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; diff --git a/src/main/java/gg/modl/backend/settings/data/Label.java b/src/main/java/gg/modl/backend/settings/data/Label.java index 1574eed..a8174f3 100644 --- a/src/main/java/gg/modl/backend/settings/data/Label.java +++ b/src/main/java/gg/modl/backend/settings/data/Label.java @@ -1,17 +1,17 @@ -package gg.modl.backend.settings.data; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class Label { - private String id; // UUID - private String name; // e.g., "bug", "critical" - private String color; // Hex color e.g., "#d73a4a" - private String description; // Optional description -} +package gg.modl.backend.settings.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Label { + private String id; + private String name; + private String color; + private String description; +} diff --git a/src/main/java/gg/modl/backend/settings/data/OffenderThresholdSettings.java b/src/main/java/gg/modl/backend/settings/data/OffenderThresholdSettings.java index 0f07bc0..22b74f0 100644 --- a/src/main/java/gg/modl/backend/settings/data/OffenderThresholdSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/OffenderThresholdSettings.java @@ -1,134 +1,85 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * Configurable thresholds for offender levels based on points. - * Used to determine if a player is Low, Medium, or Habitual offender. - * Separate thresholds for Social and Gameplay categories. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class OffenderThresholdSettings { - /** - * Thresholds for social category punishments. - * Default: medium=4, habitual=8 - */ - @Builder.Default - private CategoryThresholds social = new CategoryThresholds(4, 8); - - /** - * Thresholds for gameplay category punishments. - * Default: medium=5, habitual=10 - */ - @Builder.Default - private CategoryThresholds gameplay = new CategoryThresholds(5, 10); - - public static OffenderThresholdSettings defaults() { - return OffenderThresholdSettings.builder().build(); - } - - /** - * Get the social offender level for display. - */ - public String getSocialOffenderLevel(int points) { - return social.getOffenderLevel(points); - } - - /** - * Get the gameplay offender level for display. - */ - public String getGameplayOffenderLevel(int points) { - return gameplay.getOffenderLevel(points); - } - - /** - * Get the internal offense level for a category (for duration lookup). - */ - public String getOffenseLevelInternal(int points, boolean isSocial) { - return isSocial ? social.getOffenseLevelInternal(points) : gameplay.getOffenseLevelInternal(points); - } - - @Data - @NoArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class CategoryThresholds { - /** - * Points threshold for Medium offender level. - * Players with points >= this value and < habitual are "Medium". - */ - private int medium; - - /** - * Points threshold for Habitual offender level. - * Players with points >= this value are "Habitual". - */ - private int habitual; - - /** - * Number of months after a punishment's effective expiry before its points stop counting. - * Default: 24 months (2 years). Permanent punishments always count. - */ - private int pointExpiryMonths = 24; - - public CategoryThresholds(int medium, int habitual) { - this.medium = medium; - this.habitual = habitual; - this.pointExpiryMonths = 24; - } - - public CategoryThresholds(int medium, int habitual, int pointExpiryMonths) { - this.medium = medium; - this.habitual = habitual; - this.pointExpiryMonths = pointExpiryMonths; - } - - /** - * Convert pointExpiryMonths to milliseconds for date comparison. - */ - @JsonIgnore - public long getPointExpiryMs() { - return (long) pointExpiryMonths * 30L * 24L * 60L * 60L * 1000L; - } - - /** - * Calculate the offender level based on points. - * - * @param points The player's offense points - * @return "Low", "Medium", or "Habitual" - */ - public String getOffenderLevel(int points) { - if (points >= habitual) { - return "Habitual"; - } else if (points >= medium) { - return "Medium"; - } else { - return "Low"; - } - } - - /** - * Get the internal offense level used for duration lookup. - * - * @param points The player's offense points - * @return "first", "medium", or "habitual" (for duration matrix lookup) - */ - public String getOffenseLevelInternal(int points) { - if (points >= habitual) { - return "habitual"; - } else if (points >= medium) { - return "medium"; - } else { - return "first"; - } - } - } -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class OffenderThresholdSettings { + @Builder.Default + private CategoryThresholds social = new CategoryThresholds(4, 8); + + @Builder.Default + private CategoryThresholds gameplay = new CategoryThresholds(5, 10); + + public static OffenderThresholdSettings defaults() { + return OffenderThresholdSettings.builder().build(); + } + + public String getSocialOffenderLevel(int points) { + return social.getOffenderLevel(points); + } + + public String getGameplayOffenderLevel(int points) { + return gameplay.getOffenderLevel(points); + } + + public String getOffenseLevelInternal(int points, boolean isSocial) { + return isSocial ? social.getOffenseLevelInternal(points) : gameplay.getOffenseLevelInternal(points); + } + + @Data + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CategoryThresholds { + private int medium; + + private int habitual; + + private int pointExpiryMonths = 24; + + public CategoryThresholds(int medium, int habitual) { + this.medium = medium; + this.habitual = habitual; + this.pointExpiryMonths = 24; + } + + public CategoryThresholds(int medium, int habitual, int pointExpiryMonths) { + this.medium = medium; + this.habitual = habitual; + this.pointExpiryMonths = pointExpiryMonths; + } + + @JsonIgnore + public long getPointExpiryMs() { + return (long) pointExpiryMonths * 30L * 24L * 60L * 60L * 1000L; + } + + public String getOffenderLevel(int points) { + if (points >= habitual) { + return "Habitual"; + } else if (points >= medium) { + return "Medium"; + } else { + return "Low"; + } + } + + public String getOffenseLevelInternal(int points) { + if (points >= habitual) { + return "habitual"; + } else if (points >= medium) { + return "medium"; + } else { + return "first"; + } + } + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/OffenseLevelDurations.java b/src/main/java/gg/modl/backend/settings/data/OffenseLevelDurations.java index 6f0223d..7410e7e 100644 --- a/src/main/java/gg/modl/backend/settings/data/OffenseLevelDurations.java +++ b/src/main/java/gg/modl/backend/settings/data/OffenseLevelDurations.java @@ -1,18 +1,18 @@ -package gg.modl.backend.settings.data; - -import jakarta.validation.Valid; - -public record OffenseLevelDurations( - @Valid DurationDetail first, - @Valid DurationDetail medium, - @Valid DurationDetail habitual -) { - public DurationDetail getForOffenseLevel(String offenseLevel) { - return switch (offenseLevel.toLowerCase()) { - case "first" -> first; - case "medium" -> medium; - case "habitual" -> habitual; - default -> first; - }; - } -} +package gg.modl.backend.settings.data; + +import jakarta.validation.Valid; + +public record OffenseLevelDurations( + @Valid DurationDetail first, + @Valid DurationDetail medium, + @Valid DurationDetail habitual +) { + public DurationDetail getForOffenseLevel(String offenseLevel) { + return switch (offenseLevel.toLowerCase()) { + case "first" -> first; + case "medium" -> medium; + case "habitual" -> habitual; + default -> first; + }; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/PunishmentCategory.java b/src/main/java/gg/modl/backend/settings/data/PunishmentCategory.java new file mode 100644 index 0000000..2ce1357 --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/data/PunishmentCategory.java @@ -0,0 +1,30 @@ +package gg.modl.backend.settings.data; + +import org.jetbrains.annotations.Nullable; + +public enum PunishmentCategory { + KICK, + MUTE, + BAN; + + public static final int MIN_CORE_ORDINAL = 0; + public static final int MAX_CORE_ORDINAL = 5; + + public static boolean isCoreOrdinal(@Nullable Integer ordinal) { + return ordinal != null && ordinal >= MIN_CORE_ORDINAL && ordinal <= MAX_CORE_ORDINAL; + } + + @Nullable + public static PunishmentCategory fromCoreOrdinal(@Nullable Integer ordinal) { + if (!isCoreOrdinal(ordinal)) { + return null; + } + if (ordinal == 0) { + return KICK; + } + if (ordinal == 1) { + return MUTE; + } + return BAN; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/PunishmentDurations.java b/src/main/java/gg/modl/backend/settings/data/PunishmentDurations.java index 7d49bf4..782f91e 100644 --- a/src/main/java/gg/modl/backend/settings/data/PunishmentDurations.java +++ b/src/main/java/gg/modl/backend/settings/data/PunishmentDurations.java @@ -1,18 +1,18 @@ -package gg.modl.backend.settings.data; - -import jakarta.validation.Valid; - -public record PunishmentDurations( - @Valid OffenseLevelDurations low, - @Valid OffenseLevelDurations regular, - @Valid OffenseLevelDurations severe -) { - public DurationDetail getDuration(String severity, String offenseLevel) { - OffenseLevelDurations severityDurations = getForSeverity(severity); - return severityDurations != null ? severityDurations.getForOffenseLevel(offenseLevel) : null; - } - - public OffenseLevelDurations getForSeverity(String severity) { - return SeverityLevel.select(severity, low, regular, severe); - } -} +package gg.modl.backend.settings.data; + +import jakarta.validation.Valid; + +public record PunishmentDurations( + @Valid OffenseLevelDurations low, + @Valid OffenseLevelDurations regular, + @Valid OffenseLevelDurations severe +) { + public DurationDetail getDuration(String severity, String offenseLevel) { + OffenseLevelDurations severityDurations = getForSeverity(severity); + return severityDurations != null ? severityDurations.getForOffenseLevel(offenseLevel) : null; + } + + public OffenseLevelDurations getForSeverity(String severity) { + return SeverityLevel.select(severity, low, regular, severe); + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/PunishmentPoints.java b/src/main/java/gg/modl/backend/settings/data/PunishmentPoints.java index 329b1c9..89da1a4 100644 --- a/src/main/java/gg/modl/backend/settings/data/PunishmentPoints.java +++ b/src/main/java/gg/modl/backend/settings/data/PunishmentPoints.java @@ -1,21 +1,21 @@ -package gg.modl.backend.settings.data; - -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; - -public record PunishmentPoints( - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - int low, - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - int regular, - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - int severe -) { - public int getForSeverity(String severity) { - return SeverityLevel.select(severity, low, regular, severe); - } -} +package gg.modl.backend.settings.data; + +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +public record PunishmentPoints( + @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) + @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) + int low, + @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) + @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) + int regular, + @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) + @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) + int severe +) { + public int getForSeverity(String severity) { + return SeverityLevel.select(severity, low, regular, severe); + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/PunishmentType.java b/src/main/java/gg/modl/backend/settings/data/PunishmentType.java index 39d0b6e..730aa5e 100644 --- a/src/main/java/gg/modl/backend/settings/data/PunishmentType.java +++ b/src/main/java/gg/modl/backend/settings/data/PunishmentType.java @@ -1,149 +1,151 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder(toBuilder = true) -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class PunishmentType { - private Integer id; - private String name; - private String category; - private Boolean customizable; - private Integer ordinal; - - @Valid - private PunishmentDurations durations; - - private Boolean singleSeverityPunishment; - @Valid - private OffenseLevelDurations singleSeverityDurations; - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - private Integer singleSeverityPoints; - - @Valid - private PunishmentPoints points; - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - private Integer customPoints; - - private String staffDescription; - private String playerDescription; - - private Boolean canBeAltBlocking; - private Boolean canBeStatWiping; - private Boolean appealable; - - private AppealForm appealForm; - - private Boolean permanentUntilSkinChange; - private Boolean permanentUntilUsernameChange; - - public boolean isSocial() { - return "Social".equalsIgnoreCase(category); - } - - public boolean isGameplay() { - return "Gameplay".equalsIgnoreCase(category); - } - - public boolean isAdministrative() { - return "Administrative".equalsIgnoreCase(category); - } - - public boolean isBan() { - // Core types (ordinals 0-5) are hardcoded - if (ordinal != null && ordinal >= 0 && ordinal <= 5) { - return ordinal >= 2; - } - return category != null && category.toLowerCase().contains("ban"); - } - - public boolean isMute() { - if (ordinal != null && ordinal >= 0 && ordinal <= 5) { - return ordinal == 1; - } - return category != null && category.toLowerCase().contains("mute"); - } - - public boolean isKick() { - if (ordinal != null && ordinal >= 0 && ordinal <= 5) { - return ordinal == 0; - } - return category != null && category.toLowerCase().contains("kick"); - } - - public int getPointsForSeverity(String severity) { - if (customPoints != null) { - return customPoints; - } - if (singleSeverityPoints != null) { - return singleSeverityPoints; - } - if (points != null) { - return points.getForSeverity(severity); - } - return 0; - } - - public long getDurationMillis(String severity, String offenseLevel) { - if (isSingleSeverityPunishment() && singleSeverityDurations != null) { - DurationDetail detail = singleSeverityDurations.getForOffenseLevel(offenseLevel); - return detail != null ? detail.toMilliseconds() : 0L; - } - if (durations != null) { - DurationDetail detail = durations.getDuration(severity, offenseLevel); - return detail != null ? detail.toMilliseconds() : 0L; - } - return 0L; - } - - public boolean isSingleSeverityPunishment() { - return singleSeverityPunishment != null && singleSeverityPunishment; - } - - public DurationDetail getDurationDetail(String severity, String offenseLevel) { - if (isSingleSeverityPunishment() && singleSeverityDurations != null) { - return singleSeverityDurations.getForOffenseLevel(offenseLevel); - } - if (durations != null) { - return durations.getDuration(severity, offenseLevel); - } - return null; - } - - public boolean isCustomizable() { - return customizable != null && customizable; - } - - public boolean isCanBeAltBlocking() { - return canBeAltBlocking != null && canBeAltBlocking; - } - - public boolean isCanBeStatWiping() { - return canBeStatWiping != null && canBeStatWiping; - } - - public boolean isAppealable() { - return appealable != null && appealable; - } - - public boolean isPermanentUntilSkinChange() { - return permanentUntilSkinChange != null && permanentUntilSkinChange; - } - - public boolean isPermanentUntilUsernameChange() { - return permanentUntilUsernameChange != null && permanentUntilUsernameChange; - } -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class PunishmentType { + private Integer id; + private String name; + private String category; + private Boolean customizable; + private Integer ordinal; + + @Valid + private PunishmentDurations durations; + + private Boolean singleSeverityPunishment; + @Valid + private OffenseLevelDurations singleSeverityDurations; + @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) + @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) + private Integer singleSeverityPoints; + + @Valid + private PunishmentPoints points; + @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) + @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) + private Integer customPoints; + + private String staffDescription; + private String playerDescription; + + private Boolean canBeAltBlocking; + private Boolean canBeStatWiping; + private Boolean appealable; + + private AppealForm appealForm; + + private Boolean permanentUntilSkinChange; + private Boolean permanentUntilUsernameChange; + + public boolean isSocial() { + return "Social".equalsIgnoreCase(category); + } + + public boolean isGameplay() { + return "Gameplay".equalsIgnoreCase(category); + } + + public boolean isAdministrative() { + return "Administrative".equalsIgnoreCase(category); + } + + public boolean isBan() { + PunishmentCategory coreCategory = PunishmentCategory.fromCoreOrdinal(ordinal); + if (coreCategory != null) { + return coreCategory == PunishmentCategory.BAN; + } + return category != null && category.toLowerCase().contains("ban"); + } + + public boolean isMute() { + PunishmentCategory coreCategory = PunishmentCategory.fromCoreOrdinal(ordinal); + if (coreCategory != null) { + return coreCategory == PunishmentCategory.MUTE; + } + return category != null && category.toLowerCase().contains("mute"); + } + + public boolean isKick() { + PunishmentCategory coreCategory = PunishmentCategory.fromCoreOrdinal(ordinal); + if (coreCategory != null) { + return coreCategory == PunishmentCategory.KICK; + } + return category != null && category.toLowerCase().contains("kick"); + } + + public int getPointsForSeverity(String severity) { + if (customPoints != null) { + return customPoints; + } + if (singleSeverityPoints != null) { + return singleSeverityPoints; + } + if (points != null) { + return points.getForSeverity(severity); + } + return 0; + } + + public long getDurationMillis(String severity, String offenseLevel) { + if (isSingleSeverityPunishment() && singleSeverityDurations != null) { + DurationDetail detail = singleSeverityDurations.getForOffenseLevel(offenseLevel); + return detail != null ? detail.toMilliseconds() : 0L; + } + if (durations != null) { + DurationDetail detail = durations.getDuration(severity, offenseLevel); + return detail != null ? detail.toMilliseconds() : 0L; + } + return 0L; + } + + public boolean isSingleSeverityPunishment() { + return singleSeverityPunishment != null && singleSeverityPunishment; + } + + public DurationDetail getDurationDetail(String severity, String offenseLevel) { + if (isSingleSeverityPunishment() && singleSeverityDurations != null) { + return singleSeverityDurations.getForOffenseLevel(offenseLevel); + } + if (durations != null) { + return durations.getDuration(severity, offenseLevel); + } + return null; + } + + public boolean isCustomizable() { + return customizable != null && customizable; + } + + public boolean isCanBeAltBlocking() { + return canBeAltBlocking != null && canBeAltBlocking; + } + + public boolean isCanBeStatWiping() { + return canBeStatWiping != null && canBeStatWiping; + } + + public boolean isAppealable() { + return appealable != null && appealable; + } + + public boolean isPermanentUntilSkinChange() { + return permanentUntilSkinChange != null && permanentUntilSkinChange; + } + + public boolean isPermanentUntilUsernameChange() { + return permanentUntilUsernameChange != null && permanentUntilUsernameChange; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/QuickResponseSettings.java b/src/main/java/gg/modl/backend/settings/data/QuickResponseSettings.java index 357f385..ad5383f 100644 --- a/src/main/java/gg/modl/backend/settings/data/QuickResponseSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/QuickResponseSettings.java @@ -1,65 +1,65 @@ -package gg.modl.backend.settings.data; - -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import java.util.ArrayList; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class QuickResponseSettings { - public static final int MAX_CATEGORIES = 100; - private static final int MAX_ACTIONS_PER_CATEGORY = 100; - private static final int MAX_TICKET_TYPES = 50; - private static final int NAME_MAX_LENGTH = 128; - private static final int MESSAGE_MAX_LENGTH = 10_000; - - @Builder.Default - private List categories = new ArrayList<>(); - - @Data - @NoArgsConstructor - @AllArgsConstructor - @Builder - public static class Category { - @Size(max = RequestValidationLimits.QUICK_RESPONSE_CATEGORY_ID_MAX_LENGTH) - private String id; - @Size(max = NAME_MAX_LENGTH) - private String name; - @Builder.Default - @Size(max = MAX_TICKET_TYPES) - private List ticketTypes = new ArrayList<>(); - @Builder.Default - @Size(max = MAX_ACTIONS_PER_CATEGORY) - @Valid - private List actions = new ArrayList<>(); - private Integer order; - } - - @Data - @NoArgsConstructor - @AllArgsConstructor - @Builder - public static class Action { - @Size(max = RequestValidationLimits.QUICK_RESPONSE_ACTION_ID_MAX_LENGTH) - private String id; - @Size(max = NAME_MAX_LENGTH) - private String name; - @NotBlank - @Size(max = MESSAGE_MAX_LENGTH) - private String message; - private Integer order; - private Boolean closeTicket; - private Boolean showPunishment; - @Size(max = RequestValidationLimits.QUICK_RESPONSE_APPEAL_ACTION_MAX_LENGTH) - private String appealAction; - } -} +package gg.modl.backend.settings.data; + +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class QuickResponseSettings { + public static final int MAX_CATEGORIES = 100; + private static final int MAX_ACTIONS_PER_CATEGORY = 100; + private static final int MAX_TICKET_TYPES = 50; + private static final int NAME_MAX_LENGTH = 128; + private static final int MESSAGE_MAX_LENGTH = 10_000; + + @Builder.Default + private List categories = new ArrayList<>(); + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Category { + @Size(max = RequestValidationLimits.QUICK_RESPONSE_CATEGORY_ID_MAX_LENGTH) + private String id; + @Size(max = NAME_MAX_LENGTH) + private String name; + @Builder.Default + @Size(max = MAX_TICKET_TYPES) + private List ticketTypes = new ArrayList<>(); + @Builder.Default + @Size(max = MAX_ACTIONS_PER_CATEGORY) + @Valid + private List actions = new ArrayList<>(); + private Integer order; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Action { + @Size(max = RequestValidationLimits.QUICK_RESPONSE_ACTION_ID_MAX_LENGTH) + private String id; + @Size(max = NAME_MAX_LENGTH) + private String name; + @NotBlank + @Size(max = MESSAGE_MAX_LENGTH) + private String message; + private Integer order; + private Boolean closeTicket; + private Boolean showPunishment; + @Size(max = RequestValidationLimits.QUICK_RESPONSE_APPEAL_ACTION_MAX_LENGTH) + private String appealAction; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/TicketFormSettings.java b/src/main/java/gg/modl/backend/settings/data/TicketFormSettings.java index 1af18dd..82f4c13 100644 --- a/src/main/java/gg/modl/backend/settings/data/TicketFormSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/TicketFormSettings.java @@ -1,78 +1,78 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class TicketFormSettings { - private TicketForm bug; - private TicketForm support; - private TicketForm application; - private TicketForm player; - private TicketForm chat; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class TicketForm { - @Builder.Default - private boolean requireEmail = false; - @Builder.Default - private boolean requireEmailAuth = false; - private Boolean allowEmailNotifications; - @Builder.Default - private List fields = new ArrayList<>(); - @Builder.Default - private List sections = new ArrayList<>(); - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class FormField { - private String id; - private String type; - private String label; - private String description; - private boolean required; - @Builder.Default - private List options = new ArrayList<>(); - private int order; - private String sectionId; - private String goToSection; - @Builder.Default - private Map optionSectionMapping = new HashMap<>(); - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class FormSection { - private String id; - private String title; - private String description; - private int order; - private String showIfFieldId; - private String showIfValue; - @Builder.Default - private List showIfValues = new ArrayList<>(); - private boolean hideByDefault; - } -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class TicketFormSettings { + private TicketForm bug; + private TicketForm support; + private TicketForm application; + private TicketForm player; + private TicketForm chat; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TicketForm { + @Builder.Default + private boolean requireEmail = false; + @Builder.Default + private boolean requireEmailAuth = false; + private Boolean allowEmailNotifications; + @Builder.Default + private List fields = new ArrayList<>(); + @Builder.Default + private List sections = new ArrayList<>(); + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class FormField { + private String id; + private String type; + private String label; + private String description; + private boolean required; + @Builder.Default + private List options = new ArrayList<>(); + private int order; + private String sectionId; + private String goToSection; + @Builder.Default + private Map optionSectionMapping = new HashMap<>(); + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class FormSection { + private String id; + private String title; + private String description; + private int order; + private String showIfFieldId; + private String showIfValue; + @Builder.Default + private List showIfValues = new ArrayList<>(); + private boolean hideByDefault; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/WebhookSettings.java b/src/main/java/gg/modl/backend/settings/data/WebhookSettings.java index 9f3135c..7adfe39 100644 --- a/src/main/java/gg/modl/backend/settings/data/WebhookSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/WebhookSettings.java @@ -1,70 +1,70 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.ArrayList; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class WebhookSettings { - private String discordWebhookUrl; - private String discordAdminRoleId; - private String botName; - private String avatarUrl; - private boolean enabled; - private NotificationSettings notifications; - private EmbedTemplates embedTemplates; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class NotificationSettings { - private boolean newTickets; - private boolean newPunishments; - private boolean auditLogs; - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class EmbedTemplates { - private EmbedTemplate newTickets; - private EmbedTemplate newPunishments; - private EmbedTemplate auditLogs; - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class EmbedTemplate { - private String title; - private String description; - private String color; - @Builder.Default - private List fields = new ArrayList<>(); - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class EmbedField { - private String name; - private String value; - private boolean inline; - } -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class WebhookSettings { + private String discordWebhookUrl; + private String discordAdminRoleId; + private String botName; + private String avatarUrl; + private boolean enabled; + private NotificationSettings notifications; + private EmbedTemplates embedTemplates; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class NotificationSettings { + private boolean newTickets; + private boolean newPunishments; + private boolean auditLogs; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class EmbedTemplates { + private EmbedTemplate newTickets; + private EmbedTemplate newPunishments; + private EmbedTemplate auditLogs; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class EmbedTemplate { + private String title; + private String description; + private String color; + @Builder.Default + private List fields = new ArrayList<>(); + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class EmbedField { + private String name; + private String value; + private boolean inline; + } +} diff --git a/src/main/java/gg/modl/backend/settings/dto/request/PunishmentTypeRequest.java b/src/main/java/gg/modl/backend/settings/dto/request/PunishmentTypeRequest.java deleted file mode 100644 index bff8833..0000000 --- a/src/main/java/gg/modl/backend/settings/dto/request/PunishmentTypeRequest.java +++ /dev/null @@ -1,72 +0,0 @@ -package gg.modl.backend.settings.dto.request; - -import gg.modl.backend.settings.data.AppealForm; -import gg.modl.backend.settings.data.OffenseLevelDurations; -import gg.modl.backend.settings.data.PunishmentDurations; -import gg.modl.backend.settings.data.PunishmentPoints; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; - -public record PunishmentTypeRequest( - @NotBlank - @Size( - min = RequestValidationLimits.PUNISHMENT_TYPE_NAME_MIN_LENGTH, - max = RequestValidationLimits.PUNISHMENT_TYPE_NAME_MAX_LENGTH - ) - String name, - @NotBlank - @Pattern(regexp = "(?i)Administrative|Social|Gameplay", message = "must be one of: Administrative, Social, Gameplay") - @Size(max = RequestValidationLimits.PUNISHMENT_CATEGORY_MAX_LENGTH) - String category, - @Valid - PunishmentDurations durations, - @Valid - OffenseLevelDurations singleSeverityDurations, - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - Integer singleSeverityPoints, - @Valid - PunishmentPoints points, - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - Integer customPoints, - @Size(max = RequestValidationLimits.PUNISHMENT_STAFF_DESCRIPTION_MAX_LENGTH) - String staffDescription, - @Size(max = RequestValidationLimits.PUNISHMENT_DESCRIPTION_MAX_LENGTH) - String playerDescription, - Boolean singleSeverityPunishment, - Boolean canBeAltBlocking, - Boolean canBeStatWiping, - Boolean appealable, - @Valid - AppealForm appealForm, - Boolean permanentUntilSkinChange, - Boolean permanentUntilUsernameChange -) { - public PunishmentType toPunishmentType() { - PunishmentType punishmentType = new PunishmentType(); - punishmentType.setName(name); - punishmentType.setCategory(category); - punishmentType.setDurations(durations); - punishmentType.setSingleSeverityDurations(singleSeverityDurations); - punishmentType.setSingleSeverityPoints(singleSeverityPoints); - punishmentType.setPoints(points); - punishmentType.setCustomPoints(customPoints); - punishmentType.setStaffDescription(staffDescription); - punishmentType.setPlayerDescription(playerDescription); - punishmentType.setSingleSeverityPunishment(singleSeverityPunishment); - punishmentType.setCanBeAltBlocking(canBeAltBlocking); - punishmentType.setCanBeStatWiping(canBeStatWiping); - punishmentType.setAppealable(appealable); - punishmentType.setAppealForm(appealForm); - punishmentType.setPermanentUntilSkinChange(permanentUntilSkinChange); - punishmentType.setPermanentUntilUsernameChange(permanentUntilUsernameChange); - return punishmentType; - } -} diff --git a/src/main/java/gg/modl/backend/settings/dto/request/UpdateWebhookSettingsRequest.java b/src/main/java/gg/modl/backend/settings/dto/request/UpdateWebhookSettingsRequest.java deleted file mode 100644 index 106e414..0000000 --- a/src/main/java/gg/modl/backend/settings/dto/request/UpdateWebhookSettingsRequest.java +++ /dev/null @@ -1,100 +0,0 @@ -package gg.modl.backend.settings.dto.request; - -import gg.modl.backend.settings.data.WebhookSettings; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import java.util.List; - -public record UpdateWebhookSettingsRequest( - @Size(max = RequestValidationLimits.WEBHOOK_URL_MAX_LENGTH) - String discordWebhookUrl, - @Size(max = RequestValidationLimits.DISCORD_ADMIN_ROLE_ID_MAX_LENGTH) - String discordAdminRoleId, - @Size(max = RequestValidationLimits.DISCORD_BOT_NAME_MAX_LENGTH) - String botName, - @Size(max = RequestValidationLimits.WEBHOOK_URL_MAX_LENGTH) - String avatarUrl, - Boolean enabled, - @Valid NotificationSettingsRequest notifications, - @Valid EmbedTemplatesRequest embedTemplates -) { - public WebhookSettings toSettings() { - return WebhookSettings.builder() - .discordWebhookUrl(discordWebhookUrl) - .discordAdminRoleId(discordAdminRoleId) - .botName(botName) - .avatarUrl(avatarUrl) - .enabled(Boolean.TRUE.equals(enabled)) - .notifications(notifications != null ? notifications.toSettings() : null) - .embedTemplates(embedTemplates != null ? embedTemplates.toSettings() : null) - .build(); - } - - public record NotificationSettingsRequest( - Boolean newTickets, - Boolean newPunishments, - Boolean auditLogs - ) { - public WebhookSettings.NotificationSettings toSettings() { - return WebhookSettings.NotificationSettings.builder() - .newTickets(Boolean.TRUE.equals(newTickets)) - .newPunishments(Boolean.TRUE.equals(newPunishments)) - .auditLogs(Boolean.TRUE.equals(auditLogs)) - .build(); - } - } - - public record EmbedTemplatesRequest( - @Valid EmbedTemplateRequest newTickets, - @Valid EmbedTemplateRequest newPunishments, - @Valid EmbedTemplateRequest auditLogs - ) { - public WebhookSettings.EmbedTemplates toSettings() { - return WebhookSettings.EmbedTemplates.builder() - .newTickets(newTickets != null ? newTickets.toSettings() : null) - .newPunishments(newPunishments != null ? newPunishments.toSettings() : null) - .auditLogs(auditLogs != null ? auditLogs.toSettings() : null) - .build(); - } - } - - public record EmbedTemplateRequest( - @Size(max = RequestValidationLimits.EMBED_TITLE_MAX_LENGTH) - String title, - @Size(max = RequestValidationLimits.EMBED_DESCRIPTION_MAX_LENGTH) - String description, - @Size(max = RequestValidationLimits.EMBED_COLOR_MAX_LENGTH) - String color, - @Size(max = RequestValidationLimits.EMBED_FIELDS_MAX_ENTRIES) - List<@Valid EmbedFieldRequest> fields - ) { - public WebhookSettings.EmbedTemplate toSettings() { - return WebhookSettings.EmbedTemplate.builder() - .title(title) - .description(description) - .color(color) - .fields(fields != null ? fields.stream().map(EmbedFieldRequest::toSettings).toList() : null) - .build(); - } - } - - public record EmbedFieldRequest( - @NotBlank - @Size(max = RequestValidationLimits.EMBED_FIELD_NAME_MAX_LENGTH) - String name, - @NotBlank - @Size(max = RequestValidationLimits.EMBED_FIELD_VALUE_MAX_LENGTH) - String value, - Boolean inline - ) { - public WebhookSettings.EmbedField toSettings() { - return WebhookSettings.EmbedField.builder() - .name(name) - .value(value) - .inline(Boolean.TRUE.equals(inline)) - .build(); - } - } -} diff --git a/src/main/java/gg/modl/backend/settings/service/AIModerationSettingsService.java b/src/main/java/gg/modl/backend/settings/service/AIModerationSettingsService.java index a47cbae..0a24d08 100644 --- a/src/main/java/gg/modl/backend/settings/service/AIModerationSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/AIModerationSettingsService.java @@ -5,20 +5,23 @@ import gg.modl.backend.settings.data.AIModerationSettings; import java.util.HashMap; import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Service -@Slf4j -@RequiredArgsConstructor public class AIModerationSettingsService { - private final SettingsDocumentService settingsDocumentService; - private final ObjectMapper objectMapper; private static final String SETTINGS_TYPE_AI_MODERATION = "aiModerationSettings"; + private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + public AIModerationSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_AI_MODERATION, this::mapToAIModerationSettings); + } + public AIModerationSettings updateAIModerationSettings(Server server, AIModerationSettings newSettings) { - SettingsDocumentService.RawSettingsState current = settingsDocumentService.getRawState(server, SETTINGS_TYPE_AI_MODERATION); + long expectedVersion = support.currentVersion(server); AIModerationSettings toPersist = AIModerationSettings.builder() .enableAIReview(newSettings.isEnableAIReview()) @@ -28,13 +31,15 @@ public AIModerationSettings updateAIModerationSettings(Server server, AIModerati : new HashMap<>()) .build(); - SettingsDocumentService.RawSettingsState saved = - settingsDocumentService.saveRawState(server, SETTINGS_TYPE_AI_MODERATION, current.version(), codec().encode(toPersist)); - return codec().decode(saved.data()); + return support.save(server, expectedVersion, codec().encode(toPersist)).data(); } public AIModerationSettings getAIModerationSettings(Server server) { - return codec().decode(settingsDocumentService.getRawState(server, SETTINGS_TYPE_AI_MODERATION).data()); + return support.get(server); + } + + private AIModerationSettings mapToAIModerationSettings(Map data) { + return codec().decode(data); } private SettingsCodec codec() { diff --git a/src/main/java/gg/modl/backend/settings/service/ApiKeySettingsService.java b/src/main/java/gg/modl/backend/settings/service/ApiKeySettingsService.java index 1e377df..f4c908e 100644 --- a/src/main/java/gg/modl/backend/settings/service/ApiKeySettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/ApiKeySettingsService.java @@ -1,15 +1,14 @@ package gg.modl.backend.settings.service; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerCredentialRepository; +import gg.modl.backend.database.mongo.repository.ServerLookupRepository; import gg.modl.backend.infrastructure.scheduling.SchedulerLeaseService; import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.Settings; import gg.modl.backend.infrastructure.util.IdGenerator; import java.time.Duration; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -19,17 +18,33 @@ @Service @Slf4j -@RequiredArgsConstructor public class ApiKeySettingsService { - private final SettingsRepositoryAccess settingsRepositoryAccess; - private final IdGenerator idGenerator; - private final ServerMongoRepository serverRepository; - private final SchedulerLeaseService schedulerLeaseService; private static final String SETTINGS_TYPE_API_KEYS = "apiKeys"; private static final String API_KEY_FIELD = "api_key"; private static final String API_KEY_BACKFILL_LEASE = "server-api-key-backfill"; private static final Duration API_KEY_BACKFILL_LEASE_TTL = Duration.ofMinutes(30); + private final IdGenerator idGenerator; + private final ServerLookupRepository serverLookupRepository; + private final ServerCredentialRepository serverCredentialRepository; + private final SchedulerLeaseService schedulerLeaseService; + private final VersionedSettingsSupport> support; + + public ApiKeySettingsService( + SettingsDocumentService settingsDocumentService, + IdGenerator idGenerator, + ServerLookupRepository serverLookupRepository, + ServerCredentialRepository serverCredentialRepository, + SchedulerLeaseService schedulerLeaseService + ) { + this.idGenerator = idGenerator; + this.serverLookupRepository = serverLookupRepository; + this.serverCredentialRepository = serverCredentialRepository; + this.schedulerLeaseService = schedulerLeaseService; + this.support = VersionedSettingsSupport.>of( + settingsDocumentService, SETTINGS_TYPE_API_KEYS, LinkedHashMap::new); + } + @EventListener(ApplicationReadyEvent.class) public void backfillServerApiKeys() { if (!schedulerLeaseService.tryAcquire(API_KEY_BACKFILL_LEASE, API_KEY_BACKFILL_LEASE_TTL)) { @@ -37,7 +52,7 @@ public void backfillServerApiKeys() { } List servers; try { - servers = serverRepository.findAll(); + servers = serverLookupRepository.findAll(); } catch (Exception e) { log.error("Failed to load servers for API key backfill", e); return; @@ -69,44 +84,34 @@ private boolean syncServerApiKeyFromSettings(@NotNull Server server) { if (currentKey != null && currentKey.equals(settingsKey)) { return false; } - serverRepository.updateApiKey(server.getId(), settingsKey); + serverCredentialRepository.updateApiKey(server.getId(), settingsKey); return true; } @Nullable public Server findServerByApiKey(@NotNull String apiKey) { - return serverRepository.findByApiKey(apiKey).orElse(null); + return serverLookupRepository.findByApiKey(apiKey).orElse(null); } @Nullable public String getApiKeyFromSettings(@NotNull Server server) { - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_API_KEYS).orElse(null); - if (settings == null || settings.getData() == null) { - return null; - } - - @SuppressWarnings("unchecked") - Map data = (Map) settings.getData(); - Object apiKey = data.get(API_KEY_FIELD); - return apiKey instanceof String ? (String) apiKey : null; + Object apiKey = support.get(server).get(API_KEY_FIELD); + return apiKey instanceof String value ? value : null; } public void syncApiKeyToServer(@NotNull Server server, @NotNull String apiKey) { - serverRepository.updateApiKey(server.getId(), apiKey); + serverCredentialRepository.updateApiKey(server.getId(), apiKey); } public String generateApiKey(Server server, String keyType) { - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_API_KEYS).orElse(null); - @SuppressWarnings("unchecked") - Map data = settings != null && settings.getData() != null - ? new HashMap<>((Map) settings.getData()) - : new HashMap<>(); + VersionedSettings> state = support.state(server); + Map data = state.data(); String newApiKey = generateSecureApiKey(); String fieldName = getFieldNameForType(keyType); data.put(fieldName, newApiKey); - settingsRepositoryAccess.upsertSettings(server, SETTINGS_TYPE_API_KEYS, data); + support.save(server, state.version(), data); if (API_KEY_FIELD.equals(fieldName)) { syncApiKeyToServer(server, newApiKey); server.setApiKey(newApiKey); @@ -128,14 +133,8 @@ private String getFieldNameForType(String keyType) { } public boolean deleteApiKey(Server server, String keyType) { - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_API_KEYS).orElse(null); - - if (settings == null || settings.getData() == null) { - return false; - } - - @SuppressWarnings("unchecked") - Map data = new HashMap<>((Map) settings.getData()); + VersionedSettings> state = support.state(server); + Map data = state.data(); String fieldName = getFieldNameForType(keyType); if (!data.containsKey(fieldName)) { @@ -143,9 +142,9 @@ public boolean deleteApiKey(Server server, String keyType) { } data.remove(fieldName); - settingsRepositoryAccess.upsertSettings(server, SETTINGS_TYPE_API_KEYS, data); + support.save(server, state.version(), data); if (API_KEY_FIELD.equals(fieldName)) { - serverRepository.clearApiKey(server.getId()); + serverCredentialRepository.clearApiKey(server.getId()); server.setApiKey(null); } @@ -158,16 +157,8 @@ public boolean hasApiKey(Server server, String keyType) { } public String revealApiKey(Server server, String keyType) { - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_API_KEYS).orElse(null); - - if (settings == null || settings.getData() == null) { - return null; - } - String fieldName = getFieldNameForType(keyType); - @SuppressWarnings("unchecked") - Map data = (Map) settings.getData(); - Object apiKey = data.get(fieldName); - return apiKey instanceof String ? (String) apiKey : null; + Object apiKey = support.get(server).get(fieldName); + return apiKey instanceof String value ? value : null; } } diff --git a/src/main/java/gg/modl/backend/settings/service/CustomDomainLockRegistry.java b/src/main/java/gg/modl/backend/settings/service/CustomDomainLockRegistry.java new file mode 100644 index 0000000..8e52617 --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/service/CustomDomainLockRegistry.java @@ -0,0 +1,46 @@ +package gg.modl.backend.settings.service; + +import java.util.Arrays; +import java.util.concurrent.locks.ReentrantLock; +import org.springframework.stereotype.Component; + +@Component +public class CustomDomainLockRegistry { + private static final int STRIPES = 64; + private final ReentrantLock[] locks = createLocks(); + + public LockHold acquire(String... keys) { + int[] stripeIndices = Arrays.stream(keys) + .mapToInt(key -> Math.floorMod(key.hashCode(), STRIPES)) + .distinct() + .sorted() + .toArray(); + for (int stripeIndex : stripeIndices) { + locks[stripeIndex].lock(); + } + return new LockHold(stripeIndices); + } + + private static ReentrantLock[] createLocks() { + ReentrantLock[] created = new ReentrantLock[STRIPES]; + for (int i = 0; i < created.length; i++) { + created[i] = new ReentrantLock(); + } + return created; + } + + public final class LockHold implements AutoCloseable { + private final int[] stripeIndices; + + private LockHold(int[] stripeIndices) { + this.stripeIndices = stripeIndices; + } + + @Override + public void close() { + for (int i = stripeIndices.length - 1; i >= 0; i--) { + locks[stripeIndices[i]].unlock(); + } + } + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/CustomDomainReconciliationSweeper.java b/src/main/java/gg/modl/backend/settings/service/CustomDomainReconciliationSweeper.java new file mode 100644 index 0000000..b48040c --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/service/CustomDomainReconciliationSweeper.java @@ -0,0 +1,140 @@ +package gg.modl.backend.settings.service; + +import gg.modl.backend.cloudflare.config.CloudflareConfiguration; +import gg.modl.backend.cloudflare.external.CloudflareClient; +import gg.modl.backend.database.mongo.repository.ServerCustomDomainRepository; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import gg.modl.backend.server.data.CustomDomainStatus; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.config.CustomDomainReconciliationProperties; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomDomainReconciliationSweeper { + private final CustomDomainReconciliationProperties properties; + private final CloudflareConfiguration cloudflareConfiguration; + private final CloudflareClient cloudflareClient; + private final ServerCustomDomainRepository serverCustomDomainRepository; + private final CustomDomainStatusMapper statusMapper; + private final CustomDomainStateWriter stateWriter; + private final CustomDomainLockRegistry lockRegistry; + + @Scheduled(fixedDelayString = "${modl.custom-domain.reconciliation.interval-ms:21600000}") + public void runScheduledReconciliation() { + runReconciliationOnce(); + } + + public void runReconciliationOnce() { + if (!properties.isEnabled()) { + return; + } + if (!cloudflareConfiguration.isConfigured()) { + log.debug("Skipping custom domain reconciliation; Cloudflare is not configured"); + return; + } + + List servers = serverCustomDomainRepository.findAllWithCustomDomainOverride(); + int reconciled = 0; + Set claimedDomains = new HashSet<>(); + for (Server server : servers) { + String domain = server.getCustomDomainOverride(); + if (domain == null || domain.isEmpty()) { + continue; + } + claimedDomains.add(domain.toLowerCase(Locale.ROOT)); + if (reconcileServer(server, domain)) { + reconciled++; + } + } + log.info("Custom domain reconciliation scanned={} updated={}", servers.size(), reconciled); + + if (properties.isOrphanGarbageCollection()) { + garbageCollectOrphans(claimedDomains); + } + } + + private boolean reconcileServer(Server staleServer, String domain) { + try (CustomDomainLockRegistry.LockHold hold = lockRegistry.acquire(staleServer.getId(), domain)) { + Server server = serverCustomDomainRepository.findById(staleServer.getId()).orElse(null); + if (server == null || !domain.equalsIgnoreCase(server.getCustomDomainOverride())) { + return false; + } + + String cloudflareId = server.getCustomDomainCloudflareId(); + CloudflareClient.CustomHostnameResult cfResult; + try { + cfResult = lookupHostname(cloudflareId, domain); + } catch (ExternalServiceException exception) { + log.warn("Skipping custom domain reconciliation for {}; Cloudflare lookup failed", domain, exception); + return false; + } + + CustomDomainStatusMapper.Resolution resolution = statusMapper.resolve(cfResult); + CustomDomainStatus target = statusMapper.toEnum(resolution.status()); + String resolvedCloudflareId = cfResult == null ? cloudflareId : cfResult.id(); + + if (target == server.getCustomDomainStatus() + && Objects.equals(resolution.error(), server.getCustomDomainError())) { + return false; + } + + boolean updated = stateWriter.reconcileStatus( + server.getId(), domain, target, resolvedCloudflareId, resolution.error()); + if (updated) { + log.info("Custom domain {} reconciled {} -> {}", domain, server.getCustomDomainStatus(), target); + } + return updated; + } + } + + private CloudflareClient.CustomHostnameResult lookupHostname(String cloudflareId, String domain) { + CloudflareClient.CustomHostnameResult cfResult = cloudflareId != null && !cloudflareId.isEmpty() + ? cloudflareClient.getCustomHostname(cloudflareId) + : null; + return cfResult != null ? cfResult : cloudflareClient.findCustomHostnameByName(domain); + } + + private void garbageCollectOrphans(Set claimedDomains) { + List hostnames = cloudflareClient.listAllCustomHostnames(); + int deleted = 0; + for (CloudflareClient.CustomHostnameResult hostname : hostnames) { + if (hostname.hostname() == null || hostname.id() == null) { + continue; + } + String normalized = hostname.hostname().toLowerCase(Locale.ROOT); + if (claimedDomains.contains(normalized)) { + continue; + } + if (deleteOrphan(normalized, hostname.id())) { + deleted++; + } + } + if (deleted > 0) { + log.info("Custom domain orphan collection deleted={} of scanned={}", deleted, hostnames.size()); + } + } + + private boolean deleteOrphan(String normalizedDomain, String hostnameId) { + try (CustomDomainLockRegistry.LockHold hold = lockRegistry.acquire(normalizedDomain)) { + if (serverCustomDomainRepository.isCustomDomainClaimed(normalizedDomain)) { + return false; + } + if (cloudflareClient.deleteCustomHostname(hostnameId)) { + log.info("Deleted orphaned Cloudflare custom hostname {}", normalizedDomain); + return true; + } + log.warn("Failed to delete orphaned Cloudflare custom hostname {}", normalizedDomain); + return false; + } + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/CustomDomainStateWriter.java b/src/main/java/gg/modl/backend/settings/service/CustomDomainStateWriter.java new file mode 100644 index 0000000..454fd1c --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/service/CustomDomainStateWriter.java @@ -0,0 +1,45 @@ +package gg.modl.backend.settings.service; + +import gg.modl.backend.database.mongo.repository.ServerCustomDomainRepository; +import gg.modl.backend.infrastructure.cors.DynamicCorsConfigurationSource; +import gg.modl.backend.server.ServerService; +import gg.modl.backend.server.data.CustomDomainStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CustomDomainStateWriter { + private final ServerCustomDomainRepository serverCustomDomainRepository; + private final ServerService serverService; + private final DynamicCorsConfigurationSource corsConfigurationSource; + + public void persist(String serverId, String domain, CustomDomainStatus status, + String cloudflareHostnameId, String error) { + serverCustomDomainRepository.updateCustomDomain(serverId, domain, status, cloudflareHostnameId, error); + evict(domain); + } + + public boolean reconcileStatus(String serverId, String domain, CustomDomainStatus status, + String cloudflareHostnameId, String error) { + boolean updated = serverCustomDomainRepository.updateCustomDomainStatus( + serverId, domain, status, cloudflareHostnameId, error); + if (updated) { + evict(domain); + } + return updated; + } + + public void clear(String serverId, String domain) { + serverCustomDomainRepository.clearCustomDomain(serverId); + evict(domain); + } + + public void evict(String domain) { + if (domain == null || domain.isBlank()) { + return; + } + serverService.evictServerCache(domain); + corsConfigurationSource.invalidateCache(domain); + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/CustomDomainStatusMapper.java b/src/main/java/gg/modl/backend/settings/service/CustomDomainStatusMapper.java new file mode 100644 index 0000000..21f352e --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/service/CustomDomainStatusMapper.java @@ -0,0 +1,62 @@ +package gg.modl.backend.settings.service; + +import gg.modl.backend.cloudflare.external.CloudflareClient; +import gg.modl.backend.server.data.CustomDomainStatus; +import java.util.Locale; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Component; + +@Component +public class CustomDomainStatusMapper { + private static final String STATUS_ACTIVE = "active"; + private static final String STATUS_PENDING = "pending"; + private static final String STATUS_ERROR = "error"; + private static final String CF_BLOCKED = "blocked"; + private static final String CF_MOVED = "moved"; + private static final String HOSTNAME_MISSING_ERROR = + "Custom hostname not found in Cloudflare. Please reconfigure the domain."; + + public Resolution resolve(@Nullable CloudflareClient.CustomHostnameResult cfResult) { + if (cfResult == null) { + return new Resolution(STATUS_ERROR, STATUS_ERROR, false, HOSTNAME_MISSING_ERROR); + } + + String normalizedCfStatus = normalize(cfResult.status()); + String status = mapStatus(normalizedCfStatus); + boolean cnameConfigured = STATUS_ACTIVE.equals(status); + String sslStatus = cfResult.ssl() != null ? mapStatus(normalize(cfResult.ssl().status())) : STATUS_PENDING; + String error = isRejected(normalizedCfStatus) + ? "Domain verification failed. Status: " + normalizedCfStatus + : null; + return new Resolution(status, sslStatus, cnameConfigured, error); + } + + public CustomDomainStatus toEnum(String status) { + return switch (status) { + case STATUS_ACTIVE -> CustomDomainStatus.ACTIVE; + case STATUS_ERROR -> CustomDomainStatus.ERROR; + default -> CustomDomainStatus.PENDING; + }; + } + + private String normalize(@Nullable String cfStatus) { + return cfStatus == null ? null : cfStatus.toLowerCase(Locale.ROOT); + } + + private String mapStatus(@Nullable String normalizedCfStatus) { + if (normalizedCfStatus == null) { + return STATUS_PENDING; + } + return switch (normalizedCfStatus) { + case STATUS_ACTIVE -> STATUS_ACTIVE; + case CF_BLOCKED, CF_MOVED -> STATUS_ERROR; + default -> STATUS_PENDING; + }; + } + + private boolean isRejected(@Nullable String normalizedCfStatus) { + return CF_BLOCKED.equals(normalizedCfStatus) || CF_MOVED.equals(normalizedCfStatus); + } + + public record Resolution(String status, String sslStatus, boolean cnameConfigured, String error) {} +} diff --git a/src/main/java/gg/modl/backend/settings/service/DomainSettingsService.java b/src/main/java/gg/modl/backend/settings/service/DomainSettingsService.java index d8c77fd..1966924 100644 --- a/src/main/java/gg/modl/backend/settings/service/DomainSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/DomainSettingsService.java @@ -1,296 +1,261 @@ package gg.modl.backend.settings.service; -import gg.modl.backend.infrastructure.cors.DynamicCorsConfigurationSource; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.cloudflare.config.CloudflareConfiguration; import gg.modl.backend.cloudflare.external.CloudflareClient; +import gg.modl.backend.database.mongo.repository.ServerCustomDomainRepository; +import gg.modl.backend.infrastructure.config.ModlCorsProperties; import gg.modl.backend.infrastructure.exception.ConflictException; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.exception.ServiceUnavailableException; import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.server.data.CustomDomainStatus; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.DomainSettings; -import gg.modl.backend.settings.data.Settings; import java.net.IDN; import java.time.Instant; -import java.util.HashMap; +import java.util.Arrays; +import java.util.HashSet; import java.util.Locale; -import java.util.Map; import java.util.Set; import java.util.regex.Pattern; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; @Service @Slf4j -@RequiredArgsConstructor public class DomainSettingsService { - private final SettingsRepositoryAccess settingsRepositoryAccess; - private final ServerMongoRepository serverRepository; + private final ServerCustomDomainRepository serverCustomDomainRepository; private final CloudflareClient cloudflareClient; - private final DynamicCorsConfigurationSource corsConfigurationSource; + private final CloudflareConfiguration cloudflareConfiguration; private final CustomDomainAccessService customDomainAccessService; + private final CustomDomainStatusMapper statusMapper; + private final CustomDomainStateWriter stateWriter; + private final CustomDomainLockRegistry lockRegistry; + private final SettingsDocumentService settingsDocumentService; + private final Set reservedSuffixes; private static final String SETTINGS_TYPE_DOMAIN = "domain"; + private static final String DOMAIN_UNAVAILABLE_MESSAGE = "This domain is not available."; private static final Pattern HOSTNAME_PATTERN = Pattern.compile("^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(? RESERVED_SUFFIXES = Set.of( "modl.gg", "modl.top", "localhost", "local", "internal", "test", "example", "invalid" ); - public DomainSettings getDomainSettings(Server server, String requestHost) { - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_DOMAIN).orElse(null); - - String modlSubdomainUrl = "https://" + server.getCustomDomain() + ".modl.gg"; - boolean canManageCustomDomain = customDomainAccessService.canManageCustomDomain(server); - - if (settings == null || settings.getData() == null) { - return DomainSettings.builder() - .customDomain(null) - .status(null) - .accessingFromCustomDomain(false) - .modlSubdomainUrl(modlSubdomainUrl) - .canManageCustomDomain(canManageCustomDomain) - .build(); - } + public DomainSettingsService( + SettingsDocumentService settingsDocumentService, + ServerCustomDomainRepository serverCustomDomainRepository, + CloudflareClient cloudflareClient, + CloudflareConfiguration cloudflareConfiguration, + CustomDomainAccessService customDomainAccessService, + CustomDomainStatusMapper statusMapper, + CustomDomainStateWriter stateWriter, + CustomDomainLockRegistry lockRegistry, + ModlCorsProperties corsProperties + ) { + this.serverCustomDomainRepository = serverCustomDomainRepository; + this.cloudflareClient = cloudflareClient; + this.cloudflareConfiguration = cloudflareConfiguration; + this.customDomainAccessService = customDomainAccessService; + this.statusMapper = statusMapper; + this.stateWriter = stateWriter; + this.lockRegistry = lockRegistry; + this.settingsDocumentService = settingsDocumentService; + this.reservedSuffixes = buildReservedSuffixes(corsProperties); + } - @SuppressWarnings("unchecked") - Map data = (Map) settings.getData(); - String customDomain = getStringValue(data, "customDomain"); + private static Set buildReservedSuffixes(ModlCorsProperties corsProperties) { + Set combined = new HashSet<>(RESERVED_SUFFIXES); + Arrays.stream(corsProperties.getAppDomains().split(",")) + .map(String::trim) + .filter(suffix -> !suffix.isBlank()) + .map(suffix -> suffix.toLowerCase(Locale.ROOT)) + .forEach(combined::add); + return Set.copyOf(combined); + } + public DomainSettings getDomainSettings(Server server, String requestHost) { + String customDomain = server.getCustomDomainOverride(); boolean accessingFromCustomDomain = customDomain != null && !customDomain.isEmpty() && requestHost != null && requestHost.equalsIgnoreCase(customDomain); - - DomainSettings.DomainStatus status = null; - if (customDomain != null && !customDomain.isEmpty()) { - @SuppressWarnings("unchecked") - Map statusData = (Map) data.get("status"); - if (statusData != null) { - status = DomainSettings.DomainStatus.builder() - .domain(getStringValue(statusData, "domain")) - .status(getStringValue(statusData, "status")) - .cnameConfigured(getBooleanValue(statusData, "cnameConfigured")) - .sslStatus(getStringValue(statusData, "sslStatus")) - .lastChecked(getStringValue(statusData, "lastChecked")) - .error(getStringValue(statusData, "error")) - .build(); - } - } - - return DomainSettings.builder() - .customDomain(customDomain) - .status(status) - .accessingFromCustomDomain(accessingFromCustomDomain) - .modlSubdomainUrl(modlSubdomainUrl) - .canManageCustomDomain(canManageCustomDomain) - .build(); + DomainSettings.DomainStatus status = customDomain == null || customDomain.isEmpty() + ? null : statusFromServer(server); + return buildResponse(server, customDomain, status, accessingFromCustomDomain); } - private String getStringValue(Map data, String key) { - Object value = data.get(key); - return value instanceof String ? (String) value : null; - } + public DomainSettings configureDomain(Server server, String requestedDomain) { + String customDomain = normalizeAndValidateCustomDomain(requestedDomain); + requireCloudflareConfigured(); - private boolean getBooleanValue(Map data, String key) { - Object value = data.get(key); - return value instanceof Boolean ? (Boolean) value : false; + try (CustomDomainLockRegistry.LockHold hold = lockRegistry.acquire(server.getId(), customDomain)) { + return configureDomainLocked(server, customDomain); + } } - public DomainSettings configureDomain(Server server, String customDomain) { - customDomain = normalizeAndValidateCustomDomain(customDomain); - - String currentDomain = extractCurrentDomain(server); + private DomainSettings configureDomainLocked(Server staleServer, String customDomain) { + Server server = serverCustomDomainRepository.findById(staleServer.getId()) + .orElseThrow(() -> new ResourceNotFoundException("Server not found")); + String currentDomain = server.getCustomDomainOverride(); + String currentCloudflareId = server.getCustomDomainCloudflareId(); + boolean sameDomain = currentDomain != null && currentDomain.equalsIgnoreCase(customDomain); - if (currentDomain != null && currentDomain.equalsIgnoreCase(customDomain)) { - throw new ConflictException("This domain is already configured. Please verify the existing configuration or remove it first."); + if (serverCustomDomainRepository.isCustomDomainOwnedByAnotherServer(customDomain, server.getId())) { + throw new ConflictException(DOMAIN_UNAVAILABLE_MESSAGE); } - String currentCloudflareHostnameId = extractCurrentCloudflareHostnameId(server); - CloudflareClient.CustomHostnameResult existingHostname = cloudflareClient.findCustomHostnameByName(customDomain); - if (existingHostname != null) { - if (currentCloudflareHostnameId == null || !existingHostname.id().equals(currentCloudflareHostnameId)) { - throw new ConflictException("This domain is already configured by another server."); + if (sameDomain && server.getCustomDomainStatus() == CustomDomainStatus.ACTIVE) { + CloudflareClient.CustomHostnameResult existing = currentCloudflareId == null + ? null : cloudflareClient.getCustomHostname(currentCloudflareId); + if (existing != null && customDomain.equalsIgnoreCase(existing.hostname())) { + return getDomainSettings(server, null); } - cloudflareClient.deleteCustomHostname(existingHostname.id()); } - CloudflareClient.CustomHostnameResult cfResult = cloudflareClient.createCustomHostname(customDomain); + if (!sameDomain && currentDomain != null) { + deleteExistingHostname(currentCloudflareId, currentDomain); + } - String initialStatus = "pending"; - String sslStatus = "pending"; - String error = null; - String cloudflareHostnameId = null; + CloudflareClient.CustomHostnameResult collision = cloudflareClient.findCustomHostnameByName(customDomain); + if (collision != null) { + cloudflareClient.deleteCustomHostname(collision.id()); + } + CloudflareClient.CustomHostnameResult cfResult = cloudflareClient.createCustomHostname(customDomain); if (cfResult == null) { - initialStatus = "error"; - sslStatus = "error"; - error = "Failed to create custom hostname in Cloudflare. Please check your configuration."; - log.error("Failed to create Cloudflare custom hostname for domain: {}", customDomain); - } else { - cloudflareHostnameId = cfResult.id(); - initialStatus = mapCloudflareStatus(cfResult.status()); - if (cfResult.ssl() != null) { - sslStatus = mapCloudflareStatus(cfResult.ssl().status()); - } + throw new ExternalServiceException("Failed to provision the custom domain. Please try again."); } - DomainSettings.DomainStatus status = DomainSettings.DomainStatus.builder() - .domain(customDomain) - .status(initialStatus) - .cnameConfigured(false) - .sslStatus(sslStatus) - .lastChecked(Instant.now().toString()) - .error(error) - .build(); + CustomDomainStatusMapper.Resolution resolution = statusMapper.resolve(cfResult); + DomainSettings.DomainStatus status = buildStatus(customDomain, resolution); - Map data = new HashMap<>(); - data.put("customDomain", customDomain); - data.put("status", buildDomainStatusMap(status)); - data.put("cloudflareHostnameId", cloudflareHostnameId); - - settingsRepositoryAccess.upsertSettings(server, SETTINGS_TYPE_DOMAIN, data); + try { + stateWriter.persist(server.getId(), customDomain, statusMapper.toEnum(resolution.status()), + cfResult.id(), resolution.error()); + } catch (DuplicateKeyException duplicateKeyException) { + cloudflareClient.deleteCustomHostname(cfResult.id()); + throw new ConflictException(DOMAIN_UNAVAILABLE_MESSAGE); + } - updateServerDocument(server.getId(), customDomain, initialStatus, cloudflareHostnameId, error); + stateWriter.evict(currentDomain); + settingsDocumentService.deleteState(server, SETTINGS_TYPE_DOMAIN); - return buildDomainSettingsResponse(server, customDomain, status); + return buildResponse(server, customDomain, status, false); } - private void updateServerDocument(String serverId, String customDomain, String status, - String cloudflareHostnameId, String error) { - serverRepository.updateCustomDomain(serverId, customDomain, status, cloudflareHostnameId, error); - corsConfigurationSource.invalidateCache(customDomain); - log.debug("Invalidated CORS cache for domain: {}", customDomain); - } + public DomainSettings verifyDomain(Server server, String requestedDomain) { + String domain = normalizeAndValidateCustomDomain(requestedDomain); + requireCloudflareConfigured(); + String configuredDomain = server.getCustomDomainOverride(); + if (configuredDomain == null || configuredDomain.isEmpty()) { + throw new ResourceNotFoundException("No domain configured"); + } + if (!domain.equalsIgnoreCase(configuredDomain)) { + throw new ValidationException("Domain does not match configured domain"); + } - private String extractCurrentDomain(Server server) { - String currentDomain = server.getCustomDomainOverride(); - if (currentDomain == null) { - Settings existingSettings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_DOMAIN).orElse(null); - if (existingSettings != null && existingSettings.getData() != null) { - @SuppressWarnings("unchecked") - Map data = (Map) existingSettings.getData(); - currentDomain = getStringValue(data, "customDomain"); + try (CustomDomainLockRegistry.LockHold hold = lockRegistry.acquire(server.getId(), configuredDomain)) { + String cloudflareHostnameId = server.getCustomDomainCloudflareId(); + CloudflareClient.CustomHostnameResult cfResult; + if (cloudflareHostnameId != null && !cloudflareHostnameId.isEmpty()) { + cfResult = cloudflareClient.getCustomHostname(cloudflareHostnameId); + } else { + cfResult = cloudflareClient.findCustomHostnameByName(domain); + cloudflareHostnameId = cfResult == null ? null : cfResult.id(); } - } - return currentDomain; - } - private String extractCurrentCloudflareHostnameId(Server server) { - String currentCloudflareHostnameId = server.getCustomDomainCloudflareId(); - if (currentCloudflareHostnameId == null) { - Settings existingSettings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_DOMAIN).orElse(null); - if (existingSettings != null && existingSettings.getData() != null) { - @SuppressWarnings("unchecked") - Map data = (Map) existingSettings.getData(); - currentCloudflareHostnameId = getStringValue(data, "cloudflareHostnameId"); + if (cfResult == null) { + log.warn("Custom hostname not found in Cloudflare for domain: {}", domain); } - } - return currentCloudflareHostnameId; - } - private Map buildDomainStatusMap(DomainSettings.DomainStatus status) { - Map statusMap = new HashMap<>(); - statusMap.put("domain", status.getDomain()); - statusMap.put("status", status.getStatus()); - statusMap.put("cnameConfigured", status.isCnameConfigured()); - statusMap.put("sslStatus", status.getSslStatus()); - statusMap.put("lastChecked", status.getLastChecked()); - statusMap.put("error", status.getError()); - return statusMap; - } + CustomDomainStatusMapper.Resolution resolution = statusMapper.resolve(cfResult); + DomainSettings.DomainStatus status = buildStatus(domain, resolution); - private DomainSettings buildDomainSettingsResponse(Server server, String customDomain, - DomainSettings.DomainStatus status) { - return DomainSettings.builder() - .customDomain(customDomain) - .status(status) - .accessingFromCustomDomain(false) - .modlSubdomainUrl("https://" + server.getCustomDomain() + ".modl.gg") - .canManageCustomDomain(customDomainAccessService.canManageCustomDomain(server)) - .build(); - } + boolean updated = stateWriter.reconcileStatus(server.getId(), domain, + statusMapper.toEnum(resolution.status()), cloudflareHostnameId, resolution.error()); + if (!updated) { + throw new ResourceNotFoundException("No domain configured"); + } - private String mapCloudflareStatus(String cfStatus) { - if (cfStatus == null) { - return "pending"; + return buildResponse(server, domain, status, false); } - return switch (cfStatus.toLowerCase()) { - case "active" -> "active"; - case "pending", "pending_validation", "pending_issuance", "pending_deployment", "initializing" -> "pending"; - case "pending_deletion", "deleted" -> "pending"; - case "blocked", "moved" -> "error"; - default -> "pending"; - }; } - public DomainSettings verifyDomain(Server server, String domain) { - domain = normalizeAndValidateCustomDomain(domain); - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_DOMAIN).orElse(null); - - if (settings == null || settings.getData() == null) { - throw new ResourceNotFoundException("No domain configured"); + public void removeDomain(Server server) { + String customDomain = server.getCustomDomainOverride(); + String[] lockKeys = customDomain == null + ? new String[] {server.getId()} + : new String[] {server.getId(), customDomain}; + try (CustomDomainLockRegistry.LockHold hold = lockRegistry.acquire(lockKeys)) { + deleteExistingHostname(server.getCustomDomainCloudflareId(), customDomain); + stateWriter.clear(server.getId(), customDomain); + settingsDocumentService.deleteState(server, SETTINGS_TYPE_DOMAIN); } + } - @SuppressWarnings("unchecked") - Map data = (Map) settings.getData(); - String configuredDomain = getStringValue(data, "customDomain"); - String cloudflareHostnameId = getStringValue(data, "cloudflareHostnameId"); - - if (!domain.equalsIgnoreCase(configuredDomain)) { - throw new ValidationException("Domain does not match configured domain"); + private void requireCloudflareConfigured() { + if (!cloudflareConfiguration.isConfigured()) { + throw new ServiceUnavailableException("Custom domains are not available on this deployment."); } + } - String verifiedStatus = "pending"; - String sslStatus = "pending"; - boolean cnameConfigured = false; - String error = null; - - CloudflareClient.CustomHostnameResult cfResult = null; - - if (cloudflareHostnameId != null && !cloudflareHostnameId.isEmpty()) { - cfResult = cloudflareClient.getCustomHostname(cloudflareHostnameId); - } else { - cfResult = cloudflareClient.findCustomHostnameByName(domain); - if (cfResult != null) { - cloudflareHostnameId = cfResult.id(); + private void deleteExistingHostname(String cloudflareId, String domain) { + String hostnameId = cloudflareId; + if (hostnameId == null && domain != null && !domain.isEmpty()) { + try { + CloudflareClient.CustomHostnameResult existing = cloudflareClient.findCustomHostnameByName(domain); + hostnameId = existing == null ? null : existing.id(); + } catch (ExternalServiceException exception) { + log.warn("Could not look up Cloudflare custom hostname for domain {}; leaving it for orphan collection", + domain, exception); + return; } } - - if (cfResult != null) { - verifiedStatus = mapCloudflareStatus(cfResult.status()); - cnameConfigured = "active".equals(verifiedStatus); - - if (cfResult.ssl() != null) { - sslStatus = mapCloudflareStatus(cfResult.ssl().status()); - } - - if ("blocked".equals(cfResult.status()) || "moved".equals(cfResult.status())) { - error = "Domain verification failed. Status: " + cfResult.status(); - } - } else { - verifiedStatus = "error"; - sslStatus = "error"; - error = "Custom hostname not found in Cloudflare. Please reconfigure the domain."; - log.warn("Custom hostname not found in Cloudflare for domain: {}", domain); + if (hostnameId == null) { + return; + } + if (!cloudflareClient.deleteCustomHostname(hostnameId)) { + log.warn("Failed to delete Cloudflare custom hostname for domain: {}", domain); } + } - DomainSettings.DomainStatus status = DomainSettings.DomainStatus.builder() + private DomainSettings.DomainStatus buildStatus(String domain, CustomDomainStatusMapper.Resolution resolution) { + return DomainSettings.DomainStatus.builder() .domain(domain) - .status(verifiedStatus) - .cnameConfigured(cnameConfigured) - .sslStatus(sslStatus) + .status(resolution.status()) + .cnameConfigured(resolution.cnameConfigured()) + .sslStatus(resolution.sslStatus()) .lastChecked(Instant.now().toString()) - .error(error) + .error(resolution.error()) .build(); + } - data.put("status", buildDomainStatusMap(status)); - if (cloudflareHostnameId != null) { - data.put("cloudflareHostnameId", cloudflareHostnameId); - } - - settingsRepositoryAccess.updateDataSettings(server, SETTINGS_TYPE_DOMAIN, data); - - updateServerDocument(server.getId(), domain, verifiedStatus, cloudflareHostnameId, error); + private DomainSettings.DomainStatus statusFromServer(Server server) { + CustomDomainStatus stored = server.getCustomDomainStatus(); + String status = stored == null ? "pending" : stored.name().toLowerCase(Locale.ROOT); + boolean active = stored == CustomDomainStatus.ACTIVE; + String sslStatus = active ? "active" : (stored == CustomDomainStatus.ERROR ? "error" : "pending"); + String lastChecked = server.getCustomDomainLastChecked() == null + ? null : server.getCustomDomainLastChecked().toInstant().toString(); + return DomainSettings.DomainStatus.builder() + .domain(server.getCustomDomainOverride()) + .status(status) + .cnameConfigured(active) + .sslStatus(sslStatus) + .lastChecked(lastChecked) + .error(server.getCustomDomainError()) + .build(); + } - return buildDomainSettingsResponse(server, domain, status); + private DomainSettings buildResponse(Server server, String customDomain, + DomainSettings.DomainStatus status, boolean accessingFromCustomDomain) { + return DomainSettings.builder() + .customDomain(customDomain) + .status(status) + .accessingFromCustomDomain(accessingFromCustomDomain) + .modlSubdomainUrl("https://" + server.getCustomDomain() + ".modl.gg") + .canManageCustomDomain(customDomainAccessService.canManageCustomDomain(server)) + .build(); } private String normalizeAndValidateCustomDomain(String domain) { @@ -317,48 +282,11 @@ private String normalizeAndValidateCustomDomain(String domain) { } private boolean isReservedDomain(String domain) { - for (String suffix : RESERVED_SUFFIXES) { + for (String suffix : reservedSuffixes) { if (domain.equals(suffix) || domain.endsWith("." + suffix)) { return true; } } return false; } - - public void removeDomain(Server server) { - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_DOMAIN).orElse(null); - - String customDomain = null; - - if (settings != null && settings.getData() != null) { - @SuppressWarnings("unchecked") - Map data = (Map) settings.getData(); - String cloudflareHostnameId = getStringValue(data, "cloudflareHostnameId"); - customDomain = getStringValue(data, "customDomain"); - - if (cloudflareHostnameId != null && !cloudflareHostnameId.isEmpty()) { - boolean deleted = cloudflareClient.deleteCustomHostname(cloudflareHostnameId); - if (!deleted) { - log.warn("Failed to delete Cloudflare custom hostname for domain: {}", customDomain); - } - } - } - - if (customDomain == null && server.getCustomDomainOverride() != null) { - customDomain = server.getCustomDomainOverride(); - } - - settingsRepositoryAccess.removeSettings(server, SETTINGS_TYPE_DOMAIN); - - clearServerDomainFields(server.getId()); - - if (customDomain != null && !customDomain.isEmpty()) { - corsConfigurationSource.invalidateCache(customDomain); - log.debug("Invalidated CORS cache for removed domain: {}", customDomain); - } - } - - private void clearServerDomainFields(String serverId) { - serverRepository.clearCustomDomain(serverId); - } } diff --git a/src/main/java/gg/modl/backend/settings/service/GeneralSettingsService.java b/src/main/java/gg/modl/backend/settings/service/GeneralSettingsService.java index 2543ace..695c815 100644 --- a/src/main/java/gg/modl/backend/settings/service/GeneralSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/GeneralSettingsService.java @@ -1,119 +1,112 @@ -package gg.modl.backend.settings.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.GeneralSettings; -import gg.modl.backend.settings.data.SupportedLanguages; -import java.util.LinkedHashMap; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class GeneralSettingsService { - private final SettingsDocumentService settingsDocumentService; - private final ObjectMapper objectMapper; - private static final String SETTINGS_TYPE_GENERAL = "general"; - private static final int MAX_SERVER_NAME_LENGTH = 80; - private static final int MAX_URL_LENGTH = 2048; - - public GeneralSettings getGeneralSettings(Server server) { - return getGeneralSettingsState(server).data(); - } - - public VersionedSettings getGeneralSettingsState(Server server) { - SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_GENERAL); - GeneralSettings settings = mapToGeneralSettings(state.data()); - return new VersionedSettings<>(settings, state.version(), state.updatedAt()); - } - - private GeneralSettings mapToGeneralSettings(Map data) { - GeneralSettings mapped = codec().decode(data); - return GeneralSettings.builder() - .serverDisplayName(sanitizeOrEmpty(mapped.getServerDisplayName(), MAX_SERVER_NAME_LENGTH)) - .discordWebhookUrl(sanitizeOrEmpty(mapped.getDiscordWebhookUrl(), MAX_URL_LENGTH)) - .homepageIconUrl(sanitizeOrEmpty(mapped.getHomepageIconUrl(), MAX_URL_LENGTH)) - .panelIconUrl(sanitizeOrEmpty(mapped.getPanelIconUrl(), MAX_URL_LENGTH)) - .defaultLanguage(resolveLanguage(mapped.getDefaultLanguage())) - .build(); - } - - private String resolveLanguage(String value) { - return SupportedLanguages.isSupported(value) ? value : SupportedLanguages.DEFAULT; - } - - private SettingsCodec codec() { - return SettingsCodec.of(objectMapper, GeneralSettings.class, this::defaultGeneralSettings); - } - - private String sanitizeOrEmpty(String value, int maxLength) { - String sanitized = sanitize(value, maxLength); - return sanitized != null ? sanitized : ""; - } - - private GeneralSettings defaultGeneralSettings() { - return GeneralSettings.builder() - .serverDisplayName("") - .discordWebhookUrl("") - .homepageIconUrl("") - .panelIconUrl("") - .defaultLanguage(SupportedLanguages.DEFAULT) - .build(); - } - - private String sanitize(String value, int maxLength) { - if (value == null) { - return null; - } - - String trimmed = value.trim(); - if (trimmed.length() <= maxLength) { - return trimmed; - } - return trimmed.substring(0, maxLength); - } - - public VersionedSettings patchGeneralSettings( - Server server, - long expectedVersion, - GeneralSettings patch - ) { - SettingsDocumentService.RawSettingsState current = settingsDocumentService.getRawState(server, SETTINGS_TYPE_GENERAL); - Map data = new LinkedHashMap<>(current.data()); - - putIfNotNull(data, "serverDisplayName", patch.getServerDisplayName(), MAX_SERVER_NAME_LENGTH); - putIfNotNull(data, "discordWebhookUrl", patch.getDiscordWebhookUrl(), MAX_URL_LENGTH); - putIfNotNull(data, "homepageIconUrl", patch.getHomepageIconUrl(), MAX_URL_LENGTH); - putIfNotNull(data, "panelIconUrl", patch.getPanelIconUrl(), MAX_URL_LENGTH); - putLanguageIfNotNull(data, patch.getDefaultLanguage()); - - SettingsDocumentService.RawSettingsState updated = settingsDocumentService.saveRawState( - server, - SETTINGS_TYPE_GENERAL, - expectedVersion, - data - ); - return new VersionedSettings<>(mapToGeneralSettings(updated.data()), updated.version(), updated.updatedAt()); - } - - private void putIfNotNull(Map data, String key, String value, int maxLength) { - if (value != null) { - data.put(key, sanitize(value, maxLength)); - } - } - - private void putLanguageIfNotNull(Map data, String value) { - if (value == null) { - return; - } - String language = value.trim(); - if (!SupportedLanguages.isSupported(language)) { - throw new ValidationException("Unsupported default language: " + language); - } - data.put("defaultLanguage", language); - } -} +package gg.modl.backend.settings.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.GeneralSettings; +import gg.modl.backend.settings.data.SupportedLanguages; +import java.util.Map; +import org.springframework.stereotype.Service; + +@Service +public class GeneralSettingsService { + private static final String SETTINGS_TYPE_GENERAL = "general"; + private static final int MAX_SERVER_NAME_LENGTH = 80; + private static final int MAX_URL_LENGTH = 2048; + + private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + public GeneralSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_GENERAL, this::mapToGeneralSettings); + } + + public GeneralSettings getGeneralSettings(Server server) { + return support.get(server); + } + + public VersionedSettings getGeneralSettingsState(Server server) { + return support.state(server); + } + + private GeneralSettings mapToGeneralSettings(Map data) { + GeneralSettings mapped = codec().decode(data); + return GeneralSettings.builder() + .serverDisplayName(sanitizeOrEmpty(mapped.getServerDisplayName(), MAX_SERVER_NAME_LENGTH)) + .discordWebhookUrl(sanitizeOrEmpty(mapped.getDiscordWebhookUrl(), MAX_URL_LENGTH)) + .homepageIconUrl(sanitizeOrEmpty(mapped.getHomepageIconUrl(), MAX_URL_LENGTH)) + .panelIconUrl(sanitizeOrEmpty(mapped.getPanelIconUrl(), MAX_URL_LENGTH)) + .defaultLanguage(resolveLanguage(mapped.getDefaultLanguage())) + .build(); + } + + private String resolveLanguage(String value) { + return SupportedLanguages.isSupported(value) ? value : SupportedLanguages.DEFAULT; + } + + private SettingsCodec codec() { + return SettingsCodec.of(objectMapper, GeneralSettings.class, this::defaultGeneralSettings); + } + + private String sanitizeOrEmpty(String value, int maxLength) { + String sanitized = sanitize(value, maxLength); + return sanitized != null ? sanitized : ""; + } + + private GeneralSettings defaultGeneralSettings() { + return GeneralSettings.builder() + .serverDisplayName("") + .discordWebhookUrl("") + .homepageIconUrl("") + .panelIconUrl("") + .defaultLanguage(SupportedLanguages.DEFAULT) + .build(); + } + + private String sanitize(String value, int maxLength) { + if (value == null) { + return null; + } + + String trimmed = value.trim(); + if (trimmed.length() <= maxLength) { + return trimmed; + } + return trimmed.substring(0, maxLength); + } + + public VersionedSettings patchGeneralSettings( + Server server, + long expectedVersion, + GeneralSettings patch + ) { + Map data = support.currentData(server); + + putIfNotNull(data, "serverDisplayName", patch.getServerDisplayName(), MAX_SERVER_NAME_LENGTH); + putIfNotNull(data, "discordWebhookUrl", patch.getDiscordWebhookUrl(), MAX_URL_LENGTH); + putIfNotNull(data, "homepageIconUrl", patch.getHomepageIconUrl(), MAX_URL_LENGTH); + putIfNotNull(data, "panelIconUrl", patch.getPanelIconUrl(), MAX_URL_LENGTH); + putLanguageIfNotNull(data, patch.getDefaultLanguage()); + + return support.save(server, expectedVersion, data); + } + + private void putIfNotNull(Map data, String key, String value, int maxLength) { + if (value != null) { + data.put(key, sanitize(value, maxLength)); + } + } + + private void putLanguageIfNotNull(Map data, String value) { + if (value == null) { + return; + } + String language = value.trim(); + if (!SupportedLanguages.isSupported(language)) { + throw new ValidationException("Unsupported default language: " + language); + } + data.put("defaultLanguage", language); + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/IconUploadService.java b/src/main/java/gg/modl/backend/settings/service/IconUploadService.java index 6008372..132b33d 100644 --- a/src/main/java/gg/modl/backend/settings/service/IconUploadService.java +++ b/src/main/java/gg/modl/backend/settings/service/IconUploadService.java @@ -1,82 +1,82 @@ -package gg.modl.backend.settings.service; - -import gg.modl.backend.server.data.Server; -import gg.modl.backend.storage.service.S3StorageService; -import gg.modl.backend.storage.service.StorageMetadataService; -import gg.modl.backend.storage.service.StorageQuotaService; -import java.io.IOException; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -@Service -@RequiredArgsConstructor -public class IconUploadService { - private final S3StorageService s3StorageService; - private final StorageQuotaService storageQuotaService; - private final StorageMetadataService storageMetadataService; - private static final Set ALLOWED_IMAGE_TYPES = Set.of( - "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp" - ); - private static final long MAX_ICON_SIZE = 2 * 1024 * 1024; - - public ResponseEntity uploadIcon(Server server, MultipartFile file, String iconType) { - ResponseEntity validationError = validateUpload(server, file, iconType); - if (validationError != null) { - return validationError; - } - - try { - String fileName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "icon"; - S3StorageService.UploadFileResult result = s3StorageService.uploadFile( - server, "icons/" + iconType, fileName, file.getContentType(), file.getBytes() - ); - StorageQuotaService.ConfirmResult confirmResult = storageQuotaService.confirmAndRecordFile( - server, result.key(), file.getSize(), file.getContentType() - ); - switch (confirmResult) { - case QUOTA_EXCEEDED -> { - s3StorageService.deleteFile(result.key()); - return ResponseEntity.badRequest().body(Map.of("error", "Storage quota exceeded")); - } - case RECORD_FAILED -> { - s3StorageService.deleteFile(result.key()); - return ResponseEntity.internalServerError().body(Map.of("error", "Failed to record upload")); - } - default -> { - } - } - return ResponseEntity.ok(Map.of("url", result.cdnUrl())); - } catch (IOException e) { - return ResponseEntity.internalServerError().body(Map.of("error", "Failed to read file")); - } catch (RuntimeException e) { - return ResponseEntity.internalServerError().body(Map.of("error", "Failed to upload file")); - } - } - - private ResponseEntity validateUpload(Server server, MultipartFile file, String iconType) { - if (!iconType.equals("homepage") && !iconType.equals("panel")) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid icon type. Must be 'homepage' or 'panel'.")); - } - if (file.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("error", "No file uploaded")); - } - String contentType = file.getContentType(); - if (contentType == null || !ALLOWED_IMAGE_TYPES.contains(contentType)) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid file type. Allowed: PNG, JPEG, GIF, WebP")); - } - if (file.getSize() > MAX_ICON_SIZE) { - return ResponseEntity.badRequest().body(Map.of("error", "File too large. Maximum size is 2MB.")); - } - if (!s3StorageService.isConfigured()) { - return ResponseEntity.status(503).body(Map.of("error", "File storage is not configured")); - } - if (!storageQuotaService.canUpload(server, file.getSize())) { - return ResponseEntity.badRequest().body(Map.of("error", "Storage quota exceeded")); - } - return null; - } -} +package gg.modl.backend.settings.service; + +import gg.modl.backend.server.data.Server; +import gg.modl.backend.storage.service.S3StorageService; +import gg.modl.backend.storage.service.StorageMetadataService; +import gg.modl.backend.storage.service.StorageQuotaService; +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class IconUploadService { + private final S3StorageService s3StorageService; + private final StorageQuotaService storageQuotaService; + private final StorageMetadataService storageMetadataService; + private static final Set ALLOWED_IMAGE_TYPES = Set.of( + "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp" + ); + private static final long MAX_ICON_SIZE = 2 * 1024 * 1024; + + public ResponseEntity uploadIcon(Server server, MultipartFile file, String iconType) { + ResponseEntity validationError = validateUpload(server, file, iconType); + if (validationError != null) { + return validationError; + } + + try { + String fileName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "icon"; + S3StorageService.UploadFileResult result = s3StorageService.uploadFile( + server, "icons/" + iconType, fileName, file.getContentType(), file.getBytes() + ); + StorageQuotaService.ConfirmResult confirmResult = storageQuotaService.confirmAndRecordFile( + server, result.key(), file.getSize(), file.getContentType() + ); + switch (confirmResult) { + case QUOTA_EXCEEDED -> { + s3StorageService.deleteFile(result.key()); + return ResponseEntity.badRequest().body(Map.of("error", "Storage quota exceeded")); + } + case RECORD_FAILED -> { + s3StorageService.deleteFile(result.key()); + return ResponseEntity.internalServerError().body(Map.of("error", "Failed to record upload")); + } + default -> { + } + } + return ResponseEntity.ok(Map.of("url", result.cdnUrl())); + } catch (IOException e) { + return ResponseEntity.internalServerError().body(Map.of("error", "Failed to read file")); + } catch (RuntimeException e) { + return ResponseEntity.internalServerError().body(Map.of("error", "Failed to upload file")); + } + } + + private ResponseEntity validateUpload(Server server, MultipartFile file, String iconType) { + if (!iconType.equals("homepage") && !iconType.equals("panel")) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid icon type. Must be 'homepage' or 'panel'.")); + } + if (file.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "No file uploaded")); + } + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_IMAGE_TYPES.contains(contentType)) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid file type. Allowed: PNG, JPEG, GIF, WebP")); + } + if (file.getSize() > MAX_ICON_SIZE) { + return ResponseEntity.badRequest().body(Map.of("error", "File too large. Maximum size is 2MB.")); + } + if (!s3StorageService.isConfigured()) { + return ResponseEntity.status(503).body(Map.of("error", "File storage is not configured")); + } + if (!storageQuotaService.canUpload(server, file.getSize())) { + return ResponseEntity.badRequest().body(Map.of("error", "Storage quota exceeded")); + } + return null; + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/OffenderThresholdSettingsService.java b/src/main/java/gg/modl/backend/settings/service/OffenderThresholdSettingsService.java index ba25880..9df77a7 100644 --- a/src/main/java/gg/modl/backend/settings/service/OffenderThresholdSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/OffenderThresholdSettingsService.java @@ -1,124 +1,113 @@ -package gg.modl.backend.settings.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.OffenderThresholdSettings; -import java.time.Duration; -import java.util.LinkedHashMap; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class OffenderThresholdSettingsService { - private final SettingsDocumentService settingsDocumentService; - private final ObjectMapper objectMapper; - private static final String SETTINGS_TYPE_STATUS_THRESHOLDS = "statusThresholds"; - - private final Cache thresholdCache = Caffeine.newBuilder() - .expireAfterWrite(Duration.ofSeconds(45)) - .maximumSize(500) - .build(); - private static final int MIN_THRESHOLD = 0; - private static final int MAX_THRESHOLD = 10_000; - private static final int MIN_POINT_EXPIRY_MONTHS = 1; - private static final int MAX_POINT_EXPIRY_MONTHS = 60; - - public OffenderThresholdSettings getThresholdSettings(Server server) { - return thresholdCache.get(server.getId(), id -> getThresholdSettingsState(server).data()); - } - - public VersionedSettings getThresholdSettingsState(Server server) { - SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_STATUS_THRESHOLDS); - OffenderThresholdSettings settings = mapToThresholdSettings(state.data()); - return new VersionedSettings<>(settings, state.version(), state.updatedAt()); - } - - public VersionedSettings patchThresholdSettings( - Server server, - long expectedVersion, - OffenderThresholdSettings patch - ) { - OffenderThresholdSettings current = getThresholdSettingsState(server).data(); - if (patch != null) { - if (patch.getSocial() != null) { - current.setSocial(sanitizeCategoryThresholds(patch.getSocial())); - } - if (patch.getGameplay() != null) { - current.setGameplay(sanitizeCategoryThresholds(patch.getGameplay())); - } - } - - current = normalizeSettings(current); - - Map data = codec().encode(current); - SettingsDocumentService.RawSettingsState updated; - try { - updated = settingsDocumentService.saveRawState( - server, - SETTINGS_TYPE_STATUS_THRESHOLDS, - expectedVersion, - new LinkedHashMap<>(data) - ); - } finally { - thresholdCache.invalidate(server.getId()); - } - return new VersionedSettings<>(mapToThresholdSettings(updated.data()), updated.version(), updated.updatedAt()); - } - - public OffenderThresholdSettings updateThresholdSettings(Server server, OffenderThresholdSettings newSettings) { - long expectedVersion = getThresholdSettingsState(server).version(); - return patchThresholdSettings(server, expectedVersion, newSettings).data(); - } - - private OffenderThresholdSettings mapToThresholdSettings(Map data) { - OffenderThresholdSettings mapped = codec().decode(data); - if (mapped.getSocial() == null || mapped.getGameplay() == null) { - return OffenderThresholdSettings.defaults(); - } - return normalizeSettings(mapped); - } - - private SettingsCodec codec() { - return SettingsCodec.of(objectMapper, OffenderThresholdSettings.class, OffenderThresholdSettings::defaults); - } - - private OffenderThresholdSettings normalizeSettings(OffenderThresholdSettings settings) { - OffenderThresholdSettings normalized = settings != null ? settings : OffenderThresholdSettings.defaults(); - if (normalized.getSocial() == null) { - normalized.setSocial(OffenderThresholdSettings.defaults().getSocial()); - } - if (normalized.getGameplay() == null) { - normalized.setGameplay(OffenderThresholdSettings.defaults().getGameplay()); - } - - normalized.setSocial(sanitizeCategoryThresholds(normalized.getSocial())); - normalized.setGameplay(sanitizeCategoryThresholds(normalized.getGameplay())); - return normalized; - } - - private OffenderThresholdSettings.CategoryThresholds sanitizeCategoryThresholds( - OffenderThresholdSettings.CategoryThresholds thresholds - ) { - int medium = sanitizeThresholdValue(thresholds.getMedium()); - int habitual = sanitizeThresholdValue(thresholds.getHabitual()); - int pointExpiryMonths = sanitizePointExpiryMonths(thresholds.getPointExpiryMonths()); - if (habitual < medium) { - habitual = medium; - } - return new OffenderThresholdSettings.CategoryThresholds(medium, habitual, pointExpiryMonths); - } - - private int sanitizeThresholdValue(int value) { - return Math.max(MIN_THRESHOLD, Math.min(MAX_THRESHOLD, value)); - } - - private int sanitizePointExpiryMonths(int value) { - return Math.max(MIN_POINT_EXPIRY_MONTHS, Math.min(MAX_POINT_EXPIRY_MONTHS, value)); - } -} +package gg.modl.backend.settings.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.OffenderThresholdSettings; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.stereotype.Service; + +@Service +public class OffenderThresholdSettingsService { + private static final String SETTINGS_TYPE_STATUS_THRESHOLDS = "statusThresholds"; + private static final int MIN_THRESHOLD = 0; + private static final int MAX_THRESHOLD = 10_000; + private static final int MIN_POINT_EXPIRY_MONTHS = 1; + private static final int MAX_POINT_EXPIRY_MONTHS = 60; + + private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + private final Cache thresholdCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(45)) + .maximumSize(500) + .build(); + + public OffenderThresholdSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_STATUS_THRESHOLDS, this::mapToThresholdSettings); + } + + public OffenderThresholdSettings getThresholdSettings(Server server) { + return thresholdCache.get(server.getId(), id -> support.get(server)); + } + + public VersionedSettings getThresholdSettingsState(Server server) { + return support.state(server); + } + + public VersionedSettings patchThresholdSettings( + Server server, + long expectedVersion, + OffenderThresholdSettings patch + ) { + OffenderThresholdSettings current = support.state(server).data(); + if (patch != null) { + if (patch.getSocial() != null) { + current.setSocial(sanitizeCategoryThresholds(patch.getSocial())); + } + if (patch.getGameplay() != null) { + current.setGameplay(sanitizeCategoryThresholds(patch.getGameplay())); + } + } + + current = normalizeSettings(current); + + Map data = codec().encode(current); + try { + return support.save(server, expectedVersion, new LinkedHashMap<>(data)); + } finally { + thresholdCache.invalidate(server.getId()); + } + } + + private OffenderThresholdSettings mapToThresholdSettings(Map data) { + OffenderThresholdSettings mapped = codec().decode(data); + if (mapped.getSocial() == null || mapped.getGameplay() == null) { + return OffenderThresholdSettings.defaults(); + } + return normalizeSettings(mapped); + } + + private SettingsCodec codec() { + return SettingsCodec.of(objectMapper, OffenderThresholdSettings.class, OffenderThresholdSettings::defaults); + } + + private OffenderThresholdSettings normalizeSettings(OffenderThresholdSettings settings) { + OffenderThresholdSettings normalized = settings != null ? settings : OffenderThresholdSettings.defaults(); + if (normalized.getSocial() == null) { + normalized.setSocial(OffenderThresholdSettings.defaults().getSocial()); + } + if (normalized.getGameplay() == null) { + normalized.setGameplay(OffenderThresholdSettings.defaults().getGameplay()); + } + + normalized.setSocial(sanitizeCategoryThresholds(normalized.getSocial())); + normalized.setGameplay(sanitizeCategoryThresholds(normalized.getGameplay())); + return normalized; + } + + private OffenderThresholdSettings.CategoryThresholds sanitizeCategoryThresholds( + OffenderThresholdSettings.CategoryThresholds thresholds + ) { + int medium = sanitizeThresholdValue(thresholds.getMedium()); + int habitual = sanitizeThresholdValue(thresholds.getHabitual()); + int pointExpiryMonths = sanitizePointExpiryMonths(thresholds.getPointExpiryMonths()); + if (habitual < medium) { + habitual = medium; + } + return new OffenderThresholdSettings.CategoryThresholds(medium, habitual, pointExpiryMonths); + } + + private int sanitizeThresholdValue(int value) { + return Math.max(MIN_THRESHOLD, Math.min(MAX_THRESHOLD, value)); + } + + private int sanitizePointExpiryMonths(int value) { + return Math.max(MIN_POINT_EXPIRY_MONTHS, Math.min(MAX_POINT_EXPIRY_MONTHS, value)); + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/PunishmentTypeService.java b/src/main/java/gg/modl/backend/settings/service/PunishmentTypeService.java index 99cd112..d4a005c 100644 --- a/src/main/java/gg/modl/backend/settings/service/PunishmentTypeService.java +++ b/src/main/java/gg/modl/backend/settings/service/PunishmentTypeService.java @@ -7,6 +7,7 @@ import gg.modl.backend.server.data.Server; import gg.modl.backend.server.service.ServerTimestampService; import gg.modl.backend.settings.data.DefaultPunishmentTypes; +import gg.modl.backend.settings.data.PunishmentCategory; import gg.modl.backend.settings.data.PunishmentType; import gg.modl.backend.settings.data.Settings; import java.time.Duration; @@ -14,13 +15,11 @@ import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; import java.util.Objects; @Service -@Slf4j @RequiredArgsConstructor public class PunishmentTypeService { private final SettingsRepositoryAccess settingsRepositoryAccess; @@ -33,12 +32,6 @@ public class PunishmentTypeService { .maximumSize(500) .build(); - public Optional getPunishmentTypeById(@NotNull Server server, int id) { - return getPunishmentTypes(server).stream() - .filter(pt -> pt.getId() != null && pt.getId() == id) - .findFirst(); - } - public List getPunishmentTypes(@NotNull Server server) { return typesCache.get(server.getId(), id -> loadOrInitializeTypes(server)); } @@ -76,7 +69,7 @@ private List persistPunishmentTypes(@NotNull Server server, @Not } public PunishmentType updatePunishmentType(@NotNull Server server, int ordinal, @NotNull PunishmentType updatedType) { - if (ordinal == 0 || ordinal == 5) { + if (ordinal == PunishmentCategory.MIN_CORE_ORDINAL || ordinal == PunishmentCategory.MAX_CORE_ORDINAL) { throw new IllegalArgumentException("Kick and Blacklist punishment types cannot be configured"); } @@ -121,7 +114,7 @@ public PunishmentType updatePunishmentType(@NotNull Server server, int ordinal, } public boolean deletePunishmentType(@NotNull Server server, int ordinal) { - if (ordinal < 6) { + if (ordinal <= PunishmentCategory.MAX_CORE_ORDINAL) { throw new IllegalArgumentException("Cannot delete core administrative punishment types"); } diff --git a/src/main/java/gg/modl/backend/settings/service/QuickResponseSettingsService.java b/src/main/java/gg/modl/backend/settings/service/QuickResponseSettingsService.java index cea0539..75bc1ba 100644 --- a/src/main/java/gg/modl/backend/settings/service/QuickResponseSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/QuickResponseSettingsService.java @@ -7,26 +7,27 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Service -@RequiredArgsConstructor -@Slf4j public class QuickResponseSettingsService { - private final SettingsDocumentService settingsDocumentService; - private final ObjectMapper objectMapper; private static final String SETTINGS_TYPE_QUICK_RESPONSES = "quickResponses"; + private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + public QuickResponseSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_QUICK_RESPONSES, this::mapToQuickResponseSettings); + } + public QuickResponseSettings getQuickResponseSettings(Server server) { - return getQuickResponseSettingsState(server).data(); + return support.get(server); } public VersionedSettings getQuickResponseSettingsState(Server server) { - SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_QUICK_RESPONSES); - QuickResponseSettings settings = mapToQuickResponseSettings(state.data()); - return new VersionedSettings<>(settings, state.version(), state.updatedAt()); + return support.state(server); } public QuickResponseSettings.Action findAction(QuickResponseSettings settings, String categoryId, String actionId) { @@ -55,24 +56,13 @@ public VersionedSettings patchQuickResponseSettings( long expectedVersion, UpdateQuickResponsesRequest quickResponses ) { - QuickResponseSettings currentSettings = getQuickResponseSettings(server); + QuickResponseSettings currentSettings = support.get(server); if (quickResponses != null && quickResponses.categories() != null) { currentSettings.setCategories(quickResponses.categories()); } Map data = codec().encode(currentSettings); - SettingsDocumentService.RawSettingsState updated = settingsDocumentService.saveRawState( - server, - SETTINGS_TYPE_QUICK_RESPONSES, - expectedVersion, - new LinkedHashMap<>(data) - ); - return new VersionedSettings<>(mapToQuickResponseSettings(updated.data()), updated.version(), updated.updatedAt()); - } - - public void updateQuickResponseSettings(Server server, UpdateQuickResponsesRequest quickResponses) { - long expectedVersion = getQuickResponseSettingsState(server).version(); - patchQuickResponseSettings(server, expectedVersion, quickResponses); + return support.save(server, expectedVersion, new LinkedHashMap<>(data)); } private QuickResponseSettings mapToQuickResponseSettings(Map data) { diff --git a/src/main/java/gg/modl/backend/settings/service/ReplayRetentionSettingsService.java b/src/main/java/gg/modl/backend/settings/service/ReplayRetentionSettingsService.java index 50c7c66..8647d55 100644 --- a/src/main/java/gg/modl/backend/settings/service/ReplayRetentionSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/ReplayRetentionSettingsService.java @@ -6,28 +6,29 @@ import gg.modl.backend.settings.data.ReplayRetentionSettings; import java.util.LinkedHashMap; import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Service -@RequiredArgsConstructor -@Slf4j public class ReplayRetentionSettingsService { private static final String SETTINGS_TYPE_REPLAY_RETENTION = "replayRetention"; private static final int MIN_DAYS = 1; private static final int MAX_DAYS = 365; - private final SettingsDocumentService settingsDocumentService; private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + public ReplayRetentionSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_REPLAY_RETENTION, this::mapToSettings); + } public ReplayRetentionSettings getReplayRetentionSettings(Server server) { - return getReplayRetentionSettingsState(server).data(); + return support.get(server); } public VersionedSettings getReplayRetentionSettingsState(Server server) { - SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_REPLAY_RETENTION); - return new VersionedSettings<>(mapToSettings(state.data()), state.version(), state.updatedAt()); + return support.state(server); } public VersionedSettings patchReplayRetentionSettings( @@ -36,8 +37,7 @@ public VersionedSettings patchReplayRetentionSettings( Boolean enabled, Integer days ) { - SettingsDocumentService.RawSettingsState current = settingsDocumentService.getRawState(server, SETTINGS_TYPE_REPLAY_RETENTION); - Map data = new LinkedHashMap<>(current.data()); + Map data = support.currentData(server); ReplayRetentionSettings merged = mapToSettings(data); if (enabled != null) { @@ -51,13 +51,7 @@ public VersionedSettings patchReplayRetentionSettings( data.put("enabled", normalized.isEnabled()); data.put("days", normalized.getDays()); - SettingsDocumentService.RawSettingsState updated = settingsDocumentService.saveRawState( - server, - SETTINGS_TYPE_REPLAY_RETENTION, - expectedVersion, - data - ); - return new VersionedSettings<>(mapToSettings(updated.data()), updated.version(), updated.updatedAt()); + return support.save(server, expectedVersion, data); } private ReplayRetentionSettings mapToSettings(Map data) { diff --git a/src/main/java/gg/modl/backend/settings/service/SettingsConflictException.java b/src/main/java/gg/modl/backend/settings/service/SettingsConflictException.java index 7fe269a..810c1b3 100644 --- a/src/main/java/gg/modl/backend/settings/service/SettingsConflictException.java +++ b/src/main/java/gg/modl/backend/settings/service/SettingsConflictException.java @@ -1,14 +1,14 @@ -package gg.modl.backend.settings.service; - -import gg.modl.backend.infrastructure.exception.ConflictException; -import lombok.Getter; - -@Getter -public class SettingsConflictException extends ConflictException { - private final long currentVersion; - - public SettingsConflictException(String message, long currentVersion) { - super(message); - this.currentVersion = currentVersion; - } -} +package gg.modl.backend.settings.service; + +import gg.modl.backend.infrastructure.exception.ConflictException; +import lombok.Getter; + +@Getter +public class SettingsConflictException extends ConflictException { + private final long currentVersion; + + public SettingsConflictException(String message, long currentVersion) { + super(message); + this.currentVersion = currentVersion; + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/SettingsDocumentService.java b/src/main/java/gg/modl/backend/settings/service/SettingsDocumentService.java index c819968..207334c 100644 --- a/src/main/java/gg/modl/backend/settings/service/SettingsDocumentService.java +++ b/src/main/java/gg/modl/backend/settings/service/SettingsDocumentService.java @@ -1,104 +1,108 @@ -package gg.modl.backend.settings.service; - +package gg.modl.backend.settings.service; + import gg.modl.backend.database.mongo.repository.SettingsMongoRepository; import gg.modl.backend.infrastructure.util.MongoKeyUtils; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.Settings; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class SettingsDocumentService { - private final SettingsMongoRepository settingsRepository; - private static final long INITIAL_VERSION = 0L; - - public RawSettingsState saveRawState(Server server, String type, long expectedVersion, Map data) { - Settings current = findLatestSettingsDocument(server, type); - RawSettingsState currentState = toRawState(current); - - if (currentState.version() != expectedVersion) { - throwConflict(currentState.version()); - } - +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.Settings; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SettingsDocumentService { + private final SettingsMongoRepository settingsRepository; + private static final long INITIAL_VERSION = 0L; + + public RawSettingsState saveRawState(Server server, String type, long expectedVersion, Map data) { + Settings current = findLatestSettingsDocument(server, type); + RawSettingsState currentState = toRawState(current); + + if (currentState.version() != expectedVersion) { + throwConflict(currentState.version()); + } + Map normalizedData = data == null ? new LinkedHashMap<>() : MongoKeyUtils.sanitizeKeys(data); - Date now = new Date(); - - if (!currentState.exists()) { - try { - Settings inserted = new Settings(null, type, normalizedData, expectedVersion + 1, now); - settingsRepository.saveEntity(server, inserted); - return new RawSettingsState(normalizedData, expectedVersion + 1, now, true); - } catch (org.springframework.dao.DuplicateKeyException duplicateKeyException) { - throwConflict(getRawState(server, type).version()); - } - } - - if (currentState.data().equals(normalizedData)) { - return currentState; - } - - boolean updated = settingsRepository.updateWithVersionCheck( - server, current.getId(), expectedVersion, type, normalizedData, expectedVersion + 1, now); - if (!updated) { - throwConflict(getRawState(server, type).version()); - } - - return new RawSettingsState(normalizedData, expectedVersion + 1, now, true); - } - - public RawSettingsState getRawState(Server server, String type) { - Settings settings = findLatestSettingsDocument(server, type); - return toRawState(settings); - } - - private void throwConflict(long currentVersion) { - throw new SettingsConflictException( - "Settings were modified by another user. Reload and retry.", - currentVersion - ); - } - - private Settings findLatestSettingsDocument(Server server, String type) { - List matches = settingsRepository.findLatestByType(server, type, 2); - if (matches.size() > 1) { - log.warn( - "Detected duplicate settings documents for type '{}'. Using latest id '{}'.", - type, - matches.get(0).getId() - ); - } - return matches.isEmpty() ? null : matches.get(0); - } - - @SuppressWarnings("unchecked") - private RawSettingsState toRawState(Settings settings) { - if (settings == null) { - return new RawSettingsState(new LinkedHashMap<>(), INITIAL_VERSION, null, false); - } - - Map mappedData = new LinkedHashMap<>(); - if (settings.getData() instanceof Map rawMap) { - mappedData.putAll((Map) rawMap); - } - - long version = settings.getVersion() != null ? settings.getVersion() : INITIAL_VERSION; - return new RawSettingsState(mappedData, version, settings.getUpdatedAt(), true); - } - - public record RawSettingsState( - Map data, - long version, - Date updatedAt, - boolean exists - ) { - } -} + Date now = new Date(); + + if (!currentState.exists()) { + try { + Settings inserted = new Settings(null, type, normalizedData, expectedVersion + 1, now); + settingsRepository.saveEntity(server, inserted); + return new RawSettingsState(normalizedData, expectedVersion + 1, now, true); + } catch (org.springframework.dao.DuplicateKeyException duplicateKeyException) { + throwConflict(getRawState(server, type).version()); + } + } + + if (currentState.data().equals(normalizedData)) { + return currentState; + } + + boolean updated = settingsRepository.updateWithVersionCheck( + server, current.getId(), expectedVersion, type, normalizedData, expectedVersion + 1, now); + if (!updated) { + throwConflict(getRawState(server, type).version()); + } + + return new RawSettingsState(normalizedData, expectedVersion + 1, now, true); + } + + public RawSettingsState getRawState(Server server, String type) { + Settings settings = findLatestSettingsDocument(server, type); + return toRawState(settings); + } + + public void deleteState(Server server, String type) { + settingsRepository.removeByType(server, type); + } + + private void throwConflict(long currentVersion) { + throw new SettingsConflictException( + "Settings were modified by another user. Reload and retry.", + currentVersion + ); + } + + private Settings findLatestSettingsDocument(Server server, String type) { + List matches = settingsRepository.findLatestByType(server, type, 2); + if (matches.size() > 1) { + log.warn( + "Detected duplicate settings documents for type '{}'. Using latest id '{}'.", + type, + matches.get(0).getId() + ); + } + return matches.isEmpty() ? null : matches.get(0); + } + + @SuppressWarnings("unchecked") + private RawSettingsState toRawState(Settings settings) { + if (settings == null) { + return new RawSettingsState(new LinkedHashMap<>(), INITIAL_VERSION, null, false); + } + + Map mappedData = new LinkedHashMap<>(); + if (settings.getData() instanceof Map rawMap) { + mappedData.putAll((Map) rawMap); + } + + long version = settings.getVersion() != null ? settings.getVersion() : INITIAL_VERSION; + return new RawSettingsState(mappedData, version, settings.getUpdatedAt(), true); + } + + public record RawSettingsState( + Map data, + long version, + Date updatedAt, + boolean exists + ) { + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/SettingsRepositoryAccess.java b/src/main/java/gg/modl/backend/settings/service/SettingsRepositoryAccess.java index 9b9197e..5f310af 100644 --- a/src/main/java/gg/modl/backend/settings/service/SettingsRepositoryAccess.java +++ b/src/main/java/gg/modl/backend/settings/service/SettingsRepositoryAccess.java @@ -4,7 +4,6 @@ import gg.modl.backend.infrastructure.util.MongoKeyUtils; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.Settings; -import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -18,23 +17,7 @@ public Optional findSettings(Server server, String type) { return settingsRepository.findByType(server, type); } - public void upsertSettings(Server server, String type, Map data) { - settingsRepository.upsertData(server, type, MongoKeyUtils.sanitizeKeys(data)); - } - public void upsertListSettings(Server server, String type, Object data) { settingsRepository.upsertListData(server, type, MongoKeyUtils.sanitizeValue(data)); } - - public void updateDataSettings(Server server, String type, Map data) { - settingsRepository.updateDataByType(server, type, MongoKeyUtils.sanitizeKeys(data)); - } - - public void removeSettings(Server server, String type) { - settingsRepository.removeByType(server, type); - } - - public void saveEntity(Server server, Settings settings) { - settingsRepository.saveEntity(server, settings); - } } diff --git a/src/main/java/gg/modl/backend/settings/service/TicketFormSettingsService.java b/src/main/java/gg/modl/backend/settings/service/TicketFormSettingsService.java index 64c2e0a..ffd1b44 100644 --- a/src/main/java/gg/modl/backend/settings/service/TicketFormSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/TicketFormSettingsService.java @@ -1,168 +1,140 @@ -package gg.modl.backend.settings.service; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.TicketFormSettings; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class TicketFormSettingsService { - private final SettingsDocumentService settingsDocumentService; - private final ObjectMapper objectMapper; - private static final String SETTINGS_TYPE_TICKET_FORMS = "ticketForms"; - - public VersionedSettings patchTicketFormSettings( - Server server, - long expectedVersion, - TicketFormSettings newSettings - ) { - TicketFormSettings merged = newSettings != null ? newSettings : getDefaultTicketFormSettings(); - ensureFormDefaults(merged); - Map data = codec().encode(merged); - - SettingsDocumentService.RawSettingsState updated = settingsDocumentService.saveRawState( - server, - SETTINGS_TYPE_TICKET_FORMS, - expectedVersion, - new LinkedHashMap<>(data) - ); - - return new VersionedSettings<>(mapToTicketFormSettings(updated.data()), updated.version(), updated.updatedAt()); - } - - public TicketFormSettings updateTicketFormSettings(Server server, TicketFormSettings newSettings) { - long expectedVersion = getTicketFormSettingsState(server).version(); - return patchTicketFormSettings(server, expectedVersion, newSettings).data(); - } - - public TicketFormSettings.TicketForm getFormByType(Server server, String formType) { - TicketFormSettings settings = getTicketFormSettings(server); - - return switch (formType.toLowerCase()) { - case "bug" -> settings.getBug(); - case "support" -> settings.getSupport(); - case "application", "staff" -> settings.getApplication(); - case "player" -> settings.getPlayer(); - case "chat" -> settings.getChat(); - default -> null; - }; - } - - public TicketFormSettings getTicketFormSettings(Server server) { - return getTicketFormSettingsState(server).data(); - } - - public VersionedSettings getTicketFormSettingsState(Server server) { - SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_TICKET_FORMS); - TicketFormSettings settings = mapToTicketFormSettings(state.data()); - return new VersionedSettings<>(settings, state.version(), state.updatedAt()); - } - - private TicketFormSettings mapToTicketFormSettings(Map data) { - TicketFormSettings mapped = codec().decode(data); - ensureFormDefaults(mapped); - return mapped; - } - - private SettingsCodec codec() { - return SettingsCodec.of(objectMapper, TicketFormSettings.class, this::getDefaultTicketFormSettings); - } - - private void ensureFormDefaults(TicketFormSettings settings) { - if (settings.getBug() == null) { - settings.setBug(emptyForm()); - } - if (settings.getSupport() == null) { - settings.setSupport(emptyForm()); - } - if (settings.getApplication() == null) { - settings.setApplication(emptyForm()); - } - if (settings.getPlayer() == null) { - settings.setPlayer(emptyForm()); - } - if (settings.getChat() == null) { - settings.setChat(emptyForm()); - } - - for (TicketFormSettings.TicketForm form : List.of( - settings.getBug(), settings.getSupport(), settings.getApplication(), - settings.getPlayer(), settings.getChat() - )) { - sanitizeForm(form); - } - } - - private void sanitizeForm(TicketFormSettings.TicketForm form) { - if (form.getFields() == null) { - form.setFields(new ArrayList<>()); - } - if (form.getSections() == null) { - form.setSections(new ArrayList<>()); - } - if (form.getAllowEmailNotifications() == null) { - form.setAllowEmailNotifications(true); - } - } - - private TicketFormSettings getDefaultTicketFormSettings() { - return TicketFormSettings.builder() - .bug(emptyForm()) - .support(emptyForm()) - .application(emptyForm()) - .player(emptyForm()) - .chat(emptyForm()) - .build(); - } - - private TicketFormSettings.TicketForm emptyForm() { - return TicketFormSettings.TicketForm.builder() - .allowEmailNotifications(true) - .fields(new ArrayList<>()) - .sections(new ArrayList<>()) - .build(); - } - - public TicketFormSettings updateFormByType(Server server, String formType, TicketFormSettings.TicketForm form) { - TicketFormSettings settings = getTicketFormSettings(server); - - switch (formType.toLowerCase()) { - case "bug" -> settings.setBug(form); - case "support" -> settings.setSupport(form); - case "application", "staff" -> settings.setApplication(form); - case "player" -> settings.setPlayer(form); - case "chat" -> settings.setChat(form); - default -> { - // no-op for unknown form type - } - } - - return updateTicketFormSettings(server, settings); - } - - public Map buildTicketFormsResponse(TicketFormSettings ticketForms) { - Map forms = new HashMap<>(); - putFormIfNotNull(forms, "bug", ticketForms.getBug()); - putFormIfNotNull(forms, "support", ticketForms.getSupport()); - putFormIfNotNull(forms, "application", ticketForms.getApplication()); - putFormIfNotNull(forms, "player", ticketForms.getPlayer()); - putFormIfNotNull(forms, "chat", ticketForms.getChat()); - return forms; - } - - private void putFormIfNotNull(Map forms, String key, TicketFormSettings.TicketForm form) { - if (form != null) { - forms.put(key, objectMapper.convertValue(form, new TypeReference>() {})); - } - } -} +package gg.modl.backend.settings.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.TicketFormSettings; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.stereotype.Service; + +@Service +public class TicketFormSettingsService { + private static final String SETTINGS_TYPE_TICKET_FORMS = "ticketForms"; + + private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + public TicketFormSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_TICKET_FORMS, this::mapToTicketFormSettings); + } + + public VersionedSettings patchTicketFormSettings( + Server server, + long expectedVersion, + TicketFormSettings newSettings + ) { + TicketFormSettings merged = newSettings != null ? newSettings : getDefaultTicketFormSettings(); + ensureFormDefaults(merged); + Map data = codec().encode(merged); + + return support.save(server, expectedVersion, new LinkedHashMap<>(data)); + } + + public TicketFormSettings.TicketForm getFormByType(Server server, String formType) { + TicketFormSettings settings = getTicketFormSettings(server); + + return switch (formType.toLowerCase()) { + case "bug" -> settings.getBug(); + case "support" -> settings.getSupport(); + case "application", "staff" -> settings.getApplication(); + case "player" -> settings.getPlayer(); + case "chat" -> settings.getChat(); + default -> null; + }; + } + + public TicketFormSettings getTicketFormSettings(Server server) { + return support.get(server); + } + + public VersionedSettings getTicketFormSettingsState(Server server) { + return support.state(server); + } + + private TicketFormSettings mapToTicketFormSettings(Map data) { + TicketFormSettings mapped = codec().decode(data); + ensureFormDefaults(mapped); + return mapped; + } + + private SettingsCodec codec() { + return SettingsCodec.of(objectMapper, TicketFormSettings.class, this::getDefaultTicketFormSettings); + } + + private void ensureFormDefaults(TicketFormSettings settings) { + if (settings.getBug() == null) { + settings.setBug(emptyForm()); + } + if (settings.getSupport() == null) { + settings.setSupport(emptyForm()); + } + if (settings.getApplication() == null) { + settings.setApplication(emptyForm()); + } + if (settings.getPlayer() == null) { + settings.setPlayer(emptyForm()); + } + if (settings.getChat() == null) { + settings.setChat(emptyForm()); + } + + for (TicketFormSettings.TicketForm form : List.of( + settings.getBug(), settings.getSupport(), settings.getApplication(), + settings.getPlayer(), settings.getChat() + )) { + sanitizeForm(form); + } + } + + private void sanitizeForm(TicketFormSettings.TicketForm form) { + if (form.getFields() == null) { + form.setFields(new ArrayList<>()); + } + if (form.getSections() == null) { + form.setSections(new ArrayList<>()); + } + if (form.getAllowEmailNotifications() == null) { + form.setAllowEmailNotifications(true); + } + } + + private TicketFormSettings getDefaultTicketFormSettings() { + return TicketFormSettings.builder() + .bug(emptyForm()) + .support(emptyForm()) + .application(emptyForm()) + .player(emptyForm()) + .chat(emptyForm()) + .build(); + } + + private TicketFormSettings.TicketForm emptyForm() { + return TicketFormSettings.TicketForm.builder() + .allowEmailNotifications(true) + .fields(new ArrayList<>()) + .sections(new ArrayList<>()) + .build(); + } + + public Map buildTicketFormsResponse(TicketFormSettings ticketForms) { + Map forms = new HashMap<>(); + putFormIfNotNull(forms, "bug", ticketForms.getBug()); + putFormIfNotNull(forms, "support", ticketForms.getSupport()); + putFormIfNotNull(forms, "application", ticketForms.getApplication()); + putFormIfNotNull(forms, "player", ticketForms.getPlayer()); + putFormIfNotNull(forms, "chat", ticketForms.getChat()); + return forms; + } + + private void putFormIfNotNull(Map forms, String key, TicketFormSettings.TicketForm form) { + if (form != null) { + forms.put(key, objectMapper.convertValue(form, new TypeReference>() {})); + } + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/TicketLabelSettingsService.java b/src/main/java/gg/modl/backend/settings/service/TicketLabelSettingsService.java index 13eb2cd..0087cab 100644 --- a/src/main/java/gg/modl/backend/settings/service/TicketLabelSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/TicketLabelSettingsService.java @@ -14,16 +14,12 @@ import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Service -@RequiredArgsConstructor @Slf4j public class TicketLabelSettingsService { - private final SettingsDocumentService settingsDocumentService; - private final ObjectMapper objectMapper; private static final String SETTINGS_TYPE_TICKET_LABELS = "ticketLabels"; private static final int MAX_LABELS = 100; private static final int MAX_LABEL_ID_LENGTH = 64; @@ -31,14 +27,21 @@ public class TicketLabelSettingsService { private static final int MAX_LABEL_DESCRIPTION_LENGTH = 512; private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"); + private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + public TicketLabelSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_TICKET_LABELS, this::mapToTicketLabelSettings); + } + public TicketLabelSettings getTicketLabelSettings(Server server) { - return getTicketLabelSettingsState(server).data(); + return support.get(server); } public VersionedSettings getTicketLabelSettingsState(Server server) { - SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_TICKET_LABELS); - TicketLabelSettings settings = mapToTicketLabelSettings(state.data()); - return new VersionedSettings<>(settings, state.version(), state.updatedAt()); + return support.state(server); } public VersionedSettings patchTicketLabelSettings( @@ -46,8 +49,7 @@ public VersionedSettings patchTicketLabelSettings( long expectedVersion, List