diff --git a/Dockerfile b/Dockerfile index 8a2024d..a4d72ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,7 +83,7 @@ RUN pnpm static:build #FROM bellsoft/liberica-openjdk-alpine:latest ## add wget to manage the health check... #RUN apk add --no-cache wget -FROM common +FROM common AS prod ENV LANG C.UTF-8 @@ -98,4 +98,6 @@ EXPOSE 80 HEALTHCHECK --start-period=10s --start-interval=2s --interval=30s --timeout=5s --retries=10 \ CMD wget --no-verbose --tries=1 --spider http://localhost:80/api/health_check || exit 1 -CMD ["java", "-Xms64M", "-Xmx1G", "-cp", "/application/application.jar", "org.atriasoft.karideo.WebLauncher"] +CMD ["java", "-Xms128M", "-Xmx1G", "-cp", "/application/application.jar", "org.atriasoft.karideo.WebLauncher"] + +#RUN cat /etc/passwd \ No newline at end of file diff --git a/back/CheckStyle.xml b/back/CheckStyle.xml index d68aedd..ac24ac1 100755 --- a/back/CheckStyle.xml +++ b/back/CheckStyle.xml @@ -53,6 +53,9 @@ Checkstyle configuration that checks the sun coding conventions. + + + diff --git a/back/pom.xml.versionsBackup b/back/pom.xml.versionsBackup new file mode 100644 index 0000000..b59ba80 --- /dev/null +++ b/back/pom.xml.versionsBackup @@ -0,0 +1,245 @@ + + + 4.0.0 + org.kar + karideo + 0.3.0 + + 3.1 + 21 + 21 + 3.1.1 + + + + gitea + https://gitea.atria-soft.org/api/packages/kangaroo-and-rabbit/maven + + + + + kangaroo-and-rabbit + archidata + 0.11.0 + + + org.slf4j + slf4j-simple + 2.0.9 + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + 2.16.1 + + + + org.junit.jupiter + junit-jupiter-api + 5.10.1 + test + + + org.junit.jupiter + junit-jupiter-engine + 5.10.1 + test + + + + src + test/src + ${project.basedir}/out/maven/ + + + src/resources + + + + + ${basedir}/test/resources + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.version} + + ${maven.compiler.source} + ${maven.compiler.target} + + + + org.codehaus.mojo + exec-maven-plugin + 1.4.0 + + org.kar.karideo.WebLauncher + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M5 + + + maven-assembly-plugin + + + + fully.qualified.MainClass + + + + jar-with-dependencies + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.2.0 + + private + true + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + exec-application + package + + java + + + + + org.kar.karideo.WebLauncher + + + + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.2.0 + + public + + + + + diff --git a/back/src/org/atriasoft/karideo/CacheFilter.java__ b/back/src/org/atriasoft/karideo/CacheFilter.java__ new file mode 100644 index 0000000..4269fc6 --- /dev/null +++ b/back/src/org/atriasoft/karideo/CacheFilter.java__ @@ -0,0 +1,20 @@ +package org.kar.karideo; + +public class CacheFilter { + @Override + public List create(AbstractMethod am) { + if (am.isAnnotationPresent(CacheMaxAge.class)) { + CacheMaxAge maxAge = am.getAnnotation(CacheMaxAge.class); + return newCacheFilter("max-age: " + maxAge.unit().toSeconds(maxAge.time())); + } else if (am.isAnnotationPresent(NoCache.class)) { + return newCacheFilter("no-cache"); + } else { + return Collections.emptyList(); + } + } + + private List newCacheFilter(String content) { + return Collections + . singletonList(new CacheResponseFilter(content)); + } +} diff --git a/back/src/org/atriasoft/karideo/WebLauncher.java b/back/src/org/atriasoft/karideo/WebLauncher.java index 332ba8e..68ddbce 100755 --- a/back/src/org/atriasoft/karideo/WebLauncher.java +++ b/back/src/org/atriasoft/karideo/WebLauncher.java @@ -1,10 +1,10 @@ package org.atriasoft.karideo; import java.net.URI; +import java.util.TimeZone; import java.util.logging.LogManager; import org.atriasoft.archidata.UpdateJwtPublicKey; -import org.atriasoft.archidata.api.DataResource; import org.atriasoft.archidata.catcher.GenericCatcher; import org.atriasoft.archidata.db.DbConfig; import org.atriasoft.archidata.filter.CORSFilter; @@ -12,6 +12,7 @@ import org.atriasoft.archidata.filter.OptionFilter; import org.atriasoft.archidata.migration.MigrationEngine; import org.atriasoft.archidata.tools.ConfigBaseVariable; import org.atriasoft.archidata.tools.ContextGenericTools; +import org.atriasoft.karideo.api.DataResource; import org.atriasoft.karideo.api.Front; import org.atriasoft.karideo.api.HealthCheck; import org.atriasoft.karideo.api.MediaResource; @@ -47,6 +48,7 @@ public class WebLauncher { protected HttpServer server = null; public WebLauncher() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); ConfigBaseVariable.bdDatabase = "karideo"; } @@ -126,6 +128,10 @@ public class WebLauncher { this.server = GrizzlyHttpServerFactory.createHttpServer(getBaseURI(), rc); final HttpServer serverLink = this.server; + // for (final NetworkListener listener : serverLink.getListeners()) { + // listener.getKeepAlive().setIdleTimeoutInSeconds(30); // Set idle timeout + // listener.getKeepAlive().setMaxRequestsCount(80); // Set request timeout + // } Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { @Override public void run() { diff --git a/back/src/org/atriasoft/karideo/api/DataResource.java b/back/src/org/atriasoft/karideo/api/DataResource.java new file mode 100644 index 0000000..a4f3394 --- /dev/null +++ b/back/src/org/atriasoft/karideo/api/DataResource.java @@ -0,0 +1,531 @@ +package org.atriasoft.karideo.api; + +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Date; +import java.util.UUID; + +import javax.imageio.ImageIO; + +import org.atriasoft.archidata.annotation.apiGenerator.ApiInputOptional; +import org.atriasoft.archidata.annotation.security.PermitTokenInURI; +import org.atriasoft.archidata.api.MediaStreamer; +import org.atriasoft.archidata.dataAccess.DataAccess; +import org.atriasoft.archidata.dataAccess.QueryCondition; +import org.atriasoft.archidata.dataAccess.options.Condition; +import org.atriasoft.archidata.exception.FailException; +import org.atriasoft.archidata.filter.GenericContext; +import org.atriasoft.archidata.model.Data; +import org.atriasoft.archidata.tools.ConfigBaseVariable; +import org.bson.types.ObjectId; +import org.glassfish.jersey.media.multipart.FormDataContentDisposition; +import org.glassfish.jersey.media.multipart.FormDataParam; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.swagger.v3.oas.annotations.Operation; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.InternalServerErrorException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.CacheControl; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.StreamingOutput; + +// https://stackoverflow.com/questions/35367113/jersey-webservice-scalable-approach-to-download-file-and-reply-to-client +// https://gist.github.com/aitoroses/4f7a2b197b732a6a691d + +@Path("/data") +@Produces(MediaType.APPLICATION_JSON) +public class DataResource { + private static final Logger LOGGER = LoggerFactory.getLogger(DataResource.class); + private final static int CHUNK_SIZE = 1024 * 1024; // 1MB chunks + private final static int CHUNK_SIZE_IN = 50 * 1024 * 1024; // 1MB chunks + /** Upload some datas */ + private static long tmpFolderId = 1; + + private static void createFolder(final String path) throws IOException { + if (!Files.exists(java.nio.file.Path.of(path))) { + // Log.print("Create folder: " + path); + Files.createDirectories(java.nio.file.Path.of(path)); + } + } + + public static long getTmpDataId() { + return tmpFolderId++; + } + + public static String getTmpFileInData(final long tmpFolderId) { + final String filePath = ConfigBaseVariable.getTmpDataFolder() + File.separator + tmpFolderId; + try { + createFolder(ConfigBaseVariable.getTmpDataFolder() + File.separator); + } catch (final IOException e) { + e.printStackTrace(); + } + return filePath; + } + + public static String getFileDataOld(final UUID uuid) { + final String stringUUID = uuid.toString(); + final String part1 = stringUUID.substring(0, 2); + final String part2 = stringUUID.substring(2, 4); + final String part3 = stringUUID.substring(4); + final String finalPath = part1 + File.separator + part2; + String filePath = ConfigBaseVariable.getMediaDataFolder() + "_uuid" + File.separator + finalPath + + File.separator; + try { + createFolder(filePath); + } catch (final IOException e) { + e.printStackTrace(); + } + filePath += part3; + return filePath; + } + + public static String getFileData(final ObjectId oid) { + final String stringOid = oid.toHexString(); + String dir1 = stringOid.substring(0, 2); + String dir2 = stringOid.substring(2, 4); + String dir3 = stringOid.substring(4, 6); + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final byte[] hashBytes = digest.digest(oid.toByteArray()); + dir1 = String.format("%02x", hashBytes[0]); + dir2 = String.format("%02x", hashBytes[1]); + dir3 = String.format("%02x", hashBytes[2]); + } catch (final NoSuchAlgorithmException ex) { + LOGGER.error("Fail to generate the hash of the objectId ==> ise direct value ... {}", ex.getMessage()); + } + final String finalPath = dir1 + File.separator + dir2 + File.separator + dir3; + String filePath = ConfigBaseVariable.getMediaDataFolder() + "_oid" + File.separator + finalPath + + File.separator; + try { + createFolder(filePath); + } catch (final IOException e) { + e.printStackTrace(); + } + filePath += stringOid; + return filePath; + } + + public static String getFileMetaData(final ObjectId oid) { + return getFileData(oid) + ".json"; + } + + public Data getWithSha512(final String sha512) { + LOGGER.info("find sha512 = {}", sha512); + try { + return DataAccess.getWhere(Data.class, new Condition(new QueryCondition("sha512", "=", sha512))); + } catch (final Exception e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + return null; + } + + public Data getWithId(final long id) { + LOGGER.info("find id = {}", id); + try { + return DataAccess.get(Data.class, id); + } catch (final Exception e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + return null; + } + + protected String getMimeType(final String extension) throws IOException { + return switch (extension.toLowerCase()) { + case "jpg", "jpeg" -> "image/jpeg"; + case "png" -> "image/png"; + case "webp" -> "image/webp"; + case "mka" -> "audio/x-matroska"; + case "mkv" -> "video/x-matroska"; + case "webm" -> "video/webm"; + default -> throw new IOException("Can not find the mime type of data input: '" + extension + "'"); + }; + } + + public Data createNewData(final long tmpUID, final String originalFileName, final String sha512) + throws IOException { + // determine mime type: + Data injectedData = new Data(); + String mimeType = ""; + final String extension = originalFileName.substring(originalFileName.lastIndexOf('.') + 1); + mimeType = getMimeType(extension); + injectedData.mimeType = mimeType; + injectedData.sha512 = sha512; + final String tmpPath = getTmpFileInData(tmpUID); + injectedData.size = Files.size(Paths.get(tmpPath)); + + try { + injectedData = DataAccess.insert(injectedData); + } catch (final Exception e) { + e.printStackTrace(); + return null; + } + final String mediaPath = getFileData(injectedData.oid); + LOGGER.info("src = {}", tmpPath); + LOGGER.info("dst = {}", mediaPath); + Files.move(Paths.get(tmpPath), Paths.get(mediaPath), StandardCopyOption.ATOMIC_MOVE); + LOGGER.info("Move done"); + return injectedData; + } + + public static void modeFileOldModelToNewModel(final UUID uuid, final ObjectId oid) throws IOException { + String mediaCurentPath = getFileDataOld(uuid); + String mediaDestPath = getFileData(oid); + LOGGER.info("src = {}", mediaCurentPath); + LOGGER.info("dst = {}", mediaDestPath); + if (Files.exists(Paths.get(mediaCurentPath))) { + LOGGER.info("move: {} ==> {}", mediaCurentPath, mediaDestPath); + Files.move(Paths.get(mediaCurentPath), Paths.get(mediaDestPath), StandardCopyOption.ATOMIC_MOVE); + } + // Move old meta-data... + mediaCurentPath = mediaCurentPath.substring(mediaCurentPath.length() - 4) + "meta.json"; + mediaDestPath = mediaCurentPath.substring(mediaDestPath.length() - 4) + "meta.json"; + if (Files.exists(Paths.get(mediaCurentPath))) { + LOGGER.info("moveM: {} ==> {}", mediaCurentPath, mediaDestPath); + Files.move(Paths.get(mediaCurentPath), Paths.get(mediaDestPath), StandardCopyOption.ATOMIC_MOVE); + } + LOGGER.info("Move done"); + } + + public static String saveTemporaryFile(final InputStream uploadedInputStream, final long idData) + throws FailException { + return saveFile(uploadedInputStream, DataResource.getTmpFileInData(idData)); + } + + public static void removeTemporaryFile(final long idData) { + final String filepath = DataResource.getTmpFileInData(idData); + if (Files.exists(Paths.get(filepath))) { + try { + Files.delete(Paths.get(filepath)); + } catch (final IOException e) { + LOGGER.info("can not delete temporary file : {}", Paths.get(filepath)); + e.printStackTrace(); + } + } + } + + // save uploaded file to a defined location on the server + static String saveFile(final InputStream uploadedInputStream, final String serverLocation) throws FailException { + String out = ""; + MessageDigest md = null; + try (OutputStream outpuStream = new FileOutputStream(new File(serverLocation))) { + md = MessageDigest.getInstance("SHA-512"); + outpuStream.flush(); + } catch (final IOException ex) { + throw new FailException(Response.Status.INTERNAL_SERVER_ERROR, "Can not write in temporary file", ex); + } catch (final NoSuchAlgorithmException ex) { + throw new FailException(Response.Status.INTERNAL_SERVER_ERROR, "Can not find sha512 algorithms", ex); + } + if (md != null) { + try (OutputStream outpuStream = new FileOutputStream(new File(serverLocation))) { + int read = 0; + final byte[] bytes = new byte[CHUNK_SIZE_IN]; + while ((read = uploadedInputStream.read(bytes)) != -1) { + // logger.info("write {}", read); + md.update(bytes, 0, read); + outpuStream.write(bytes, 0, read); + } + LOGGER.info("Flush input stream ... {}", serverLocation); + outpuStream.flush(); + // create the end of sha512 + final byte[] sha512Digest = md.digest(); + // convert in hexadecimal + out = bytesToHex(sha512Digest); + uploadedInputStream.close(); + } catch (final IOException ex) { + throw new FailException(Response.Status.INTERNAL_SERVER_ERROR, "Can not write in temporary file", ex); + } + } + return out; + } + + public static String bytesToHex(final byte[] bytes) { + final StringBuilder sb = new StringBuilder(); + for (final byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + public Data getSmall(final ObjectId oid) { + try { + return DataAccess.get(Data.class, oid); + } catch (final Exception e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + return null; + } + + @POST + @Path("/upload/") + @Consumes({ MediaType.MULTIPART_FORM_DATA }) + @RolesAllowed("ADMIN") + @Operation(description = "Insert a new data in the data environment", tags = "SYSTEM") + public void uploadFile( + @Context final SecurityContext sc, + @FormDataParam("file") final InputStream fileInputStream, + @FormDataParam("file") final FormDataContentDisposition fileMetaData) throws FailException { + final GenericContext gc = (GenericContext) sc.getUserPrincipal(); + LOGGER.info("==================================================="); + LOGGER.info("== DATA uploadFile {}", (gc == null ? "null" : gc.userByToken)); + LOGGER.info("==================================================="); + // public NodeSmall uploadFile(final FormDataMultiPart form) { + LOGGER.info("Upload file: "); + final String filePath = ConfigBaseVariable.getTmpDataFolder() + File.separator + tmpFolderId++; + try { + createFolder(ConfigBaseVariable.getTmpDataFolder() + File.separator); + } catch (final IOException ex) { + throw new FailException(Response.Status.INTERNAL_SERVER_ERROR, + "Impossible to create the folder in the server", ex); + } + saveFile(fileInputStream, filePath); + } + + @GET + @Path("{oid}") + @PermitTokenInURI + @RolesAllowed("USER") + @Produces(MediaType.APPLICATION_OCTET_STREAM) + @Operation(description = "Get back some data from the data environment", tags = "SYSTEM") + public Response retrieveDataId( + @Context final SecurityContext sc, + @QueryParam(HttpHeaders.AUTHORIZATION) final String token, + @HeaderParam("Range") final String range, + @PathParam("oid") final ObjectId oid) throws FailException { + final GenericContext gc = (GenericContext) sc.getUserPrincipal(); + // logger.info("==================================================="); + LOGGER.info("== DATA retrieveDataId ? oid={} user={}", oid, (gc == null ? "null" : gc.userByToken)); + // logger.info("==================================================="); + final Data value = getSmall(oid); + if (value == null) { + return Response.status(404).entity("media NOT FOUND: " + oid).type("text/plain").build(); + } + try { + return buildStream(getFileData(oid), range, + value.mimeType == null ? "application/octet-stream" : value.mimeType); + } catch (final Exception ex) { + throw new FailException(Response.Status.INTERNAL_SERVER_ERROR, "Fail to build output stream", ex); + } + } + + @GET + @Path("thumbnail/{oid}") + @RolesAllowed("USER") + @PermitTokenInURI + @Produces(MediaType.APPLICATION_OCTET_STREAM) + @Operation(description = "Get a thumbnail of from the data environment (if resize is possible)", tags = "SYSTEM") + // @CacheMaxAge(time = 10, unit = TimeUnit.DAYS) + public Response retrieveDataThumbnailId( + @Context final SecurityContext sc, + @QueryParam(HttpHeaders.AUTHORIZATION) final String token, + @HeaderParam("Range") final String range, + @PathParam("oid") final ObjectId oid) throws FailException { + final GenericContext gc = (GenericContext) sc.getUserPrincipal(); + LOGGER.info("==================================================="); + LOGGER.info("== DATA retrieveDataThumbnailId ? {}", (gc == null ? "null" : gc.userByToken)); + LOGGER.info("==================================================="); + final Data value = getSmall(oid); + if (value == null) { + return Response.status(404).entity("media NOT FOUND: " + oid).type("text/plain").build(); + } + final String filePathName = getFileData(oid); + final File inputFile = new File(filePathName); + if (!inputFile.exists()) { + return Response.status(404).entity("{\"error\":\"media Does not exist: " + oid + "\"}") + .type("application/json").build(); + } + if (value.mimeType.contentEquals("image/jpeg") || value.mimeType.contentEquals("image/png") + // || value.mimeType.contentEquals("image/webp") + ) { + // reads input image + BufferedImage inputImage; + try { + inputImage = ImageIO.read(inputFile); + } catch (final IOException ex) { + throw new FailException(Response.Status.INTERNAL_SERVER_ERROR, "Fail to READ the image", ex); + } + LOGGER.info("input size image: {}x{} type={}", inputImage.getWidth(), inputImage.getHeight(), + inputImage.getType()); + final int scaledWidth = ConfigBaseVariable.getThumbnailWidth(); + final int scaledHeight = (int) ((float) inputImage.getHeight() / (float) inputImage.getWidth() + * scaledWidth); + // creates output image + final BufferedImage outputImage = new BufferedImage(scaledWidth, scaledHeight, inputImage.getType()); + + // scales the input image to the output image + final Graphics2D g2d = outputImage.createGraphics(); + LOGGER.info("output size image: {}x{}", scaledWidth, scaledHeight); + g2d.drawImage(inputImage, 0, 0, scaledWidth, scaledHeight, null); + g2d.dispose(); + // create the output stream: + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + ImageIO.write(outputImage, ConfigBaseVariable.getThumbnailFormat(), baos); + } catch (final IOException e) { + e.printStackTrace(); + return Response.status(500).entity("Internal Error: resize fail: " + e.getMessage()).type("text/plain") + .build(); + } + final byte[] imageData = baos.toByteArray(); + LOGGER.info("output length {}", imageData.length); + if (imageData.length == 0) { + LOGGER.error("Fail to convert image... Availlable format:"); + for (final String data : ImageIO.getWriterFormatNames()) { + LOGGER.error(" - {}", data); + } + } + final Response.ResponseBuilder out = Response.ok(imageData).header(HttpHeaders.CONTENT_LENGTH, + imageData.length); + try { + out.type(getMimeType(ConfigBaseVariable.getThumbnailFormat())); + } catch (final IOException ex) { + throw new FailException(Response.Status.INTERNAL_SERVER_ERROR, + "Fail to convert mime type of " + ConfigBaseVariable.getThumbnailFormat(), ex); + } + // TODO: move this in a decorator !!! + final CacheControl cc = new CacheControl(); + cc.setMaxAge(3600); + cc.setNoCache(false); + out.cacheControl(cc); + return out.build(); + } + try { + return buildStream(filePathName, range, value.mimeType); + } catch (final Exception ex) { + throw new FailException(Response.Status.INTERNAL_SERVER_ERROR, "Fail to build output stream", ex); + } + } + + @GET + @Path("{oid}/{name}") + @PermitTokenInURI + @RolesAllowed("USER") + @Produces(MediaType.APPLICATION_OCTET_STREAM) + @Operation(description = "Get back some data from the data environment (with a beautiful name (permit download with basic name)", tags = "SYSTEM") + public Response retrieveDataFull( + @Context final SecurityContext sc, + @QueryParam(HttpHeaders.AUTHORIZATION) final String token, + @ApiInputOptional @HeaderParam("Range") final String range, + @PathParam("oid") final ObjectId oid, + @PathParam("name") final String name) throws Exception { + final GenericContext gc = (GenericContext) sc.getUserPrincipal(); + // logger.info("==================================================="); + LOGGER.info("== DATA retrieveDataFull ? id={} user={}", oid, (gc == null ? "null" : gc.userByToken)); + // logger.info("==================================================="); + final Data value = getSmall(oid); + if (value == null) { + return Response.status(404).entity("media NOT FOUND: " + oid).type("text/plain").build(); + } + return buildStream(getFileData(oid), range, + value.mimeType == null ? "application/octet-stream" : value.mimeType); + } + + /** Adapted from http://stackoverflow.com/questions/12768812/video-streaming-to-ipad-does-not-work-with-tapestry5/12829541#12829541 + * + * @param range range header + * @return Streaming output + * @throws FileNotFoundException + * @throws Exception IOException if an error occurs in streaming. */ + private Response buildStream(final String filename, final String range, final String mimeType) + throws FailException { + final File file = new File(filename); + // logger.info("request range : {}", range); + // range not requested : Firefox does not send range headers + if (range == null) { + final StreamingOutput output = new StreamingOutput() { + @Override + public void write(final OutputStream out) { + try (FileInputStream in = new FileInputStream(file)) { + final byte[] buf = new byte[1024 * 1024]; + int len; + while ((len = in.read(buf)) != -1) { + try { + out.write(buf, 0, len); + out.flush(); + // logger.info("---- wrote {} bytes file ----", len); + } catch (final IOException ex) { + LOGGER.info("remote close connection"); + break; + } + } + } catch (final IOException ex) { + throw new InternalServerErrorException(ex); + } + } + }; + final Response.ResponseBuilder out = Response.ok(output).header(HttpHeaders.CONTENT_LENGTH, file.length()); + if (mimeType != null) { + out.type(mimeType); + } + return out.build(); + + } + + final String[] ranges = range.split("=")[1].split("-"); + final long from = Long.parseLong(ranges[0]); + + // logger.info("request range : {}", ranges.length); + // Chunk media if the range upper bound is unspecified. Chrome, Opera sends "bytes=0-" + long to = CHUNK_SIZE + from; + if (ranges.length == 1) { + to = file.length() - 1; + } else if (to >= file.length()) { + to = file.length() - 1; + } + final String responseRange = String.format("bytes %d-%d/%d", from, to, file.length()); + // LOGGER.info("responseRange: {}", responseRange); + try { + final RandomAccessFile raf = new RandomAccessFile(file, "r"); + raf.seek(from); + + final long len = to - from + 1; + final MediaStreamer streamer = new MediaStreamer(len, raf); + final Response.ResponseBuilder out = Response.ok(streamer).status(Response.Status.PARTIAL_CONTENT) + .header("Accept-Ranges", "bytes").header("Content-Range", responseRange) + .header(HttpHeaders.CONTENT_LENGTH, streamer.getLenth()) + .header(HttpHeaders.LAST_MODIFIED, new Date(file.lastModified())); + if (mimeType != null) { + out.type(mimeType); + } + return out.build(); + } catch (final FileNotFoundException ex) { + throw new FailException(Response.Status.INTERNAL_SERVER_ERROR, "Fail to find the required file.", ex); + } catch (final IOException ex) { + throw new FailException(Response.Status.INTERNAL_SERVER_ERROR, "Fail to access to the required file.", ex); + } + } + + public void undelete(final Long id) throws Exception { + DataAccess.unsetDelete(Data.class, id); + } + +} diff --git a/back/src/org/atriasoft/karideo/api/UserMediaAdvancementResource.java b/back/src/org/atriasoft/karideo/api/UserMediaAdvancementResource.java index 7e34232..d149c88 100644 --- a/back/src/org/atriasoft/karideo/api/UserMediaAdvancementResource.java +++ b/back/src/org/atriasoft/karideo/api/UserMediaAdvancementResource.java @@ -29,34 +29,43 @@ import jakarta.ws.rs.core.SecurityContext; @Produces(MediaType.APPLICATION_JSON) public class UserMediaAdvancementResource { static final Logger LOGGER = LoggerFactory.getLogger(UserMediaAdvancementResource.class); - + @GET @Path("{id}") @RolesAllowed("USER") @Operation(description = "Get a specific user advancement with his ID", tags = "GLOBAL") - public UserMediaAdvancement get(@Context final SecurityContext sc, @PathParam("id") final Long id) throws Exception { + public UserMediaAdvancement get(@Context final SecurityContext sc, @PathParam("id") final Long id) + throws Exception { final GenericContext gc = (GenericContext) sc.getUserPrincipal(); - return DataAccess.getWhere(UserMediaAdvancement.class, new Condition(new QueryAnd(new QueryCondition("mediaId", "=", id), new QueryCondition("userId", "=", gc.userByToken.id)))); + return DataAccess.getWhere(UserMediaAdvancement.class, + new Condition(new QueryAnd(new QueryCondition("mediaId", "=", id), + new QueryCondition("userId", "=", gc.userByToken.id)))); } - + @GET @RolesAllowed("USER") @Operation(description = "Get all user advancement", tags = "GLOBAL") public List gets(@Context final SecurityContext sc) throws Exception { final GenericContext gc = (GenericContext) sc.getUserPrincipal(); - return DataAccess.getsWhere(UserMediaAdvancement.class, new Condition(new QueryCondition("userId", "=", gc.userByToken.id))); + return DataAccess.getsWhere(UserMediaAdvancement.class, + new Condition(new QueryCondition("userId", "=", gc.userByToken.id))); } - + /* ============================================================================= Modification SECTION: ============================================================================= */ - - public record MediaInformations(int time, float percent, int count) { - } - + + public record MediaInformations( + int time, + float percent, + int count) {} + // @POST // @Path("{id}") // @RolesAllowed("USER") // @Consumes(MediaType.APPLICATION_JSON) - public UserMediaAdvancement post(@Context final SecurityContext sc, @PathParam("id") final Long id, final MediaInformations data) throws Exception { + public UserMediaAdvancement post( + @Context final SecurityContext sc, + @PathParam("id") final Long id, + final MediaInformations data) throws Exception { final GenericContext gc = (GenericContext) sc.getUserPrincipal(); final UserMediaAdvancement elem = new UserMediaAdvancement(); elem.userId = gc.userByToken.id; @@ -66,16 +75,21 @@ public class UserMediaAdvancementResource { elem.count = data.count; return DataAccess.insert(elem); } - - public record MediaInformationsDelta(int time, float percent, boolean addCount) { - } - + + public record MediaInformationsDelta( + int time, + float percent, + boolean addCount) {} + @PUT @Path("{id}") @RolesAllowed("USER") @Consumes(MediaType.APPLICATION_JSON) @Operation(description = "Modify a user advancement", tags = "GLOBAL") - public UserMediaAdvancement patch(@Context final SecurityContext sc, @PathParam("id") final Long id, @Valid final MediaInformationsDelta data) throws Exception { + public UserMediaAdvancement patch( + @Context final SecurityContext sc, + @PathParam("id") final Long id, + @Valid final MediaInformationsDelta data) throws Exception { final UserMediaAdvancement elem = get(sc, id); if (elem == null) { // insert element @@ -91,10 +105,10 @@ public class UserMediaAdvancementResource { elem.count++; } LOGGER.info("{},{},{}", elem.time, elem.percent, elem.count); - final long nbAfected = DataAccess.update(elem, elem.id, List.of("time", "percent", "count")); + DataAccess.update(elem, elem.id, List.of("time", "percent", "count")); return DataAccess.get(UserMediaAdvancement.class, elem.id); } - + @DELETE @Path("{id}") @RolesAllowed("USER") @@ -103,5 +117,5 @@ public class UserMediaAdvancementResource { final UserMediaAdvancement elem = get(sc, id); DataAccess.delete(UserMediaAdvancement.class, elem.id); } - + } diff --git a/back/test/src/test/atriasoft/karideo/ConfigureDb.java b/back/test/src/test/atriasoft/karideo/ConfigureDb.java index 40b526f..6b30ef4 100644 --- a/back/test/src/test/atriasoft/karideo/ConfigureDb.java +++ b/back/test/src/test/atriasoft/karideo/ConfigureDb.java @@ -1,18 +1,12 @@ package test.atriasoft.karideo; import java.io.IOException; -import java.util.List; import org.atriasoft.archidata.dataAccess.DBAccess; import org.atriasoft.archidata.db.DbConfig; import org.atriasoft.archidata.db.DbIoFactory; import org.atriasoft.archidata.exception.DataAccessException; import org.atriasoft.archidata.tools.ConfigBaseVariable; -import org.atriasoft.karideo.model.Media; -import org.atriasoft.karideo.model.Season; -import org.atriasoft.karideo.model.Series; -import org.atriasoft.karideo.model.Type; -import org.atriasoft.karideo.model.UserKarideo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,7 +16,7 @@ public class ConfigureDb { final static private Logger LOGGER = LoggerFactory.getLogger(ConfigureDb.class); final static private String modeTestForced = null;// "MONGO"; public static DBAccess da = null; - + public static void configure() throws IOException, InternalServerErrorException, DataAccessException { String modeTest = System.getenv("TEST_E2E_MODE"); if (modeTest == null || modeTest.isEmpty() || "false".equalsIgnoreCase(modeTest)) { @@ -38,13 +32,6 @@ public class ConfigureDb { ConfigBaseVariable.apiAdress = "http://127.0.0.1:12342/test/api/"; // Enable the test mode permit to access to the test token (never use it in production). ConfigBaseVariable.testMode = "true"; - final List> listObject = List.of( // - Media.class, // - Season.class, // - Series.class, // - Type.class, // - UserKarideo.class // - ); if ("SQLITE-MEMORY".equalsIgnoreCase(modeTest)) { ConfigBaseVariable.dbType = "sqlite"; ConfigBaseVariable.bdDatabase = null; @@ -73,7 +60,7 @@ public class ConfigureDb { // Connect the dataBase... da = DBAccess.createInterface(); } - + public static void removeDB() { String modeTest = System.getenv("TEST_E2E_MODE"); if (modeTest == null || modeTest.isEmpty() || "false".equalsIgnoreCase(modeTest)) { @@ -116,7 +103,7 @@ public class ConfigureDb { return; } } - + public static void clear() throws IOException { LOGGER.info("Remove the test db"); removeDB(); diff --git a/back/test/src/test/atriasoft/karideo/TestHealthCheck.java b/back/test/src/test/atriasoft/karideo/TestHealthCheck.java index f15faf7..0c4ade2 100644 --- a/back/test/src/test/atriasoft/karideo/TestHealthCheck.java +++ b/back/test/src/test/atriasoft/karideo/TestHealthCheck.java @@ -1,5 +1,9 @@ package test.atriasoft.karideo; +import org.atriasoft.archidata.exception.RESTErrorResponseException; +import org.atriasoft.archidata.tools.ConfigBaseVariable; +import org.atriasoft.archidata.tools.RESTApi; +import org.atriasoft.karideo.api.HealthCheck.HealthResult; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; @@ -8,10 +12,6 @@ import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.api.extension.ExtendWith; -import org.atriasoft.archidata.exception.RESTErrorResponseException; -import org.atriasoft.archidata.tools.ConfigBaseVariable; -import org.atriasoft.archidata.tools.RESTApi; -import org.atriasoft.karideo.api.HealthCheck.HealthResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,14 +47,15 @@ public class TestHealthCheck { @Test // @RepeatedTest(10) public void checkHealthCheck() throws Exception { - final HealthResult result = api.get(HealthResult.class, "health_check"); + final HealthResult result = api.request("health_check").get().fetch(HealthResult.class); Assertions.assertEquals(result.value(), "alive and kicking"); } @Order(2) @Test public void checkHealthCheckWrongAPI() throws Exception { - Assertions.assertThrows(RESTErrorResponseException.class, () -> api.get(HealthResult.class, "health_checks")); + Assertions.assertThrows(RESTErrorResponseException.class, + () -> api.request("health_check_kaboom").get().fetch()); } } diff --git a/front/src/components/VideoPlayer.tsx b/front/src/components/VideoPlayer.tsx index e23b07b..0a7f71c 100644 --- a/front/src/components/VideoPlayer.tsx +++ b/front/src/components/VideoPlayer.tsx @@ -168,7 +168,8 @@ export const VideoPlayer = ({}: AudioPlayerProps) => { } }, [isPlaying, videoRef]); - const onAudioEnded = () => { + const onEnded = () => { + console.log('ended ...'); if (playMediaList.length === 0 || isNullOrUndefined(MediaOffset)) { return; } @@ -193,6 +194,7 @@ export const VideoPlayer = ({}: AudioPlayerProps) => { videoRef.current.currentTime = newValue; }; const onPlay = () => { + console.log(`onPlay ...`); if (!videoRef || !videoRef.current) { return; } @@ -203,6 +205,7 @@ export const VideoPlayer = ({}: AudioPlayerProps) => { } }; const onStop = () => { + console.log(`onStop ...`); if (!videoRef || !videoRef.current) { return; } @@ -310,6 +313,36 @@ export const VideoPlayer = ({}: AudioPlayerProps) => { } } }; + const onError = (event) => { + const errorDetails: MediaError = event.target.error; + let errorMessage = 'An error occurred while trying to play the video.'; + + if (errorDetails) { + if (errorDetails.code === 1) { + errorMessage = + 'The video cannot be played because the source is invalid or the format is unsupported.'; + } else if (errorDetails.code === 2) { + errorMessage = + 'The video cannot be played because the network error occurred.'; + } else if (errorDetails.code === 3) { + errorMessage = + 'The video cannot be played because of a decoding error.'; + } else if (errorDetails.code === 4) { + errorMessage = + 'The video cannot be played because of an unknown error.'; + } + } + + //setError(errorMessage); // Mettre à jour l'état de l'erreur + console.error( + 'Error code:', + errorDetails.code, + ' ==> ', + errorMessage, + ' // ', + errorDetails.message + ); + }; const [isFullScreen, setIsFullScreen] = useState(false); useEffect(() => { const handleFullScreenChange = () => { @@ -351,10 +384,6 @@ export const VideoPlayer = ({}: AudioPlayerProps) => { height={isFullScreen ? '100%' : undefined} width={isFullScreen ? '100%' : undefined} marginX="auto" - // left={0} - // right={0} - // top={0} - // bottom={0} >