diff --git a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java index f3d2423..6fac873 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java +++ b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java @@ -47,6 +47,7 @@ public class SecurityConfig { .requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) + .requestMatchers("/api/history/admin/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue()) .requestMatchers("/api/login/userLogin/**").permitAll() .anyRequest().permitAll() ) diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java index afa1547..76ce05c 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/history/HistoryApiController.java @@ -1,6 +1,7 @@ package com.alttd.altitudeweb.controllers.history; import com.alttd.altitudeweb.api.HistoryApi; +import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid; import com.alttd.altitudeweb.services.limits.RateLimit; import com.alttd.altitudeweb.model.HistoryCountDto; import com.alttd.altitudeweb.model.PunishmentHistoryListDto; @@ -10,7 +11,8 @@ import com.alttd.altitudeweb.database.litebans.*; import com.alttd.altitudeweb.model.PunishmentHistoryDto; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.UUID; @@ -229,4 +231,109 @@ public class HistoryApiController implements HistoryApi { .type(type) .id(historyRecord.getId()); } + + // Admin edit endpoints (restricted to head_mod scope) + @Override + @PreAuthorize("hasAuthority('SCOPE_head_mod')") + public ResponseEntity updatePunishmentReason(String type, Integer id, String reason) { + HistoryType historyTypeEnum = HistoryType.getHistoryType(type); + CompletableFuture result = new CompletableFuture<>(); + + Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> { + try { + IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class); + EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class); + HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id); + if (before == null) { + result.complete(null); + return; + } + int changed = editMapper.setReason(historyTypeEnum, id, reason); + HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id); + UUID actor = AuthenticatedUuid.getAuthenticatedUserUuid(); + log.info("[Punishment Edit] Actor={} Type={} Id={} Reason: '{}' -> '{}' (rows={})", + actor, historyTypeEnum, id, before.getReason(), after != null ? after.getReason() : null, changed); + result.complete(after != null ? mapPunishmentHistory(after) : null); + } catch (Exception e) { + log.error("Failed to update reason for {} id {}", type, id, e); + result.completeExceptionally(e); + } + }); + PunishmentHistoryDto body = result.join(); + if (body == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(body); + } + + @Override + @PreAuthorize("hasAuthority('SCOPE_head_mod')") + public ResponseEntity updatePunishmentUntil(String type, Integer id, Long until) { + HistoryType historyTypeEnum = HistoryType.getHistoryType(type); + CompletableFuture result = new CompletableFuture<>(); + + Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> { + try { + IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class); + EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class); + HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id); + if (before == null) { + result.complete(null); + return; + } + int changed = editMapper.setUntil(historyTypeEnum, id, until); + HistoryRecord after = idMapper.getRecentHistory(historyTypeEnum, id); + UUID actor = AuthenticatedUuid.getAuthenticatedUserUuid(); + log.info("[Punishment Edit] Actor={} Type={} Id={} Until: '{}' -> '{}' (rows={})", + actor, historyTypeEnum, id, before.getUntil(), after != null ? after.getUntil() : null, changed); + result.complete(after != null ? mapPunishmentHistory(after) : null); + } catch (IllegalArgumentException e) { + log.warn("Invalid until edit for type {} id {}: {}", type, id, e.getMessage()); + result.complete(null); + } catch (Exception e) { + log.error("Failed to update until for {} id {}", type, id, e); + result.completeExceptionally(e); + } + }); + PunishmentHistoryDto body = result.join(); + if (body == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(body); + } + + @Override + @PreAuthorize("hasAuthority('SCOPE_head_mod')") + public ResponseEntity removePunishment(String type, Integer id) { + HistoryType historyTypeEnum = HistoryType.getHistoryType(type); + CompletableFuture result = new CompletableFuture<>(); + + Connection.getConnection(Databases.LITE_BANS).runQuery(sqlSession -> { + try { + IdHistoryMapper idMapper = sqlSession.getMapper(IdHistoryMapper.class); + EditHistoryMapper editMapper = sqlSession.getMapper(EditHistoryMapper.class); + HistoryRecord before = idMapper.getRecentHistory(historyTypeEnum, id); + if (before == null) { + result.complete(false); + return; + } + UUID actorUuid = AuthenticatedUuid.getAuthenticatedUserUuid(); + String actorName = sqlSession.getMapper(RecentNamesMapper.class).getUsername(actorUuid.toString()); + int changed = editMapper.remove(historyTypeEnum, id); + log.info("[Punishment Remove] Actor={} ({}) Type={} Id={} Before(active={} removedBy={} reason='{}') (rows={})", + actorName, actorUuid, historyTypeEnum, id, + before.getRemovedByName() == null ? 1 : 0, before.getRemovedByName(), before.getRemovedByReason(), + changed); + result.complete(changed > 0); + } catch (Exception e) { + log.error("Failed to remove punishment for {} id {}", type, id, e); + result.completeExceptionally(e); + } + }); + Boolean ok = result.join(); + if (ok == null || !ok) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.noContent().build(); + } } diff --git a/database/src/main/java/com/alttd/altitudeweb/database/litebans/EditHistoryMapper.java b/database/src/main/java/com/alttd/altitudeweb/database/litebans/EditHistoryMapper.java new file mode 100644 index 0000000..32835c0 --- /dev/null +++ b/database/src/main/java/com/alttd/altitudeweb/database/litebans/EditHistoryMapper.java @@ -0,0 +1,64 @@ +package com.alttd.altitudeweb.database.litebans; + +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; +import org.jetbrains.annotations.NotNull; + +public interface EditHistoryMapper { + + @Update(""" + UPDATE ${table_name} + SET reason = #{reason} + WHERE id = #{id} + """) + int updateReason(@Param("table_name") String tableName, + @Param("id") int id, + @Param("reason") String reason); + + @Update(""" + UPDATE ${table_name} + SET until = #{until} + WHERE id = #{id} + """) + int updateUntil(@Param("table_name") String tableName, + @Param("id") int id, + @Param("until") Long until); + + @Delete(""" + DELETE FROM ${table_name} + WHERE id = #{id} + """) + int deletePunishment(@Param("table_name") String tableName, + @Param("id") int id); + + default int setReason(@NotNull HistoryType type, int id, String reason) { + return switch (type) { + case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported"); + case BAN -> updateReason("litebans_bans", id, reason); + case MUTE -> updateReason("litebans_mutes", id, reason); + case KICK -> updateReason("litebans_kicks", id, reason); + case WARN -> updateReason("litebans_warnings", id, reason); + }; + } + + default int setUntil(@NotNull HistoryType type, int id, Long until) { + return switch (type) { + case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported"); + case BAN -> updateUntil("litebans_bans", id, until); + case MUTE -> updateUntil("litebans_mutes", id, until); + case KICK -> throw new IllegalArgumentException("KICK has no until"); + case WARN -> throw new IllegalArgumentException("WARN has no until"); + }; + } + + default int remove(@NotNull HistoryType type, int id) { + return switch (type) { + case ALL -> throw new IllegalArgumentException("HistoryType.ALL is not supported"); + case BAN -> deletePunishment("litebans_bans", id); + case MUTE -> deletePunishment("litebans_mutes", id); + case KICK -> deletePunishment("litebans_kicks", id); + case WARN -> deletePunishment("litebans_warnings", id); + }; + } +} diff --git a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeLiteBans.java b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeLiteBans.java index 50fd3c5..dfbc8f0 100644 --- a/database/src/main/java/com/alttd/altitudeweb/setup/InitializeLiteBans.java +++ b/database/src/main/java/com/alttd/altitudeweb/setup/InitializeLiteBans.java @@ -19,6 +19,7 @@ public class InitializeLiteBans { configuration.addMapper(UUIDHistoryMapper.class); configuration.addMapper(HistoryCountMapper.class); configuration.addMapper(IdHistoryMapper.class); + configuration.addMapper(EditHistoryMapper.class); }).join() .runQuery(sqlSession -> { createAllPunishmentsView(sqlSession); diff --git a/frontend/src/app/pages/reference/bans/edit-punishment-dialog/edit-punishment-dialog.component.html b/frontend/src/app/pages/reference/bans/edit-punishment-dialog/edit-punishment-dialog.component.html new file mode 100644 index 0000000..b773dc9 --- /dev/null +++ b/frontend/src/app/pages/reference/bans/edit-punishment-dialog/edit-punishment-dialog.component.html @@ -0,0 +1,27 @@ +

Edit {{ data.punishment.type }} #{{ data.punishment.id }}

+
+
+ + Reason + + + + Permanent + + @if (!isPermanent) { + + Expires + + + } + + @if (errorMessage()) { +
{{ errorMessage() }}
+ } +
+
+
+ + + +
diff --git a/frontend/src/app/pages/reference/bans/edit-punishment-dialog/edit-punishment-dialog.component.scss b/frontend/src/app/pages/reference/bans/edit-punishment-dialog/edit-punishment-dialog.component.scss new file mode 100644 index 0000000..10e3fd2 --- /dev/null +++ b/frontend/src/app/pages/reference/bans/edit-punishment-dialog/edit-punishment-dialog.component.scss @@ -0,0 +1,10 @@ +.dialog-content { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.error { + color: #d32f2f; /* Material error color */ +} diff --git a/frontend/src/app/pages/reference/bans/edit-punishment-dialog/edit-punishment-dialog.component.ts b/frontend/src/app/pages/reference/bans/edit-punishment-dialog/edit-punishment-dialog.component.ts new file mode 100644 index 0000000..2198824 --- /dev/null +++ b/frontend/src/app/pages/reference/bans/edit-punishment-dialog/edit-punishment-dialog.component.ts @@ -0,0 +1,150 @@ +import {Component, Inject, inject, signal} from '@angular/core'; +import {FormsModule} from '@angular/forms'; +import {MatButtonModule} from '@angular/material/button'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInput, MatLabel} from '@angular/material/input'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import { + MAT_DIALOG_DATA, + MatDialogActions, + MatDialogContent, + MatDialogRef, + MatDialogTitle +} from '@angular/material/dialog'; +import {HistoryService, PunishmentHistory} from '@api'; +import {firstValueFrom} from 'rxjs'; + +interface EditPunishmentData { + punishment: PunishmentHistory; +} + +@Component({ + selector: 'app-edit-punishment-dialog', + standalone: true, + imports: [ + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInput, + MatLabel, + MatCheckboxModule, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + ], + templateUrl: './edit-punishment-dialog.component.html', + styleUrl: './edit-punishment-dialog.component.scss' +}) +export class EditPunishmentDialogComponent { + // Form model + reason: string = ''; + isPermanent: boolean = false; + expiresAtLocal: string = ''; + + // UI state + isBusy = signal(false); + errorMessage = signal(null); + + private historyApi = inject(HistoryService); + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: EditPunishmentData + ) { + const punishment = data.punishment; + this.reason = punishment.reason ?? ''; + const permanent = punishment.expiryTime <= 0; + this.isPermanent = permanent; + if (!permanent) { + const date = new Date(punishment.expiryTime); + const pad = (n: number) => n.toString().padStart(2, '0'); + const year = date.getFullYear(); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + this.expiresAtLocal = `${year}-${month}-${day}T${hours}:${minutes}`; + } + } + + onCancel(): void { + if (this.isBusy()) { + return; + } + this.dialogRef.close(null); + } + + private computeUntilMs(): number { + if (this.isPermanent) { + return -1; + } + if (!this.expiresAtLocal) { + return -1; + } + const ms = new Date(this.expiresAtLocal).getTime(); + return isNaN(ms) ? -1 : ms; + } + + onUpdate(): void { + const punishment = this.data.punishment; + if (!window.confirm('Are you sure you want to update this punishment?')) { + return; + } + this.isBusy.set(true); + this.errorMessage.set(null); + + const updates: Array> = [] as any; + + // Update reason if changed + if ((this.reason ?? '') !== (punishment.reason ?? '')) { + updates.push(firstValueFrom(this.historyApi.updatePunishmentReason(punishment.type, punishment.id, this.reason))); + } + + // Update expiry for ban/mute only + if (punishment.type === 'ban' || punishment.type === 'mute') { + const newUntil = this.computeUntilMs(); + if (newUntil !== punishment.expiryTime) { + updates.push(firstValueFrom(this.historyApi.updatePunishmentUntil(punishment.type, punishment.id, newUntil))); + } + } + + if (updates.length === 0) { + this.isBusy.set(false); + this.dialogRef.close(null); + return; + } + + Promise.all(updates) + .then(results => { + const updated = results[results.length - 1]; + this.isBusy.set(false); + this.dialogRef.close(updated); + }) + .catch(err => { + console.error(err); + this.errorMessage.set('Failed to update punishment'); + this.isBusy.set(false); + }); + } + + onRemove(): void { + const punishment = this.data.punishment; + if (!window.confirm('Are you sure you want to remove this punishment?')) { + return; + } + this.isBusy.set(true); + this.errorMessage.set(null); + + this.historyApi.removePunishment(punishment.type as any, punishment.id).subscribe({ + next: () => { + this.isBusy.set(false); + this.dialogRef.close({removed: true}); + }, + error: (err) => { + console.error(err); + this.errorMessage.set('Failed to remove punishment'); + this.isBusy.set(false); + } + }) + } +} diff --git a/frontend/src/app/pages/reference/bans/history/history.component.html b/frontend/src/app/pages/reference/bans/history/history.component.html index c0c19dd..a0654ea 100644 --- a/frontend/src/app/pages/reference/bans/history/history.component.html +++ b/frontend/src/app/pages/reference/bans/history/history.component.html @@ -6,14 +6,17 @@
- - - - - - - - + + + + + + + + @if (canEdit()) { + + } +
@@ -26,30 +29,36 @@
+ + + + + @if (canEdit()) { + - - - - - } - - -
TypePlayerBanned ByReasonDateExpires
TypePlayerBanned ByReasonDateExpiresActions
{{entry.username}}'s Minecraft skin - {{ entry.username }} -
+ alt="{{entry.username}}'s Minecraft skin"> + {{ entry.username }} + +
+
+ {{entry.punishedBy}}'s Minecraft skin + {{ entry.punishedBy }} +
+
+ {{ entry.reason | removeTrailingPeriod }} + + {{ this.historyFormat.getPunishmentTime(entry) }} + + {{ this.historyFormat.getExpiredTime(entry) }} + + -
- {{entry.punishedBy}}'s Minecraft skin - {{ entry.punishedBy }} -
-
- {{ entry.reason | removeTrailingPeriod }} - - {{ this.historyFormat.getPunishmentTime(entry) }} - - {{ this.historyFormat.getExpiredTime(entry) }} -
- } + + } + + + +} diff --git a/frontend/src/app/pages/reference/bans/history/history.component.ts b/frontend/src/app/pages/reference/bans/history/history.component.ts index 389265f..a8c14af 100644 --- a/frontend/src/app/pages/reference/bans/history/history.component.ts +++ b/frontend/src/app/pages/reference/bans/history/history.component.ts @@ -8,6 +8,9 @@ import {HttpErrorResponse} from '@angular/common/http'; import {HistoryFormatService} from '../history-format.service'; import {SearchParams} from '../search-terms'; import {Router} from '@angular/router'; +import {AuthService} from '@services/auth.service'; +import {MatDialog} from '@angular/material/dialog'; +import {EditPunishmentDialogComponent} from '../edit-punishment-dialog/edit-punishment-dialog.component'; @Component({ selector: 'app-history', @@ -35,7 +38,11 @@ export class HistoryComponent implements OnInit, OnChanges { public history: PunishmentHistory[] = [] - constructor(private historyApi: HistoryService, public historyFormat: HistoryFormatService, private router: Router) { + constructor(private historyApi: HistoryService, + public historyFormat: HistoryFormatService, + private router: Router, + private authService: AuthService, + private dialog: MatDialog) { } ngOnChanges(): void { @@ -101,4 +108,20 @@ export class HistoryComponent implements OnInit, OnChanges { public showDetailedPunishment(entry: PunishmentHistory) { this.router.navigate([`bans/${entry.type}/${entry.id}`]).then(); } + + public canEdit(): boolean { + return this.authService.hasAccess(['SCOPE_head_mod']); + } + + public openEdit(punishment: PunishmentHistory) { + if (!this.canEdit()) return; + const ref = this.dialog.open(EditPunishmentDialogComponent, { + data: {punishment} + }); + ref.afterClosed().subscribe(result => { + if (result) { + this.reloadHistory(); + } + }); + } } diff --git a/open_api/src/main/resources/api.yml b/open_api/src/main/resources/api.yml index 6c047ed..213504d 100644 --- a/open_api/src/main/resources/api.yml +++ b/open_api/src/main/resources/api.yml @@ -49,6 +49,12 @@ paths: $ref: './schemas/bans/bans.yml#/getAllHistoryForUUID' /api/history/total: $ref: './schemas/bans/bans.yml#/getTotalPunishments' + /api/history/admin/{type}/{id}/reason: + $ref: './schemas/bans/bans.yml#/updatePunishmentReason' + /api/history/admin/{type}/{id}/until: + $ref: './schemas/bans/bans.yml#/updatePunishmentUntil' + /api/history/admin/{type}/{id}: + $ref: './schemas/bans/bans.yml#/removePunishment' /api/appeal/update-mail: $ref: './schemas/forms/appeal/appeal.yml#/UpdateMail' /api/appeal/minecraft-appeal: diff --git a/open_api/src/main/resources/schemas/bans/bans.yml b/open_api/src/main/resources/schemas/bans/bans.yml index 0d7309a..201f986 100644 --- a/open_api/src/main/resources/schemas/bans/bans.yml +++ b/open_api/src/main/resources/schemas/bans/bans.yml @@ -211,6 +211,73 @@ getAllHistoryForUUID: application/json: schema: $ref: '#/components/schemas/PunishmentHistoryList' +updatePunishmentReason: + patch: + tags: + - history + summary: Update punishment reason + description: Updates the reason for a specific punishment history entry + operationId: updatePunishmentReason + parameters: + - $ref: '#/components/parameters/HistoryType' + - $ref: '#/components/parameters/Id' + - $ref: '#/components/parameters/NewPunishmentReason' + responses: + '200': + description: Updated punishment + content: + application/json: + schema: + $ref: '#/components/schemas/PunishmentHistory' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '../generic/errors.yml#/components/schemas/ApiError' +updatePunishmentUntil: + patch: + tags: + - history + summary: Update punishment expiry time + description: Updates the expiry time (until) for a specific punishment history entry (only for ban and mute) + operationId: updatePunishmentUntil + parameters: + - $ref: '#/components/parameters/HistoryType' + - $ref: '#/components/parameters/Id' + - $ref: '#/components/parameters/NewUntil' + responses: + '200': + description: Updated punishment + content: + application/json: + schema: + $ref: '#/components/schemas/PunishmentHistory' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '../generic/errors.yml#/components/schemas/ApiError' +removePunishment: + delete: + tags: + - history + summary: Remove punishment + description: Removes a punishment from history. For bans and mutes, the punishment is deactivated; for kicks and warnings, the row is deleted. + operationId: removePunishment + parameters: + - $ref: '#/components/parameters/HistoryType' + - $ref: '#/components/parameters/Id' + responses: + '204': + description: Punishment removed + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '../generic/errors.yml#/components/schemas/ApiError' components: parameters: HistoryType: @@ -250,6 +317,21 @@ components: schema: type: integer description: The id of the punishment that should be retrieved + NewPunishmentReason: + name: reason + in: query + required: true + schema: + type: string + description: The new reason to set for the punishment + NewUntil: + name: until + in: query + required: true + schema: + type: integer + format: int64 + description: The new expiry time (epoch millis) schemas: SearchResults: type: integer