Compare commits
59 Commits
db12ade59f
...
master
Author | SHA1 | Date | |
---|---|---|---|
3f4c542265 | |||
3d4dae9515 | |||
88d47ef535 | |||
da32c3bdfa | |||
9778a49c64 | |||
79e0e5651a | |||
a7e6488e72 | |||
9c74f41022 | |||
98711948b6 | |||
f60d5f2114 | |||
c8e91ba949 | |||
78af91e971 | |||
af219e69bb | |||
f3360b6041 | |||
1e2503ef44 | |||
ae718e97f5 | |||
2c1cccf25a | |||
288c127520 | |||
93d491445a | |||
2f008730f9 | |||
5b95ce2e33 | |||
6cd275994e | |||
f046d9cc68 | |||
0e585ed1cd | |||
b2c034df5f | |||
c2c8b348e6 | |||
09fdb97b6f | |||
acc129e9da | |||
50f370cde2 | |||
0888cb1ac1 | |||
fe13824d04 | |||
1ab282772e | |||
779460689a | |||
4a2d3d4fb7 | |||
3c4f5089ef | |||
4c4fcc884d | |||
d6a6b45ec7 | |||
7e678be372 | |||
64f33e1c3c | |||
93192d6b5f | |||
0150be17e7 | |||
f2783d14da | |||
5332370c32 | |||
5c97e33500 | |||
c26b1b38cd | |||
77b58dd330 | |||
821284463e | |||
76c5b48237 | |||
0fb69e8d13 | |||
4c95983c47 | |||
38848349fd | |||
6ae8929d91 | |||
2ca9b6ce5f | |||
5a0286412f | |||
7d1f81c3a2 | |||
075877748e | |||
5490bc446c | |||
4dcd0a18df | |||
5ba0c5d034 |
@ -27,7 +27,7 @@ jobs:
|
|||||||
|
|
||||||
# Setup Java and Maven
|
# Setup Java and Maven
|
||||||
- name: Set up JDK and Maven
|
- name: Set up JDK and Maven
|
||||||
uses: s4u/setup-maven-action@v1.12.0
|
uses: s4u/setup-maven-action@v1.13.0
|
||||||
with:
|
with:
|
||||||
java-version: ${{ matrix.java-version }}
|
java-version: ${{ matrix.java-version }}
|
||||||
distribution: "zulu"
|
distribution: "zulu"
|
||||||
|
@ -12,7 +12,10 @@ jobs:
|
|||||||
docker:
|
docker:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
arch: ["ubuntu-latest"]
|
arch: [ "ubuntu-latest" ]
|
||||||
|
git-version: [ "2.44.0" ]
|
||||||
|
java-version: [ "17" ]
|
||||||
|
maven-version: [ "3.8.5" ]
|
||||||
runs-on: ${{ matrix.arch }}
|
runs-on: ${{ matrix.arch }}
|
||||||
|
|
||||||
# Steps to run
|
# Steps to run
|
||||||
@ -20,8 +23,18 @@ jobs:
|
|||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Setup Java and Maven
|
||||||
|
- name: Set up JDK and Maven
|
||||||
|
uses: s4u/setup-maven-action@v1.13.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
java-version: ${{ matrix.java-version }}
|
||||||
|
distribution: "zulu"
|
||||||
|
maven-version: ${{ matrix.maven-version }}
|
||||||
|
|
||||||
|
# Run JUnit Tests
|
||||||
|
- name: Run Tests
|
||||||
|
run: mvn --batch-mode test -q
|
||||||
|
|
||||||
# Login to Docker Hub
|
# Login to Docker Hub
|
||||||
- name: Login to Repo
|
- name: Login to Repo
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# Stage 1: Build the application
|
# Stage 1: Build the application
|
||||||
FROM maven:3.9.6-eclipse-temurin-17-alpine AS builder
|
FROM maven:3.9.7-eclipse-temurin-17-alpine AS builder
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /home/container
|
WORKDIR /home/container
|
||||||
@ -11,7 +11,7 @@ COPY . .
|
|||||||
RUN mvn package -q -Dmaven.test.skip -DskipTests -T2C
|
RUN mvn package -q -Dmaven.test.skip -DskipTests -T2C
|
||||||
|
|
||||||
# Stage 2: Create the final lightweight image
|
# Stage 2: Create the final lightweight image
|
||||||
FROM eclipse-temurin:17-jre-focal
|
FROM eclipse-temurin:17.0.11_9-jre-focal
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /home/container
|
WORKDIR /home/container
|
||||||
|
9
LICENSE
Normal file
9
LICENSE
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Liam (Fascinated)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
32
README.md
Normal file
32
README.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Paste
|
||||||
|
|
||||||
|
A simple pastebin service. Running at [paste.fascinated.cc](https://paste.fascinated.cc).
|
||||||
|
|
||||||
|
## Javascript Utility
|
||||||
|
|
||||||
|
```js
|
||||||
|
/**
|
||||||
|
* Uploads a paste to paste.fascinated.cc
|
||||||
|
*
|
||||||
|
* @param content the content of the paste
|
||||||
|
* @returns the paste key and the URL
|
||||||
|
*/
|
||||||
|
async function uploadPaste(content: string) {
|
||||||
|
const response = await fetch("https://paste.fascinated.cc/api/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: content,
|
||||||
|
});
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(json.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...json,
|
||||||
|
url: `https://paste.fascinated.cc/${json.key}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(await uploadPaste("Hello, World!"));
|
||||||
|
```
|
22
pom.xml
22
pom.xml
@ -17,7 +17,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.2.5</version>
|
<version>3.3.0</version>
|
||||||
<relativePath/> <!-- lookup parent from repository -->
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
@ -52,6 +52,11 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||||
|
<version>3.3.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- MongoDB for data storage -->
|
<!-- MongoDB for data storage -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@ -72,6 +77,19 @@
|
|||||||
<version>3.14.0</version>
|
<version>3.14.0</version>
|
||||||
<scope>compile</scope>
|
<scope>compile</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.googlecode.htmlcompressor</groupId>
|
||||||
|
<artifactId>htmlcompressor</artifactId>
|
||||||
|
<version>1.5.2</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Sentry -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.sentry</groupId>
|
||||||
|
<artifactId>sentry-spring-boot-starter-jakarta</artifactId>
|
||||||
|
<version>7.10.0</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Tests -->
|
<!-- Tests -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@ -82,7 +100,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>de.flapdoodle.embed</groupId>
|
<groupId>de.flapdoodle.embed</groupId>
|
||||||
<artifactId>de.flapdoodle.embed.mongo.spring3x</artifactId>
|
<artifactId>de.flapdoodle.embed.mongo.spring3x</artifactId>
|
||||||
<version>4.12.6</version>
|
<version>4.13.0</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
@ -0,0 +1,76 @@
|
|||||||
|
package cc.fascinated.backend.common;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
public class FileHeaderChecker {
|
||||||
|
// Define known file headers
|
||||||
|
private static final Map<String, byte[]> FILE_HEADERS = new HashMap<>();
|
||||||
|
|
||||||
|
static {
|
||||||
|
FILE_HEADERS.put("PNG", new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A});
|
||||||
|
FILE_HEADERS.put("JPEG", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
|
||||||
|
FILE_HEADERS.put("GIF", new byte[]{0x47, 0x49, 0x46, 0x38});
|
||||||
|
FILE_HEADERS.put("PDF", new byte[]{0x25, 0x50, 0x44, 0x46});
|
||||||
|
FILE_HEADERS.put("ZIP", new byte[]{0x50, 0x4B, 0x03, 0x04});
|
||||||
|
FILE_HEADERS.put("RAR", new byte[]{0x52, 0x61, 0x72, 0x21});
|
||||||
|
FILE_HEADERS.put("7Z", new byte[]{0x37, 0x7A, (byte) 0xBC, (byte) 0xAF, (byte) 0x27, 0x1C});
|
||||||
|
FILE_HEADERS.put("TAR", new byte[]{0x75, 0x73, 0x74, 0x61, 0x72});
|
||||||
|
FILE_HEADERS.put("BZ2", new byte[]{0x42, 0x5A, 0x68});
|
||||||
|
FILE_HEADERS.put("GZ", new byte[]{0x1F, (byte) 0x8B});
|
||||||
|
FILE_HEADERS.put("EXE", new byte[]{0x4D, 0x5A});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a string to a byte array using UTF-8 encoding.
|
||||||
|
*
|
||||||
|
* @param input the string to convert
|
||||||
|
* @return the byte array representation of the string
|
||||||
|
*/
|
||||||
|
private static byte[] stringToByteArray(String input) {
|
||||||
|
return input.getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the byte array starts with the specified header.
|
||||||
|
*
|
||||||
|
* @param byteArray the byte array to check
|
||||||
|
* @param header the header to look for
|
||||||
|
* @return true if the byte array starts with the header, false otherwise
|
||||||
|
*/
|
||||||
|
private static boolean startsWith(byte[] byteArray, byte[] header) {
|
||||||
|
if (byteArray.length < header.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < header.length; i++) {
|
||||||
|
if (byteArray[i] != header[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given string contains a known file header.
|
||||||
|
*
|
||||||
|
* @param input the string to check
|
||||||
|
* @return true if the string contains a known file header, false otherwise
|
||||||
|
*/
|
||||||
|
public static boolean containsFileHeader(String input) {
|
||||||
|
byte[] byteArray = stringToByteArray(input);
|
||||||
|
|
||||||
|
for (byte[] header : FILE_HEADERS.values()) {
|
||||||
|
if (startsWith(byteArray, header)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
@ -4,10 +4,18 @@ import lombok.NonNull;
|
|||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||||
|
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
@Configuration
|
@Configuration @EnableWebMvc
|
||||||
public class Config {
|
public class Config implements WebMvcConfigurer {
|
||||||
|
@Override
|
||||||
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
|
registry
|
||||||
|
.addResourceHandler("/static/**")
|
||||||
|
.addResourceLocations("classpath:/static/");
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public WebMvcConfigurer configureCors() {
|
public WebMvcConfigurer configureCors() {
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
package cc.fascinated.backend.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||||
|
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||||
|
import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver;
|
||||||
|
import org.thymeleaf.spring6.view.ThymeleafViewResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebMvc
|
||||||
|
public class ThymeleafConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SpringTemplateEngine templateEngine() {
|
||||||
|
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
|
||||||
|
templateEngine.setTemplateResolver(thymeleafTemplateResolver());
|
||||||
|
return templateEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SpringResourceTemplateResolver thymeleafTemplateResolver() {
|
||||||
|
SpringResourceTemplateResolver templateResolver
|
||||||
|
= new SpringResourceTemplateResolver();
|
||||||
|
templateResolver.setPrefix("classpath:/templates/");
|
||||||
|
templateResolver.setSuffix(".html");
|
||||||
|
templateResolver.setTemplateMode("HTML");
|
||||||
|
return templateResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ThymeleafViewResolver thymeleafViewResolver() {
|
||||||
|
ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
|
||||||
|
viewResolver.setTemplateEngine(templateEngine());
|
||||||
|
return viewResolver;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
package cc.fascinated.backend.controller;
|
||||||
|
|
||||||
|
import cc.fascinated.backend.exception.impl.ResourceNotFoundException;
|
||||||
|
import cc.fascinated.backend.model.Paste;
|
||||||
|
import cc.fascinated.backend.service.PasteService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.util.MimeTypeUtils;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.awt.datatransfer.MimeTypeParseException;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
@RequestMapping(value = "/")
|
||||||
|
public class IndexController {
|
||||||
|
|
||||||
|
private final PasteService pasteService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public IndexController(PasteService pasteService) {
|
||||||
|
this.pasteService = pasteService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/")
|
||||||
|
public String home() {
|
||||||
|
return "index";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is to allow for Hastebin compatibility.
|
||||||
|
*/
|
||||||
|
@PostMapping(value = "/documents")
|
||||||
|
public ResponseEntity<?> uploadPaste(@RequestBody String content, @RequestHeader(value = HttpHeaders.CONTENT_TYPE, required = false) String contentType) {
|
||||||
|
String id = pasteService.createPaste(content, contentType);
|
||||||
|
return ResponseEntity.ok(Map.of("key", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@GetMapping(value = "/{id}", produces = MimeTypeUtils.TEXT_HTML_VALUE)
|
||||||
|
public String paste(@PathVariable String id, Model model) {
|
||||||
|
try {
|
||||||
|
Paste paste = pasteService.getPaste(id);
|
||||||
|
model.addAttribute("paste", paste);
|
||||||
|
model.addAttribute("title", "Paste - " + paste.getId());
|
||||||
|
model.addAttribute("rawUrl", "/raw/" + paste.getId());
|
||||||
|
return "paste";
|
||||||
|
} catch (ResourceNotFoundException ex) {
|
||||||
|
return "redirect:/";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/raw/{id}", produces = MimeTypeUtils.TEXT_HTML_VALUE)
|
||||||
|
public String pasteRaw(@PathVariable String id, Model model) {
|
||||||
|
try {
|
||||||
|
Paste paste = pasteService.getPaste(id);
|
||||||
|
model.addAttribute("paste", paste);
|
||||||
|
model.addAttribute("title", "Paste - " + paste.getId() + " (Raw)");
|
||||||
|
return "paste-raw";
|
||||||
|
} catch (ResourceNotFoundException ex) {
|
||||||
|
return "redirect:/";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -3,13 +3,14 @@ package cc.fascinated.backend.controller;
|
|||||||
import cc.fascinated.backend.model.Paste;
|
import cc.fascinated.backend.model.Paste;
|
||||||
import cc.fascinated.backend.service.PasteService;
|
import cc.fascinated.backend.service.PasteService;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping(value = "/")
|
@RequestMapping(value = "/api")
|
||||||
public class PasteController {
|
public class PasteController {
|
||||||
|
|
||||||
private final PasteService pasteService;
|
private final PasteService pasteService;
|
||||||
@ -19,20 +20,13 @@ public class PasteController {
|
|||||||
this.pasteService = pasteService;
|
this.pasteService = pasteService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/")
|
|
||||||
public ResponseEntity<?> home() {
|
|
||||||
return ResponseEntity.ok(Map.of(
|
|
||||||
"status", "OK"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping(value = "/upload")
|
@PostMapping(value = "/upload")
|
||||||
public ResponseEntity<?> uploadPaste(@RequestBody String content) {
|
public ResponseEntity<?> uploadPaste(@RequestBody String content, @RequestHeader(value = HttpHeaders.CONTENT_TYPE, required = false) String contentType) {
|
||||||
String id = pasteService.createPaste(content);
|
String id = pasteService.createPaste(content, contentType);
|
||||||
return ResponseEntity.ok(Map.of("id", id));
|
return ResponseEntity.ok(Map.of("key", id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/{id}")
|
@GetMapping(value = "/paste/{id}")
|
||||||
public ResponseEntity<Paste> getPaste(@PathVariable String id) {
|
public ResponseEntity<Paste> getPaste(@PathVariable String id) {
|
||||||
Paste paste = pasteService.getPaste(id);
|
Paste paste = pasteService.getPaste(id);
|
||||||
return ResponseEntity.ok(paste);
|
return ResponseEntity.ok(paste);
|
||||||
|
@ -2,6 +2,7 @@ package cc.fascinated.backend.exception;
|
|||||||
|
|
||||||
import cc.fascinated.backend.model.response.ErrorResponse;
|
import cc.fascinated.backend.model.response.ErrorResponse;
|
||||||
import io.micrometer.common.lang.NonNull;
|
import io.micrometer.common.lang.NonNull;
|
||||||
|
import io.sentry.Sentry;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.ControllerAdvice;
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
@ -39,6 +40,7 @@ public final class ExceptionControllerAdvice {
|
|||||||
}
|
}
|
||||||
if (status == null) { // Fallback to 500
|
if (status == null) { // Fallback to 500
|
||||||
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
Sentry.captureException(ex); // Capture the exception
|
||||||
}
|
}
|
||||||
return new ResponseEntity<>(new ErrorResponse(status, message), status);
|
return new ResponseEntity<>(new ErrorResponse(status, message), status);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
package cc.fascinated.backend.exception.impl;
|
||||||
|
|
||||||
|
import lombok.experimental.StandardException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
|
||||||
|
@StandardException
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
public class BadRequestException extends RuntimeException { }
|
@ -0,0 +1,98 @@
|
|||||||
|
package cc.fascinated.backend.filter;
|
||||||
|
|
||||||
|
import com.googlecode.htmlcompressor.compressor.HtmlCompressor;
|
||||||
|
import jakarta.servlet.*;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.servlet.http.HttpServletResponseWrapper;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.CharArrayWriter;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter to compress HTML responses using HtmlCompressor.
|
||||||
|
* Author: Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class HtmlCompressionFilter implements Filter {
|
||||||
|
private final HtmlCompressor htmlCompressor = new HtmlCompressor();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the HTML compression filter.
|
||||||
|
*
|
||||||
|
* @param request the ServletRequest
|
||||||
|
* @param response the ServletResponse
|
||||||
|
* @param chain the FilterChain
|
||||||
|
* @throws ServletException if an error occurs during filtering
|
||||||
|
* @throws IOException if an input or output error occurs
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
|
||||||
|
ServletResponse newResponse = response;
|
||||||
|
|
||||||
|
// Wrap the response to capture the output
|
||||||
|
if (request instanceof HttpServletRequest) {
|
||||||
|
newResponse = new CharResponseWrapper((HttpServletResponse) response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass the request and response along the filter chain
|
||||||
|
chain.doFilter(request, newResponse);
|
||||||
|
|
||||||
|
// Compress the captured response if it was wrapped
|
||||||
|
if (newResponse instanceof CharResponseWrapper) {
|
||||||
|
String responseContent = newResponse.toString();
|
||||||
|
if (responseContent != null) {
|
||||||
|
response.getWriter().write(htmlCompressor.compress(responseContent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for HttpServletResponse to capture the output for compression.
|
||||||
|
*/
|
||||||
|
private static class CharResponseWrapper extends HttpServletResponseWrapper {
|
||||||
|
private final CharArrayWriter charWriter = new CharArrayWriter();
|
||||||
|
private PrintWriter writer;
|
||||||
|
private boolean getOutputStreamCalled;
|
||||||
|
private boolean getWriterCalled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor to wrap the response.
|
||||||
|
* @param response the original HttpServletResponse
|
||||||
|
*/
|
||||||
|
public CharResponseWrapper(HttpServletResponse response) {
|
||||||
|
super(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ServletOutputStream getOutputStream() throws IOException {
|
||||||
|
if (getWriterCalled) {
|
||||||
|
throw new IllegalStateException("getWriter already called");
|
||||||
|
}
|
||||||
|
getOutputStreamCalled = true;
|
||||||
|
return super.getOutputStream();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PrintWriter getWriter() {
|
||||||
|
if (writer != null) {
|
||||||
|
return writer;
|
||||||
|
}
|
||||||
|
if (getOutputStreamCalled) {
|
||||||
|
throw new IllegalStateException("getOutputStream already called");
|
||||||
|
}
|
||||||
|
getWriterCalled = true;
|
||||||
|
writer = new PrintWriter(charWriter);
|
||||||
|
return writer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return (writer != null) ? charWriter.toString() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,11 @@
|
|||||||
package cc.fascinated.backend.service;
|
package cc.fascinated.backend.service;
|
||||||
|
|
||||||
import cc.fascinated.backend.model.Paste;
|
import cc.fascinated.backend.common.FileHeaderChecker;
|
||||||
|
import cc.fascinated.backend.exception.impl.BadRequestException;
|
||||||
import cc.fascinated.backend.exception.impl.ResourceNotFoundException;
|
import cc.fascinated.backend.exception.impl.ResourceNotFoundException;
|
||||||
|
import cc.fascinated.backend.model.Paste;
|
||||||
import cc.fascinated.backend.repository.PasteRepository;
|
import cc.fascinated.backend.repository.PasteRepository;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
import org.apache.commons.lang3.RandomStringUtils;
|
import org.apache.commons.lang3.RandomStringUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@ -10,7 +13,7 @@ import org.springframework.stereotype.Service;
|
|||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service @Log4j2(topic = "Paste Service")
|
||||||
public class PasteService {
|
public class PasteService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -24,6 +27,12 @@ public class PasteService {
|
|||||||
@Value("${paste.id-length}")
|
@Value("${paste.id-length}")
|
||||||
private int idLength;
|
private int idLength;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum size of the paste content.
|
||||||
|
*/
|
||||||
|
@Value("${paste.upload-size-limit}")
|
||||||
|
private int uploadSizeLimit;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public PasteService(PasteRepository pasteRepository) {
|
public PasteService(PasteRepository pasteRepository) {
|
||||||
this.pasteRepository = pasteRepository;
|
this.pasteRepository = pasteRepository;
|
||||||
@ -35,12 +44,37 @@ public class PasteService {
|
|||||||
* @param content The content of the paste.
|
* @param content The content of the paste.
|
||||||
* @return The id of the paste.
|
* @return The id of the paste.
|
||||||
*/
|
*/
|
||||||
public String createPaste(String content) {
|
public String createPaste(String content, String contentType) {
|
||||||
return pasteRepository.save(new Paste(
|
int length = content.length();
|
||||||
|
long before = System.currentTimeMillis();
|
||||||
|
log.info("Creating a new paste. (characters: {})", length);
|
||||||
|
|
||||||
|
// Check if the content is too large.
|
||||||
|
if (length > uploadSizeLimit && uploadSizeLimit != -1) {
|
||||||
|
log.info("Paste didn't meet the size requirements. (characters: {})", length);
|
||||||
|
throw new BadRequestException("The paste content is too large, the limit is " + uploadSizeLimit + " characters, not uploading...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the paste content type is valid.
|
||||||
|
if (contentType != null && (contentType.contains("image") || contentType.contains("video") || contentType.contains("audio"))) {
|
||||||
|
log.info("Paste content type is not supported. (content type: {})", contentType);
|
||||||
|
throw new BadRequestException("The paste content type is not supported, not uploading...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the paste content does not contain a file header.
|
||||||
|
if (FileHeaderChecker.containsFileHeader(content)) {
|
||||||
|
log.info("Paste content contains a file header, not uploading...");
|
||||||
|
throw new BadRequestException("The paste content contains a file header, not uploading...");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the paste to the database.
|
||||||
|
Paste paste = pasteRepository.save(new Paste(
|
||||||
RandomStringUtils.randomAlphabetic(idLength),
|
RandomStringUtils.randomAlphabetic(idLength),
|
||||||
System.currentTimeMillis(),
|
System.currentTimeMillis(),
|
||||||
content
|
content
|
||||||
)).getId();
|
));
|
||||||
|
log.info("Created a new paste with the id '{}' (took: {}ms)", paste.getId(), System.currentTimeMillis() - before);
|
||||||
|
return paste.getId();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -50,12 +84,17 @@ public class PasteService {
|
|||||||
* @return The content of the paste.
|
* @return The content of the paste.
|
||||||
*/
|
*/
|
||||||
public Paste getPaste(String id) {
|
public Paste getPaste(String id) {
|
||||||
|
id = id.contains(".") ? id.split("\\.")[0] : id; // Remove file extensions (if any)
|
||||||
|
log.info("Getting paste with the id '{}'", id);
|
||||||
|
long before = System.currentTimeMillis();
|
||||||
Optional<Paste> paste = pasteRepository.findById(id);
|
Optional<Paste> paste = pasteRepository.findById(id);
|
||||||
|
|
||||||
// The paste does not exist.
|
// The paste does not exist.
|
||||||
if (paste.isEmpty()) {
|
if (paste.isEmpty()) {
|
||||||
throw new ResourceNotFoundException("Paste with id '%s' not found".formatted(id));
|
log.info("Paste with the id '{}' not found", id);
|
||||||
|
throw new ResourceNotFoundException("Paste '%s' not found".formatted(id));
|
||||||
}
|
}
|
||||||
|
log.info("Got paste with the id '{}' (took: {}ms)", id, System.currentTimeMillis() - before);
|
||||||
return paste.get();
|
return paste.get();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,9 @@ server:
|
|||||||
port: 3000
|
port: 3000
|
||||||
servlet:
|
servlet:
|
||||||
context-path: /
|
context-path: /
|
||||||
|
tomcat:
|
||||||
|
# Limit the maximum POST size to 1MB
|
||||||
|
max-http-form-post-size: 1MB
|
||||||
|
|
||||||
# Spring Configuration
|
# Spring Configuration
|
||||||
spring:
|
spring:
|
||||||
@ -10,17 +13,23 @@ spring:
|
|||||||
jackson:
|
jackson:
|
||||||
default-property-inclusion: non_null
|
default-property-inclusion: non_null
|
||||||
data:
|
data:
|
||||||
|
|
||||||
# MongoDB - This is used for general data storage
|
# MongoDB - This is used for general data storage
|
||||||
mongodb:
|
mongodb:
|
||||||
uri: mongodb://localhost:27017
|
uri: mongodb://localhost:27017
|
||||||
database: test
|
database: test
|
||||||
port: 27017
|
port: 27017
|
||||||
|
|
||||||
|
# Sentry Configuration
|
||||||
|
sentry:
|
||||||
|
dsn: ""
|
||||||
|
tracesSampleRate: 1.0
|
||||||
|
|
||||||
# Paste Configuration
|
# Paste Configuration
|
||||||
paste:
|
paste:
|
||||||
# The length of the ID for the paste
|
# The length of the ID for the paste
|
||||||
id-length: 12
|
id-length: 12
|
||||||
|
# The limit of the paste size (in characters) - You can set it to "-1" to disable the limit
|
||||||
|
upload-size-limit: 400000
|
||||||
|
|
||||||
# Set the embedded MongoDB version
|
# Set the embedded MongoDB version
|
||||||
de:
|
de:
|
||||||
|
49
src/main/resources/static/assets/script.js
Normal file
49
src/main/resources/static/assets/script.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// Handle custom key binds behavior
|
||||||
|
document.addEventListener("keydown", function (event) {
|
||||||
|
// Upload the paste when Ctrl + Enter is pressed
|
||||||
|
if (event.ctrlKey && event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
upload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload the paste when the paste button is clicked
|
||||||
|
document.getElementById("paste-button").addEventListener("click", () => upload());
|
||||||
|
|
||||||
|
// Upload the paste to the server
|
||||||
|
const upload = async () => {
|
||||||
|
var pasteInput = document.getElementById("paste-input");
|
||||||
|
var paste = pasteInput.value;
|
||||||
|
|
||||||
|
if (!paste || paste.trim() === "") {
|
||||||
|
pasteInput.focus();
|
||||||
|
toast("Please enter a paste to upload.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Uploading paste...");
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: paste,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = "/" + data.key;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error:", error);
|
||||||
|
toast(`${error.message || "An error occurred while uploading the paste."}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toast = (message, duration = 3000) => {
|
||||||
|
Toastify({
|
||||||
|
text: message,
|
||||||
|
duration: duration,
|
||||||
|
gravity: "bottom",
|
||||||
|
position: "right"
|
||||||
|
}).showToast();
|
||||||
|
};
|
62
src/main/resources/static/tailwindcss.js
Normal file
62
src/main/resources/static/tailwindcss.js
Normal file
File diff suppressed because one or more lines are too long
37
src/main/resources/templates/index.html
Normal file
37
src/main/resources/templates/index.html
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport"
|
||||||
|
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
|
<title>Paste</title>
|
||||||
|
|
||||||
|
<!-- TailwindCSS -->
|
||||||
|
<script src="/static/tailwindcss.js"></script>
|
||||||
|
|
||||||
|
<!-- Toastify -->
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
|
||||||
|
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-neutral-900 text-white">
|
||||||
|
<div class="p-3 h-screen w-screen relative">
|
||||||
|
<div class="flex gap-2 h-full w-full text-sm">
|
||||||
|
<p class="hidden md:block select-none pointer-events-none">></p>
|
||||||
|
<textarea
|
||||||
|
id="paste-input"
|
||||||
|
class="w-full h-full bg-background outline-none resize-none bg-transparent"
|
||||||
|
placeholder="Paste your code here..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute top-0 right-0 p-3">
|
||||||
|
<button id="paste-button" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 rounded">
|
||||||
|
Paste
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
<!-- Paste Script -->
|
||||||
|
<script src="/static/assets/script.js"></script>
|
||||||
|
</html>
|
13
src/main/resources/templates/paste-raw.html
Normal file
13
src/main/resources/templates/paste-raw.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport"
|
||||||
|
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
|
<title th:text="${title}"></title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<pre><code th:text="${paste.content}"></code></pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
57
src/main/resources/templates/paste.html
Normal file
57
src/main/resources/templates/paste.html
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport"
|
||||||
|
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||||
|
<title th:text="${title}"></title>
|
||||||
|
|
||||||
|
<!-- Meta tags for social media sharing -->
|
||||||
|
<meta th:content="${title}" property="og:title" />
|
||||||
|
<meta content="Click to view the Paste." property="og:description" />
|
||||||
|
|
||||||
|
<!-- TailwindCSS -->
|
||||||
|
<script src="/static/tailwindcss.js"></script>
|
||||||
|
|
||||||
|
<!-- Highlight.js -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-neutral-900 text-white h-screen relative">
|
||||||
|
<pre><code class="bg-transparent text-sm" th:text="${paste.content}"></code></pre>
|
||||||
|
|
||||||
|
<div class="absolute top-0 right-0 p-3">
|
||||||
|
<button id="raw-button" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 rounded">
|
||||||
|
<a th:href="${rawUrl}">
|
||||||
|
Raw
|
||||||
|
</a>
|
||||||
|
</button>
|
||||||
|
<button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 rounded">
|
||||||
|
<a href="/">
|
||||||
|
New
|
||||||
|
</a>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
// Highlight the code block
|
||||||
|
hljs.highlightAll();
|
||||||
|
|
||||||
|
// Custom select all behavior to select all the content inside the <pre> block
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
if (event.ctrlKey && event.key === 'a') {
|
||||||
|
event.preventDefault(); // Prevent the default select all behavior
|
||||||
|
|
||||||
|
// Select all the content inside the <pre> block
|
||||||
|
const preBlock = document.querySelector('pre');
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(preBlock);
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</html>
|
@ -20,9 +20,9 @@ class PasteControllerTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void ensureUploadSuccess() throws Exception {
|
public void ensureUploadSuccess() throws Exception {
|
||||||
ResultActions result = mockMvc.perform(post("/upload")
|
ResultActions result = mockMvc.perform(post("/api/upload")
|
||||||
.accept(MediaType.APPLICATION_JSON)
|
.accept(MediaType.APPLICATION_JSON)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.TEXT_PLAIN)
|
||||||
.content("joe"))
|
.content("joe"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user