Add endpoints, services, and security controls for particle file management, including save and download APIs.

This commit is contained in:
akastijn 2025-06-29 03:15:39 +02:00
parent c72703ea32
commit 7fc25f46f3
6 changed files with 402 additions and 0 deletions

View File

@ -38,6 +38,8 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
.requestMatchers("/form/**").hasAuthority(PermissionClaimDto.USER.getValue())
.requestMatchers("/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/files/save/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.anyRequest().permitAll()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))

View File

@ -0,0 +1,176 @@
package com.alttd.altitudeweb.controllers.particles;
import com.alttd.altitudeweb.api.ParticlesApi;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@Slf4j
@RequiredArgsConstructor
@RestController
public class ParticleController implements ParticlesApi {
@Value("${login.secret:#{null}}")
private String loginSecret;
@Value("${particles.file_path}")
private String particlesFilePath;
@Value("${notification.server.url:http://localhost:8080}")
private String notificationServerUrl;
@Override
public ResponseEntity<Resource> downloadFile(String authorization, String filename) throws Exception {
if (authorization == null || !authorization.equals(loginSecret)) {
return ResponseEntity.status(401).build();
}
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not downloading particles file", particlesFilePath);
return ResponseEntity.status(404).build();
}
File targetFile = new File(file, filename);
return getFileForDownload(targetFile, filename);
}
@Override
public ResponseEntity<Resource> downloadFileForUser(String authorization, String uuid, String filename) throws Exception {
if (authorization == null || !authorization.equals(loginSecret)) {
return ResponseEntity.status(401).build();
}
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not downloading particles user file", particlesFilePath);
return ResponseEntity.status(404).build();
}
File targetDir = new File(file, uuid);
if (targetDir.exists()) {
return getFileForDownload(targetDir, filename);
} else {
log.warn("User {} does not have a directory for particles files", uuid);
return ResponseEntity.notFound().build();
}
}
private ResponseEntity<Resource> getFileForDownload(File file, String filename) {
File targetFile = new File(file, filename);
if (!targetFile.exists()) {
log.warn("Particles file {} does not exist", targetFile.getAbsolutePath());
return ResponseEntity.notFound().build();
}
if (!targetFile.isFile()) {
log.warn("Particles file {} is not a file", targetFile.getAbsolutePath());
return ResponseEntity.status(404).build();
}
try {
Path path = targetFile.toPath();
ByteArrayResource resource = new ByteArrayResource(Files.readAllBytes(path));
return ResponseEntity.ok()
.contentLength(targetFile.length())
.contentType(MediaType.APPLICATION_JSON)
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.body(resource);
} catch (IOException e) {
log.error("Failed to read particles file {}: {}", targetFile.getAbsolutePath(), e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@Override
public ResponseEntity<Void> saveFile(String filename, MultipartFile content) throws Exception {
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not saving particles file", particlesFilePath);
return ResponseEntity.status(404).build();
}
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
notifyServerOfFileUpload(filename);
return voidResponseEntity;
}
@Override
public ResponseEntity<Void> saveFileForUser(String uuid, String filename, MultipartFile content) throws Exception {
File file = new File(particlesFilePath);
if (!file.exists() || !file.isDirectory()) {
log.error("Particles file path {} is not a directory, not saving particles user file", particlesFilePath);
return ResponseEntity.status(404).build();
}
File targetDir = new File(file, uuid);
if (!file.exists()) {
log.debug("Creating particles directory {}", targetDir.getAbsolutePath());
if (targetDir.mkdirs()) {
log.info("Created particles user directory {}", targetDir.getAbsolutePath());
}
}
ResponseEntity<Void> voidResponseEntity = writeContentToFile(file, filename, content);
notifyServerOfFileUpload(uuid, filename);
return voidResponseEntity;
}
private void notifyServerOfFileUpload(String filename) {
String notificationUrl = String.format("%s/notify/%s.json", notificationServerUrl, filename);
sendNotification(notificationUrl, String.format("file upload: %s", filename));
}
private void notifyServerOfFileUpload(String uuid, String filename) {
String notificationUrl = String.format("%s/notify/%s/%s.json", notificationServerUrl, uuid, filename);
sendNotification(notificationUrl, String.format("file upload for user %s: %s", uuid, filename));
}
private void sendNotification(String notificationUrl, String logDescription) {
try {
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response = restTemplate.getForEntity(notificationUrl, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
log.info("Successfully notified server of {}", logDescription);
} else {
log.warn("Failed to notify server of {}, status: {}",
logDescription, response.getStatusCode());
}
} catch (Exception e) {
log.error("Error notifying server of {}", logDescription, e);
}
}
private ResponseEntity<Void> writeContentToFile(File dir, String filename, MultipartFile content) {
File targetFile = new File(dir, filename);
if (!Files.isWritable(targetFile.toPath())) {
log.error("Particles file {} is not writable", targetFile.getAbsolutePath());
return ResponseEntity.status(403).build();
}
if (targetFile.exists()) {
log.warn("Overwriting existing particles file {}", targetFile.getAbsolutePath());
}
try {
content.transferTo(targetFile);
} catch (Exception e) {
log.error("Failed to write particles file {}", targetFile.getAbsolutePath(), e);
return ResponseEntity.status(500).build();
}
return ResponseEntity.ok().build();
}
}

View File

@ -6,4 +6,6 @@ database.user=${DB_USER:root}
database.password=${DB_PASSWORD:root}
cors.allowed-origins=${CORS:https://alttd.com}
login.secret=${LOGIN_SECRET:SET_TOKEN}
particles.file_path=${user.home}/.altitudeweb/particles
notification.server.url=10.0.0.107:8080
logging.level.com.alttd.altitudeweb=INFO

View File

@ -22,6 +22,7 @@ import {ParticleComponent} from './components/particle/particle.component';
import {FramesComponent} from './components/frames/frames.component';
import {MatSnackBar} from '@angular/material/snack-bar';
import {RenderContainerComponent} from './components/render-container/render-container.component';
import {ParticlesService} from '../../api';
@Component({
selector: 'app-particles',
@ -55,6 +56,7 @@ export class ParticlesComponent {
private intersectionPlaneService: IntersectionPlaneService,
private particleManagerService: ParticleManagerService,
private matSnackBar: MatSnackBar,
private particlesService: ParticlesService,
) {
}
@ -93,9 +95,38 @@ export class ParticlesComponent {
return this.particleManagerService.generateJson();
}
public getJsonFile(): Blob {
const jsonContent = this.generateJson();
return new Blob([jsonContent], {type: 'application/json'});
}
public saveJsonToFile(): void {
const jsonContent = this.generateJson();
const blob = new Blob([jsonContent], {type: 'application/json'});
const url = window.URL.createObjectURL(blob);
// Create a temporary link element
const a = document.createElement('a');
a.href = url;
a.download = 'particle-data.json';
// Append to the document, click it, and remove it
document.body.appendChild(a);
a.click();
// Clean up
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
this.matSnackBar.open('JSON file downloaded', '', {duration: 2000});
}
public copyJson() {
navigator.clipboard.writeText(this.generateJson()).then(() => {
this.matSnackBar.open('Copied to clipboard', '', {duration: 2000})
});
//TODO validation
this.particlesService.saveFile(this.particleManagerService.getParticleData().particle_name, this.getJsonFile());
}
}

View File

@ -10,11 +10,18 @@ components:
schemas:
PermissionClaim:
$ref: './schemas/permissions/permissions.yml#/components/schemas/PermissionClaim'
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
tags:
- name: history
description: Retrieves punishment history
- name: team
description: Retrieves information about the staff team
- name: particles
description: All actions related to particles
paths:
/team/{team}:
$ref: './schemas/team/team.yml#/getTeam'
@ -46,3 +53,11 @@ paths:
$ref: './schemas/login/login.yml#/RequestNewUserLogin'
/login/userLogin/{code}:
$ref: './schemas/login/login.yml#/UserLogin'
/files/save/{filename}:
$ref: './schemas/particles/particles.yml#/SaveFile'
/files/save/{uuid}/{filename}:
$ref: './schemas/particles/particles.yml#/SaveFileForUser'
/files/download/{filename}/{secret}:
$ref: './schemas/particles/particles.yml#/DownloadFile'
/files/download/{uuid}/{filename}:
$ref: './schemas/particles/particles.yml#/DownloadFileForUser'

View File

@ -0,0 +1,176 @@
components:
parameters:
Filename:
name: filename
in: path
required: true
schema:
type: string
description: The name of the file
Secret:
name: Authorization
in: header
required: true
schema:
type: string
description: Secret
schemas:
FileData:
type: object
required:
- content
properties:
content:
type: string
format: binary
description: The content of the file
SaveFile:
post:
tags:
- particles
summary: Save a file
description: Save a file to the server (requires authorization)
operationId: saveFile
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Filename'
requestBody:
required: true
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/FileData'
responses:
'200':
description: File saved successfully
'401':
description: Unauthorized - Invalid or missing token
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
'403':
description: Forbidden - Insufficient permissions
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
SaveFileForUser:
post:
tags:
- particles
summary: Save a file for a specific user
description: Save a file to the server for a specific user (requires head_mod permission)
operationId: saveFileForUser
security:
- bearerAuth: []
parameters:
- $ref: '../generic/parameters.yml#/components/parameters/Uuid'
- $ref: '#/components/parameters/Filename'
requestBody:
required: true
content:
multipart/form-data:
schema:
$ref: '#/components/schemas/FileData'
responses:
'200':
description: File saved successfully
'401':
description: Unauthorized - Invalid or missing token
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
'403':
description: Forbidden - Insufficient permissions
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
DownloadFile:
get:
tags:
- particles
summary: Download a file
description: Download a file from the server using a secret key
operationId: downloadFile
parameters:
- $ref: '#/components/parameters/Secret'
- $ref: '#/components/parameters/Filename'
responses:
'200':
description: File downloaded successfully
content:
application/octet-stream:
schema:
type: string
format: binary
'404':
description: File not found
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
DownloadFileForUser:
get:
tags:
- particles
summary: Download a file for a specific user
description: Download a file from the server for a specific user (requires authorization)
operationId: downloadFileForUser
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/Secret'
- $ref: '../generic/parameters.yml#/components/parameters/Uuid'
- $ref: '#/components/parameters/Filename'
responses:
'200':
description: File downloaded successfully
content:
application/octet-stream:
schema:
type: string
format: binary
'401':
description: Unauthorized - Invalid or missing token
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
'404':
description: File not found
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../generic/errors.yml#/components/schemas/ApiError'