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 43b37b0..20e5f70 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java +++ b/backend/src/main/java/com/alttd/altitudeweb/config/SecurityConfig.java @@ -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())) diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/particles/ParticleController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/particles/ParticleController.java new file mode 100644 index 0000000..276b170 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/particles/ParticleController.java @@ -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 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 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 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 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 voidResponseEntity = writeContentToFile(file, filename, content); + notifyServerOfFileUpload(filename); + return voidResponseEntity; + } + + @Override + public ResponseEntity 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 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 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 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(); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index dd273f8..2a93f6b 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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 diff --git a/frontend/src/app/particles/particles.component.ts b/frontend/src/app/particles/particles.component.ts index f919d9a..5ce1018 100644 --- a/frontend/src/app/particles/particles.component.ts +++ b/frontend/src/app/particles/particles.component.ts @@ -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()); } } diff --git a/open_api/src/main/resources/api.yml b/open_api/src/main/resources/api.yml index 2c3b38b..5c94c94 100644 --- a/open_api/src/main/resources/api.yml +++ b/open_api/src/main/resources/api.yml @@ -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' diff --git a/open_api/src/main/resources/schemas/particles/particles.yml b/open_api/src/main/resources/schemas/particles/particles.yml new file mode 100644 index 0000000..2bb9d68 --- /dev/null +++ b/open_api/src/main/resources/schemas/particles/particles.yml @@ -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'