WIP: Submit transcoding jobs via a HTTP API #6
@ -1,9 +1,15 @@
|
|||||||
package se.su.dsv.whisperapi;
|
package se.su.dsv.whisperapi;
|
||||||
|
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import se.su.dsv.whisperapi.core.OutputFormat;
|
||||||
import se.su.dsv.whisperapi.core.TransactionRepository;
|
import se.su.dsv.whisperapi.core.TransactionRepository;
|
||||||
import se.su.dsv.whisperapi.core.Transcription;
|
import se.su.dsv.whisperapi.core.Transcription;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
public class JDBCTransactionRepository implements TransactionRepository {
|
public class JDBCTransactionRepository implements TransactionRepository {
|
||||||
private final JdbcClient jdbcClient;
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
@ -23,4 +29,34 @@ public class JDBCTransactionRepository implements TransactionRepository {
|
|||||||
.param("output_format", transcription.outputFormat().name())
|
.param("output_format", transcription.outputFormat().name())
|
||||||
.update();
|
.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Transcription> findByOwnerAndId(Principal owner, UUID uuid) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
SELECT id, owner, callback_uri, output_format
|
||||||
|
FROM transcriptions
|
||||||
|
WHERE id = :id AND owner = :owner
|
||||||
|
""")
|
||||||
|
.param("id", uuid)
|
||||||
|
.param("owner", owner.getName())
|
||||||
|
.query((rs, rowNum) -> {
|
||||||
|
UUID id = UUID.fromString(rs.getString("id"));
|
||||||
|
URI callbackUri = URI.create(rs.getString("callback_uri"));
|
||||||
|
OutputFormat outputFormat = OutputFormat.valueOf(rs.getString("output_format"));
|
||||||
|
return new Transcription(id, owner, callbackUri, outputFormat);
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addFileToTranscription(Transcription transcription, String filename) {
|
||||||
|
jdbcClient.sql("""
|
||||||
|
INSERT INTO transcriptions_files (transcription_id, filename)
|
||||||
|
VALUES (:transcription_id, :filename)
|
||||||
|
ON DUPLICATE KEY UPDATE filename = :filename
|
||||||
|
""")
|
||||||
|
.param("transcription_id", transcription.id())
|
||||||
|
.param("filename", filename)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ package se.su.dsv.whisperapi;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
@ -12,6 +13,7 @@ import se.su.dsv.whisperapi.core.TransactionRepository;
|
|||||||
import se.su.dsv.whisperapi.core.TranscriptionService;
|
import se.su.dsv.whisperapi.core.TranscriptionService;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableConfigurationProperties(WhisperFrontendConfiguration.class)
|
||||||
public class WhisperApiApplication {
|
public class WhisperApiApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
@ -39,8 +41,11 @@ public class WhisperApiApplication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public TranscriptionService transcriptionService(TransactionRepository transcriptionRepository) {
|
public TranscriptionService transcriptionService(
|
||||||
return new TranscriptionService(transcriptionRepository);
|
TransactionRepository transcriptionRepository,
|
||||||
|
WhisperFrontendConfiguration config)
|
||||||
|
{
|
||||||
|
return new TranscriptionService(transcriptionRepository, config.transcriptionFilesDirectory());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
package se.su.dsv.whisperapi;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "whisper.frontend")
|
||||||
|
public record WhisperFrontendConfiguration(
|
||||||
|
Path transcriptionFilesDirectory)
|
||||||
|
{
|
||||||
|
}
|
@ -1,17 +1,25 @@
|
|||||||
package se.su.dsv.whisperapi.api;
|
package se.su.dsv.whisperapi.api;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
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.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
import se.su.dsv.whisperapi.core.CreateTranscription;
|
import se.su.dsv.whisperapi.core.CreateTranscription;
|
||||||
import se.su.dsv.whisperapi.core.OutputFormat;
|
import se.su.dsv.whisperapi.core.OutputFormat;
|
||||||
import se.su.dsv.whisperapi.core.Transcription;
|
import se.su.dsv.whisperapi.core.Transcription;
|
||||||
|
import se.su.dsv.whisperapi.core.TranscriptionFile;
|
||||||
import se.su.dsv.whisperapi.core.TranscriptionService;
|
import se.su.dsv.whisperapi.core.TranscriptionService;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For submitting jobs programmatically via a JSON HTTP API.
|
* For submitting jobs programmatically via a JSON HTTP API.
|
||||||
@ -21,6 +29,7 @@ import java.security.Principal;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping(consumes = "application/json", produces = "application/json")
|
@RequestMapping(consumes = "application/json", produces = "application/json")
|
||||||
public class ApiController {
|
public class ApiController {
|
||||||
|
private static final System.Logger LOG = System.getLogger(ApiController.class.getName());
|
||||||
|
|
||||||
private final TranscriptionService transcriptionService;
|
private final TranscriptionService transcriptionService;
|
||||||
|
|
||||||
@ -54,4 +63,33 @@ public class ApiController {
|
|||||||
default -> throw new InvalidOutputFormat(outputFormat);
|
default -> throw new InvalidOutputFormat(outputFormat);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a file to be transcribed in the specified job. Multiple files can be attached to a single job.
|
||||||
|
* They must be unique based on filename, same filename will overwrite the existing file.
|
||||||
|
*/
|
||||||
|
@PutMapping(value = "/api/transcriptions/{id}/file", consumes = "*/*")
|
||||||
|
public ResponseEntity<Void> uploadFileToBeTranscribed(
|
||||||
|
Principal owner,
|
||||||
|
@PathVariable("id") String id,
|
||||||
|
@RequestHeader("X-Filename") String filename,
|
||||||
|
InputStream fileData)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (filename == null || filename.isBlank()) {
|
||||||
|
throw new MissingFilename();
|
||||||
|
}
|
||||||
|
UUID uuid = UUID.fromString(id);
|
||||||
|
Transcription transcription = transcriptionService.getTranscription(owner, uuid)
|
||||||
|
.orElseThrow(() -> new TranscriptionNotFound(id));
|
||||||
|
transcriptionService.addFileToBeTranscribed(transcription, new TranscriptionFile(filename, fileData));
|
||||||
|
return ResponseEntity.accepted().build();
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
|||||||
|
// Invalid UUID
|
||||||
|
throw new TranscriptionNotFound(id);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LOG.log(System.Logger.Level.ERROR, "Failed to save file", e);
|
||||||
|
throw new FileUploadFailed();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
14
src/main/java/se/su/dsv/whisperapi/api/FileUploadFailed.java
Normal file
14
src/main/java/se/su/dsv/whisperapi/api/FileUploadFailed.java
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package se.su.dsv.whisperapi.api;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.ErrorResponseException;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
public class FileUploadFailed extends ErrorResponseException {
|
||||||
|
public FileUploadFailed() {
|
||||||
|
super(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#file-upload-failed"));
|
||||||
|
setTitle("File upload failed");
|
||||||
|
}
|
||||||
|
}
|
15
src/main/java/se/su/dsv/whisperapi/api/MissingFilename.java
Normal file
15
src/main/java/se/su/dsv/whisperapi/api/MissingFilename.java
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package se.su.dsv.whisperapi.api;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.ErrorResponseException;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
public class MissingFilename extends ErrorResponseException {
|
||||||
|
public MissingFilename() {
|
||||||
|
super(HttpStatus.BAD_REQUEST);
|
||||||
|
setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#missing-filename"));
|
||||||
|
setTitle("Missing filename");
|
||||||
|
setDetail("A filename must be specified in the X-Filename header");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
package se.su.dsv.whisperapi.api;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.ErrorResponseException;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
|
public class TranscriptionNotFound extends ErrorResponseException {
|
||||||
|
public TranscriptionNotFound(String id) {
|
||||||
|
super(HttpStatus.BAD_REQUEST);
|
||||||
|
setType(URI.create("https://gitea.dsv.su.se/DMC/whisper-frontend/wiki/Errors#transcription-file-not-found"));
|
||||||
|
setTitle("Transcription not found");
|
||||||
|
setDetail("Transcription with id '" + id + "' not found");
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,13 @@
|
|||||||
package se.su.dsv.whisperapi.core;
|
package se.su.dsv.whisperapi.core;
|
||||||
|
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface TransactionRepository {
|
public interface TransactionRepository {
|
||||||
void save(Transcription transcription);
|
void save(Transcription transcription);
|
||||||
|
|
||||||
|
Optional<Transcription> findByOwnerAndId(Principal owner, UUID uuid);
|
||||||
|
|
||||||
|
void addFileToTranscription(Transcription transcription, String filename);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,6 @@
|
|||||||
|
package se.su.dsv.whisperapi.core;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
public record TranscriptionFile(String filename, InputStream data) {
|
||||||
|
}
|
@ -1,12 +1,20 @@
|
|||||||
package se.su.dsv.whisperapi.core;
|
package se.su.dsv.whisperapi.core;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardOpenOption;
|
||||||
|
import java.security.Principal;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public class TranscriptionService {
|
public class TranscriptionService {
|
||||||
private final TransactionRepository transactionRepository;
|
private final TransactionRepository transactionRepository;
|
||||||
|
private final Path fileDirectory;
|
||||||
|
|
||||||
public TranscriptionService(TransactionRepository transactionRepository) {
|
public TranscriptionService(TransactionRepository transactionRepository, Path fileDirectory) {
|
||||||
this.transactionRepository = transactionRepository;
|
this.transactionRepository = transactionRepository;
|
||||||
|
this.fileDirectory = fileDirectory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -20,4 +28,21 @@ public class TranscriptionService {
|
|||||||
transactionRepository.save(transcription);
|
transactionRepository.save(transcription);
|
||||||
return transcription;
|
return transcription;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<Transcription> getTranscription(Principal owner, UUID uuid) {
|
||||||
|
return transactionRepository.findByOwnerAndId(owner, uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addFileToBeTranscribed(Transcription transcription, TranscriptionFile file)
|
||||||
|
throws IOException
|
||||||
|
{
|
||||||
|
Path fileToBeTranscribed = fileDirectory.resolve(transcription.id().toString()).resolve(file.filename());
|
||||||
|
Files.createDirectories(fileToBeTranscribed.getParent());
|
||||||
|
try (var out = Files.newOutputStream(fileToBeTranscribed, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
var in = file.data())
|
||||||
|
{
|
||||||
|
in.transferTo(out);
|
||||||
|
transactionRepository.addFileToTranscription(transcription, file.filename());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ansv7779 marked this conversation as resolved
erth9960
commented
Avoid using the filename provided by the user as a security measure. Avoid using the filename provided by the user as a security measure.
ansv7779
commented
Fixed in Fixed in https://gitea.dsv.su.se/DMC/whisper-frontend/commit/515b2aa642d3bdce23a27f96ae532f2a1ea29543
|
|||||||
|
@ -16,3 +16,4 @@ spring.mvc.problemdetails.enabled=true
|
|||||||
# Disable logging of resolved exceptions or every ErrorResponseException (ProblemDetails)
|
# Disable logging of resolved exceptions or every ErrorResponseException (ProblemDetails)
|
||||||
# that is correctly handled will be logged as a warning
|
# that is correctly handled will be logged as a warning
|
||||||
spring.mvc.log-resolved-exception=false
|
spring.mvc.log-resolved-exception=false
|
||||||
|
whisper.frontend.transcription-files-directory=/tmp
|
||||||
|
@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE transcriptions_files (
|
||||||
|
transcription_id UUID NOT NULL,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
PRIMARY KEY (transcription_id, filename),
|
||||||
|
CONSTRAINT FK_transcriptions_files_transcription
|
||||||
|
FOREIGN KEY (transcription_id) REFERENCES transcriptions (id) ON DELETE CASCADE
|
||||||
|
);
|
Loading…
x
Reference in New Issue
Block a user
This response should contain two references to URLs:
Fixed in
8d13dc31c6