diff --git a/build.gradle b/build.gradle index 7919ea69..90bec86a 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,8 @@ dependencies { implementation libs.webjar.chartjs implementation libs.webjar.chartjs.plugin.autocolors implementation libs.webjar.humanize.duration + implementation(libs.webjar.glightbox) + implementation(libs.webjar.plyr) implementation libs.mongodb.driver implementation libs.mongojack diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d60aaed0..53a0147a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,6 +32,8 @@ unpoly = "3.3.0" chartjs = "4.2.1" chartjs-plugin-autocolors = "0.2.2" humanize-duration = "3.28.0" +glightbox = "3.2.0" +plyr = "3.7.8" [libraries] javalin = { module = "io.javalin:javalin", version.ref = "javalin" } @@ -78,7 +80,8 @@ webjar-unpoly = { module = "org.webjars.npm:unpoly", version.ref = "unpoly" } webjar-chartjs = { module = "org.webjars.npm:chart.js", version.ref = "chartjs" } webjar-chartjs-plugin-autocolors = { module = "org.webjars.npm:chartjs-plugin-autocolors", version.ref = "chartjs-plugin-autocolors" } webjar-humanize-duration = { module = "org.webjars.npm:humanize-duration", version.ref = "humanize-duration" } - +webjar-glightbox = { module = "org.webjars.npm:glightbox", version.ref = "glightbox" } +webjar-plyr = { module = "org.webjars.npm:plyr", version.ref = "plyr" } [plugins] jte-gradle = { id = "gg.jte.gradle", version.ref = "jte" } diff --git a/src/main/java/com/github/khakers/modmailviewer/Main.java b/src/main/java/com/github/khakers/modmailviewer/Main.java index c6d022ec..ec0a71f4 100644 --- a/src/main/java/com/github/khakers/modmailviewer/Main.java +++ b/src/main/java/com/github/khakers/modmailviewer/Main.java @@ -1,6 +1,7 @@ package com.github.khakers.modmailviewer; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.github.khakers.modmailviewer.attachments.MongoAttachmentClient; import com.github.khakers.modmailviewer.auditlog.AuditEventDAO; import com.github.khakers.modmailviewer.auditlog.MongoAuditEventLogger; import com.github.khakers.modmailviewer.auditlog.NoopAuditEventLogger; @@ -9,6 +10,7 @@ import com.github.khakers.modmailviewer.auth.Role; import com.github.khakers.modmailviewer.configuration.AppConfig; import com.github.khakers.modmailviewer.configuration.CSPConfig; +import com.github.khakers.modmailviewer.configuration.Config; import com.github.khakers.modmailviewer.configuration.SSLConfig; import com.github.khakers.modmailviewer.log.LogController; import com.github.khakers.modmailviewer.markdown.channelmention.ChannelMentionExtension; @@ -18,6 +20,7 @@ import com.github.khakers.modmailviewer.markdown.underline.UnderlineExtension; import com.github.khakers.modmailviewer.markdown.usermention.UserMentionExtension; import com.github.khakers.modmailviewer.page.admin.AdminController; +import com.github.khakers.modmailviewer.page.attachment.AttachmentController; import com.github.khakers.modmailviewer.page.audit.AuditController; import com.github.khakers.modmailviewer.page.dashboard.DashboardController; import com.github.khakers.modmailviewer.page.dashboard.MetricsAccessor; @@ -49,13 +52,7 @@ import org.apache.logging.log4j.Logger; import org.bson.codecs.configuration.CodecRegistries; import org.bson.codecs.pojo.PojoCodecProvider; -import org.github.gestalt.config.Gestalt; -import org.github.gestalt.config.builder.GestaltBuilder; import org.github.gestalt.config.exceptions.GestaltException; -import org.github.gestalt.config.path.mapper.SnakeCasePathMapper; -import org.github.gestalt.config.source.ClassPathConfigSourceBuilder; -import org.github.gestalt.config.source.EnvironmentConfigSourceBuilder; -import org.github.gestalt.config.source.SystemPropertiesConfigSourceBuilder; import org.jetbrains.annotations.NotNull; import java.net.URI; @@ -102,47 +99,13 @@ public class Main { public static void main(String[] args) throws GestaltException { - Gestalt gestalt = new GestaltBuilder() - .setTreatNullValuesInClassAsErrors(true) - .setTreatMissingValuesAsErrors(false) - .addSource(ClassPathConfigSourceBuilder.builder() - .setResource("/default.properties") - .build()) // Load the default property files from resources. - .addSource(EnvironmentConfigSourceBuilder.builder() - .setPrefix(envPrepend) - .setRemovePrefix(true) - .build()) - .addSource(SystemPropertiesConfigSourceBuilder.builder() - .setFailOnErrors(false) - .build()) - .addDefaultPathMappers() - .addPathMapper(new SnakeCasePathMapper()) - .build(); - - try { - gestalt.loadConfigs(); - } catch (GestaltException e) { - logger.fatal(e); - System.exit(1); - throw new RuntimeException(); - } - - AppConfig appConfigInit; - try { - appConfigInit = gestalt.getConfig("app", AppConfig.class); - } catch (GestaltException e) { - logger.fatal(e); - System.exit(1); - throw new RuntimeException(); - } - var appConfig = appConfigInit; + var appConfig = Config.appConfig; logger.debug(appConfig.toString()); var auditLogConfig = appConfig.auditLogConfig(); // var cspConfig = appConfig.cspConfig(); var authConfig = appConfig.auth().orElse(null); - TemplateEngine templateEngine; if (appConfig.dev()) { @@ -206,6 +169,7 @@ public static void main(String[] args) throws GestaltException { var logController = new LogController(auditLogger); var dashboardController = new DashboardController(); + var attachmentController = new AttachmentController(new MongoAttachmentClient(mongoClientDatabase)); var app = Javalin.create(javalinConfig -> { try { @@ -237,6 +201,7 @@ public static void main(String[] args) throws GestaltException { .get("/dashboard", dashboardController.serveDashboardPage, RoleUtils.atLeastSupporter()) .get("/admin", adminController.serveAdminPage, RoleUtils.atLeastAdministrator()) .get("/audit/{id}", auditController.serveAuditPage, RoleUtils.atLeastAdministrator()) + .get("/attachment/{id}", attachmentController.getHandler(), RoleUtils.atLeastSupporter()) .after("/api/*", ctx -> { if (auditLogConfig.isApiAuditingEnabled()) { if (ctx.statusCode() == HttpStatus.FORBIDDEN.getCode()) { @@ -274,7 +239,7 @@ private static void configure(JavalinConfig config, AppConfig appConfig) throws config.showJavalinBanner = false; config.jsonMapper(new JavalinJackson().updateMapper(objectMapper -> objectMapper.registerModule(new Jdk8Module()))); - config.plugins.enableGlobalHeaders(() -> configureHeaders(sslOptions.get(), cspConfig)); + config.plugins.enableGlobalHeaders(() -> configureHeaders(sslOptions.get(), cspConfig, appConfig)); if (sslOptions.isPresent() && sslOptions.get().httpsOnly()) { logger.info("HTTPS only is ENABLED"); config.plugins.enableSslRedirects(); @@ -333,7 +298,7 @@ private static SSLPlugin getSslPlugin(AppConfig appConfig, SSLConfig sslOptions) }); } - private static GlobalHeaderConfig configureHeaders(SSLConfig sslOptions, CSPConfig cspConfig) { + private static GlobalHeaderConfig configureHeaders(SSLConfig sslOptions, CSPConfig cspConfig, AppConfig appConfig) { var globalHeaderConfig = new GlobalHeaderConfig(); globalHeaderConfig.xFrameOptions(GlobalHeaderConfig.XFrameOptions.DENY); globalHeaderConfig.xContentTypeOptionsNoSniff(); @@ -347,11 +312,13 @@ private static GlobalHeaderConfig configureHeaders(SSLConfig sslOptions, CSPConf globalHeaderConfig.contentSecurityPolicy(cspConfig.override().get()); } else { globalHeaderConfig.contentSecurityPolicy(String.format( - "default-src 'self'; " + - "img-src * 'self' data:; " + + "default-src 'self'; " + + "img-src * 'self' data: "+appConfig.s3Url().orElse("")+"; " + "object-src 'none'; " + - "media-src media.discordapp.com; " + - "style-src-attr 'unsafe-hashes' 'self' 'sha256-biLFinpqYMtWHmXfkA1BPeCY0/fNt46SAZ+BBk5YUog='; " + + "media-src 'self' media.discordapp.com cdn.discordapp.com "+appConfig.s3Url().orElse("")+"; " + + "style-src-attr 'unsafe-hashes' 'self' 'sha256-biLFinpqYMtWHmXfkA1BPeCY0/fNt46SAZ+BBk5YUog=' 'sha256-ubXkvHkNI/o3njlOwWcW1Nrt3/3G2eJn8mN1u9LCnXo='; " + + "style-src 'self' 'sha256-Jt4TB/uiervjq+0TSAyeKjWbMJlLUrE4uXVVOyC/xQA='; "+ + "frame-src 'self' https://cdn.discordapp.com https://media.discordapp.com "+appConfig.s3Url().orElse("")+"; " + "script-src-elem 'self' https://cdn.jsdelivr.net/npm/@twemoji/api@14.1.0/dist/twemoji.min.js %s;", cspConfig.extraScriptSources().orElse(""))); } diff --git a/src/main/java/com/github/khakers/modmailviewer/attachments/AttachmentClient.java b/src/main/java/com/github/khakers/modmailviewer/attachments/AttachmentClient.java new file mode 100644 index 00000000..566921df --- /dev/null +++ b/src/main/java/com/github/khakers/modmailviewer/attachments/AttachmentClient.java @@ -0,0 +1,5 @@ +package com.github.khakers.modmailviewer.attachments; + +public interface AttachmentClient { + AttachmentResult getAttachment(long id) throws AttachmentNotFoundException, UnsupportedAttachmentException; +} diff --git a/src/main/java/com/github/khakers/modmailviewer/attachments/AttachmentNotFoundException.java b/src/main/java/com/github/khakers/modmailviewer/attachments/AttachmentNotFoundException.java new file mode 100644 index 00000000..5104cd24 --- /dev/null +++ b/src/main/java/com/github/khakers/modmailviewer/attachments/AttachmentNotFoundException.java @@ -0,0 +1,7 @@ +package com.github.khakers.modmailviewer.attachments; + +public class AttachmentNotFoundException extends Exception { + public AttachmentNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/github/khakers/modmailviewer/attachments/AttachmentResult.java b/src/main/java/com/github/khakers/modmailviewer/attachments/AttachmentResult.java new file mode 100644 index 00000000..80f40d33 --- /dev/null +++ b/src/main/java/com/github/khakers/modmailviewer/attachments/AttachmentResult.java @@ -0,0 +1,8 @@ +package com.github.khakers.modmailviewer.attachments; + +public record AttachmentResult( + byte[] attachmentData, + String content_type + +) { +} diff --git a/src/main/java/com/github/khakers/modmailviewer/attachments/MongoAttachment.java b/src/main/java/com/github/khakers/modmailviewer/attachments/MongoAttachment.java new file mode 100644 index 00000000..4416b15a --- /dev/null +++ b/src/main/java/com/github/khakers/modmailviewer/attachments/MongoAttachment.java @@ -0,0 +1,30 @@ +package com.github.khakers.modmailviewer.attachments; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.bson.codecs.pojo.annotations.BsonProperty; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +@JsonIgnoreProperties(ignoreUnknown = true) +public record MongoAttachment( + @BsonProperty("_id") + @JsonProperty("_id") + long id, + @BsonProperty("content_type") + @JsonProperty("content_type") + String contentType, + byte[] data, +// @Nullable String description, + String filename, + int size, + @Nullable + Integer height, + @Nullable + Integer width, + @BsonProperty("uploaded_at") + @JsonProperty("uploaded_at") + Instant uploadTime + +) { +} diff --git a/src/main/java/com/github/khakers/modmailviewer/attachments/MongoAttachmentClient.java b/src/main/java/com/github/khakers/modmailviewer/attachments/MongoAttachmentClient.java new file mode 100644 index 00000000..ca7b9baa --- /dev/null +++ b/src/main/java/com/github/khakers/modmailviewer/attachments/MongoAttachmentClient.java @@ -0,0 +1,28 @@ +package com.github.khakers.modmailviewer.attachments; + +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Filters; +import org.bson.UuidRepresentation; +import org.mongojack.JacksonMongoCollection; + +public class MongoAttachmentClient implements AttachmentClient { + protected MongoDatabase mongoDatabase; + protected JacksonMongoCollection collection; + + public MongoAttachmentClient(MongoDatabase mongoDatabase) { + this.mongoDatabase = mongoDatabase; + // I wanted to use the mongodb java driver's pojo support, but it doesn't support integer types being null... + // So I'm using mongojack instead + this.collection = JacksonMongoCollection.builder().build(mongoDatabase, "attachments", MongoAttachment.class, UuidRepresentation.STANDARD); + } + + @Override + public AttachmentResult getAttachment(long id) throws AttachmentNotFoundException { + var result = collection.find(Filters.eq("_id", id)).first(); + if (result == null) { + throw new AttachmentNotFoundException("attachment of id " + id + " not found"); + } + + return new AttachmentResult(result.data(), result.contentType()); + } +} diff --git a/src/main/java/com/github/khakers/modmailviewer/attachments/UnsupportedAttachmentException.java b/src/main/java/com/github/khakers/modmailviewer/attachments/UnsupportedAttachmentException.java new file mode 100644 index 00000000..3f6e5b10 --- /dev/null +++ b/src/main/java/com/github/khakers/modmailviewer/attachments/UnsupportedAttachmentException.java @@ -0,0 +1,10 @@ +package com.github.khakers.modmailviewer.attachments; + +/** + * The attachment was found, but does not contain all the data required to properly return it. + */ +public class UnsupportedAttachmentException extends Exception{ + public UnsupportedAttachmentException(String message) { + super(message); + } +} diff --git a/src/main/java/com/github/khakers/modmailviewer/configuration/AppConfig.java b/src/main/java/com/github/khakers/modmailviewer/configuration/AppConfig.java index 0498e028..fdf41716 100644 --- a/src/main/java/com/github/khakers/modmailviewer/configuration/AppConfig.java +++ b/src/main/java/com/github/khakers/modmailviewer/configuration/AppConfig.java @@ -30,6 +30,8 @@ public record AppConfig( CSPConfig cspConfig, long botId, String branding, - Optional analytics + Optional analytics, + + Optional s3Url ) { } diff --git a/src/main/java/com/github/khakers/modmailviewer/configuration/Config.java b/src/main/java/com/github/khakers/modmailviewer/configuration/Config.java index 2d78d88c..576d6a39 100644 --- a/src/main/java/com/github/khakers/modmailviewer/configuration/Config.java +++ b/src/main/java/com/github/khakers/modmailviewer/configuration/Config.java @@ -7,10 +7,7 @@ import org.github.gestalt.config.builder.GestaltBuilder; import org.github.gestalt.config.exceptions.GestaltException; import org.github.gestalt.config.path.mapper.SnakeCasePathMapper; -import org.github.gestalt.config.source.ClassPathConfigSource; -import org.github.gestalt.config.source.EnvironmentConfigSource; -import org.github.gestalt.config.source.FileConfigSource; -import org.github.gestalt.config.source.SystemPropertiesConfigSource; +import org.github.gestalt.config.source.*; import java.io.File; @@ -26,7 +23,9 @@ public class Config { var gestaltBuilder = new GestaltBuilder() .setTreatNullValuesInClassAsErrors(true) .setTreatMissingValuesAsErrors(false) - .addSource(new ClassPathConfigSource("default.properties")); + .addSource(ClassPathConfigSourceBuilder.builder() + .setResource("/default.properties") + .build()); // Load the default property files from resources. if (configURI != null) { File file = new File(configURI); @@ -40,11 +39,16 @@ public class Config { } - gestalt = gestaltBuilder.addSource(new EnvironmentConfigSource(Main.envPrepend)) - .addSource(new SystemPropertiesConfigSource()) + gestalt = gestaltBuilder + .addSource(EnvironmentConfigSourceBuilder.builder() + .setPrefix(Main.envPrepend) + .setRemovePrefix(true) + .build()) + .addSource(SystemPropertiesConfigSourceBuilder.builder() + .setFailOnErrors(false) + .build()) .addDefaultPathMappers() .addPathMapper(new SnakeCasePathMapper()) - // .addSource(new FileConfigSource(devFile)) .build(); } catch (GestaltException e) { diff --git a/src/main/java/com/github/khakers/modmailviewer/configuration/InvalidConfigurationException.java b/src/main/java/com/github/khakers/modmailviewer/configuration/InvalidConfigurationException.java new file mode 100644 index 00000000..b21964a6 --- /dev/null +++ b/src/main/java/com/github/khakers/modmailviewer/configuration/InvalidConfigurationException.java @@ -0,0 +1,22 @@ +package com.github.khakers.modmailviewer.configuration; + +/** + * Thrown when the supplied configuration is invalid + */ +public class InvalidConfigurationException extends Exception { + public InvalidConfigurationException(String message) { + super(message); + } + + public InvalidConfigurationException(String message, Throwable cause) { + super(message, cause); + } + + public InvalidConfigurationException(Throwable cause) { + super(cause); + } + + public InvalidConfigurationException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/com/github/khakers/modmailviewer/data/Attachment.java b/src/main/java/com/github/khakers/modmailviewer/data/Attachment.java index 5297193d..57d20caa 100644 --- a/src/main/java/com/github/khakers/modmailviewer/data/Attachment.java +++ b/src/main/java/com/github/khakers/modmailviewer/data/Attachment.java @@ -1,16 +1,70 @@ package com.github.khakers.modmailviewer.data; import com.fasterxml.jackson.annotation.JsonProperty; +import com.github.khakers.modmailviewer.configuration.Config; +import org.apache.logging.log4j.LogManager; import org.bson.BsonType; +import org.bson.codecs.pojo.annotations.BsonProperty; import org.bson.codecs.pojo.annotations.BsonRepresentation; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; + public record Attachment( - @BsonRepresentation(BsonType.STRING) - long id, - String filename, - String url, - @JsonProperty("is_image") - boolean isImage, - int size + @BsonRepresentation(BsonType.STRING) + long id, + String filename, + String url, + @JsonProperty("is_image") + @BsonProperty("is_image") + @Deprecated + boolean isImage, + int size, + + Optional type, + + //Requires modmail enhanced feature support + @JsonProperty("content_type") + @BsonProperty("content_type") + @Nullable + String contentType, + + @JsonProperty("s3_object") + @BsonProperty("s3_object") + @Nullable + String s3Object, + @JsonProperty("s3_bucket") + @BsonProperty("s3_bucket") + @Nullable + String s3Bucket + ) { + + public Optional getAttachmentURI() { + if (type.isPresent() && type.get().equals("internal")) { + return Optional.of("/attachment/" + id); + } + if (type.isPresent() && type.get().equals("s3")) { + if (Config.appConfig.s3Url().isEmpty()) { + LogManager.getLogger().error("S3 URL not configured, cannot generate attachment URL"); + return Optional.empty(); + } + if (s3Bucket == null || s3Object == null) { + LogManager.getLogger().error("The attachment {} is missing s3 bucket ({}) or object ({}) information, cannot generate attachment URL", id, s3Bucket, s3Object); + return Optional.empty(); + } + return (Config.appConfig.s3Url().get()+"/"+ s3Bucket+"/"+ s3Object).describeConstable(); + } + + return url.describeConstable(); + } + + public boolean isImageContentType() { + return contentType != null && contentType.startsWith("image/"); + } + + public boolean isVideoContentType() { + return contentType != null && contentType.startsWith("video/"); + } } diff --git a/src/main/java/com/github/khakers/modmailviewer/data/User.java b/src/main/java/com/github/khakers/modmailviewer/data/User.java index 0f31f169..fd0e5f4c 100644 --- a/src/main/java/com/github/khakers/modmailviewer/data/User.java +++ b/src/main/java/com/github/khakers/modmailviewer/data/User.java @@ -10,6 +10,10 @@ public record User( @BsonProperty(value = "avatar_url") @JsonProperty("avatar_url") String avatarUrl, + /* + * This is true if the channel the message was sent in was not a DM + * It implies nothing about actual status as a modw + */ boolean mod ) { } diff --git a/src/main/java/com/github/khakers/modmailviewer/jte/JteContext.java b/src/main/java/com/github/khakers/modmailviewer/jte/JteContext.java index 7bf6ca0c..f65d9bad 100644 --- a/src/main/java/com/github/khakers/modmailviewer/jte/JteContext.java +++ b/src/main/java/com/github/khakers/modmailviewer/jte/JteContext.java @@ -4,16 +4,24 @@ import gg.jte.Content; import io.javalin.http.Context; +import java.util.Locale; + public final class JteContext { private static final ThreadLocal context = ThreadLocal.withInitial(JteContext::new); private JteLocalizer localizer; + private final Locale locale = Locale.ENGLISH; + public static void init(Context ctx) { JteContext context = getContext(); //todo language context.localizer = new JteLocalizer(Localizer.getInstance("en")); } + public static Locale getLocale() { + return getContext().locale; + } + public static Content localize(String key) { return getContext().localizer.localize(key); } diff --git a/src/main/java/com/github/khakers/modmailviewer/page/attachment/AttachmentController.java b/src/main/java/com/github/khakers/modmailviewer/page/attachment/AttachmentController.java new file mode 100644 index 00000000..4279845f --- /dev/null +++ b/src/main/java/com/github/khakers/modmailviewer/page/attachment/AttachmentController.java @@ -0,0 +1,49 @@ +package com.github.khakers.modmailviewer.page.attachment; + + +import com.github.khakers.modmailviewer.attachments.AttachmentClient; +import com.github.khakers.modmailviewer.attachments.AttachmentNotFoundException; +import com.github.khakers.modmailviewer.attachments.UnsupportedAttachmentException; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.http.InternalServerErrorResponse; +import io.javalin.http.NotFoundResponse; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class AttachmentController { + private static final Logger logger = LogManager.getLogger(); + + protected AttachmentClient handler; + + public AttachmentController(AttachmentClient handler) { + this.handler = handler; + } + + public void handle (Context ctx) { + logger.traceEntry(); + var id = ctx.pathParamAsClass("id", Long.class).get(); + logger.debug("getting attachment id {}", id); + try { + var attachment_data = handler.getAttachment(id); + + ctx.contentType(attachment_data.content_type()); + ctx.result(attachment_data.attachmentData()); + // set caching headers + ctx.header("max-age", "604800").header("immutable",""); + // Override X-Frame-Options header + // This is required for the attachment viewer to work + ctx.header("X-Frame-Options", "SAMEORIGIN"); + } catch (AttachmentNotFoundException e) { + throw new NotFoundResponse(); + } catch (UnsupportedAttachmentException e) { + logger.throwing(e); + throw new InternalServerErrorResponse(""); + } + logger.traceExit(); + + } + public Handler getHandler() { + return this::handle; + } +} diff --git a/src/main/java/com/github/khakers/modmailviewer/util/AttachmentFormatUtils.java b/src/main/java/com/github/khakers/modmailviewer/util/AttachmentFormatUtils.java new file mode 100644 index 00000000..3661d335 --- /dev/null +++ b/src/main/java/com/github/khakers/modmailviewer/util/AttachmentFormatUtils.java @@ -0,0 +1,96 @@ +package com.github.khakers.modmailviewer.util; + +import com.github.khakers.modmailviewer.data.Attachment; + +public class AttachmentFormatUtils { + + public static boolean isMediaAttachment(Attachment attachment) { + return isImage(attachment) || isVideo(attachment); + } + + public static boolean isImage(Attachment attachment) { + if (attachment.contentType() == null) { + return isImage(getFileExtension(attachment.filename())); + } + return attachment.contentType().startsWith("image"); + } + + public static boolean isVideo(Attachment attachment) { + if (attachment.contentType() == null) { + return isSupportedVideoContainer(getFileExtension(attachment.filename())); + } + return attachment.contentType().startsWith("video"); + } + + public static boolean isAudio(Attachment attachment) { + if (attachment.contentType() == null) { + return isAudio(getFileExtension(attachment.filename())); + } + return attachment.contentType().startsWith("audio"); + } + + public static boolean isImage(String format) { + format = format + .strip() + .replace(".", "") + .toLowerCase(); + return format.equals("png") + || format.equals("jpg") + || format.equals("jpeg") + || format.equals("gif") + || format.equals("webp") + || format.equals("bmp") + || format.equals("tiff") + || format.equals("avif"); + } + + + /** + * Best effort guess at whether the file is an audio file based on the file extension + * + * @param format The file extension string + * @return Whether the file extension appears to be an audio file + */ + public static boolean isAudio(String format) { + format = format + .strip() + .replace(".", "") + .toLowerCase(); + return format.equals("ogg") + || format.equals("oga") + || format.equals("wav") + || format.equals("flac") + || format.equals("mp3") + || format.equals("opus") + || format.equals("pcm") + || format.equals("vorbis") + || format.equals("aac"); + } + + public static boolean isSupportedAudioContainer(String format) { + format = format.toLowerCase(); + return format.equals("ogg") + || format.equals("wav") + || format.equals("flac"); + } + + /** + * Best effort guess at whether the file is a supported video container in chromium based on the file extension + * + * @param format The file extension string + * @return Whether the file extension appears to be supported video container + */ + public static boolean isSupportedVideoContainer(String format) { + format = format + .strip() + .replace(".", "") + .toLowerCase(); + return format.equals("webm") + || format.equals("mp4") + || format.equals("mkv"); + } + + public static String getFileExtension(String filename) { + return filename.substring(filename.lastIndexOf(".") + 1); + } +} diff --git a/src/main/jte/macros/AttachmentsContainer.jte b/src/main/jte/macros/AttachmentsContainer.jte new file mode 100644 index 00000000..17d9a1cd --- /dev/null +++ b/src/main/jte/macros/AttachmentsContainer.jte @@ -0,0 +1,25 @@ +@import java.util.List; +@import com.github.khakers.modmailviewer.data.Attachment +@import com.github.khakers.modmailviewer.data.Message +@import com.github.khakers.modmailviewer.util.AttachmentFormatUtils; + +@param List attachments +@param gg.jte.support.ForSupport message +@param boolean nsfw = false +@param java.text.NumberFormat numberFormat = java.text.NumberFormat.getInstance() + + +@if(attachments != null && !attachments.isEmpty()) +
+ @for(var attachment: attachments.stream().filter(AttachmentFormatUtils::isMediaAttachment).filter(attachment -> attachment.getAttachmentURI().isPresent()).toList()) +
+ @template.macros.MediaAttachment(attachment = attachment, message = message.get(), nsfw = nsfw, galleryIndex = message.getIndex()) +
+ @endfor +
+
+ @for(var attachment: attachments.stream().filter(a -> !AttachmentFormatUtils.isMediaAttachment(a)).toList()) + @template.macros.FileAttachment(attachment = attachment, message = message.get(), nsfw = nsfw, numberFormat = numberFormat) + @endfor +
+@endif \ No newline at end of file diff --git a/src/main/jte/macros/FileAttachment.jte b/src/main/jte/macros/FileAttachment.jte new file mode 100644 index 00000000..60dbf17d --- /dev/null +++ b/src/main/jte/macros/FileAttachment.jte @@ -0,0 +1,27 @@ +@import com.github.khakers.modmailviewer.data.Attachment +@import com.github.khakers.modmailviewer.data.Message +@import com.github.khakers.modmailviewer.util.AttachmentFormatUtils +@import java.text.NumberFormat + +@param Attachment attachment +@param Message message +@param boolean nsfw +@param NumberFormat numberFormat + + +
+
+ !{var uri = attachment.getAttachmentURI().orElse(null);} +
${attachment.filename()}
+ @if(attachment.getAttachmentURI().isEmpty()) + + Attachment not found + @endif +
${attachment.size()} Bytes
+ @if(AttachmentFormatUtils.isAudio(attachment)) + + @elseif(AttachmentFormatUtils.isVideo(attachment)) + + @endif +
+
\ No newline at end of file diff --git a/src/main/jte/macros/HeaderImports.jte b/src/main/jte/macros/HeaderImports.jte index 6a32c3bd..99ea3128 100644 --- a/src/main/jte/macros/HeaderImports.jte +++ b/src/main/jte/macros/HeaderImports.jte @@ -31,6 +31,11 @@ + + + + + @if(com.github.khakers.modmailviewer.configuration.Config.appConfig.analytics().isPresent())- $unsafe{com.github.khakers.modmailviewer.configuration.Config.appConfig.analytics().get()} diff --git a/src/main/jte/macros/MediaAttachment.jte b/src/main/jte/macros/MediaAttachment.jte new file mode 100644 index 00000000..4fcd064f --- /dev/null +++ b/src/main/jte/macros/MediaAttachment.jte @@ -0,0 +1,75 @@ +@import com.github.khakers.modmailviewer.data.Attachment +@import com.github.khakers.modmailviewer.data.Message +@import com.github.khakers.modmailviewer.util.AttachmentFormatUtils + +@param Attachment attachment +@param Message message +@param boolean nsfw +@param int galleryIndex = 0 + +<%--'data-type="video"' must be properly set, otherwise, some videos will download instead of opening the lightbox --%> +!{var uri = attachment.getAttachmentURI().get();} +@if(attachment.filename().startsWith("SPOILER_")) +
+ + @if(AttachmentFormatUtils.isImage(attachment)) + + Image ${attachment.filename()} uploaded by ${message.getAuthor().name()} + + @else + + + + @endif + +
+@elseif(nsfw) +
+ + @if(AttachmentFormatUtils.isImage(attachment)) + + Image ${attachment.filename()} uploaded by ${message.getAuthor().name()} + + @else + + + + @endif + +
+@else +
+ @if(AttachmentFormatUtils.isImage(attachment)) + + Image ${attachment.filename()} uploaded by ${message.getAuthor().name()} + + @else + + <%-- potato--%> + + + @endif +
+ +@endif + diff --git a/src/main/jte/macros/imageAttachment.jte b/src/main/jte/macros/imageAttachment.jte deleted file mode 100644 index 65f5ea1e..00000000 --- a/src/main/jte/macros/imageAttachment.jte +++ /dev/null @@ -1,34 +0,0 @@ -@import com.github.khakers.modmailviewer.data.Attachment -@import com.github.khakers.modmailviewer.data.Message - -@param Attachment attachment -@param Message message -@param boolean nsfw - -@if(attachment.filename().startsWith("SPOILER_")) -
- - Image ${attachment.filename()} uploaded by ${message.getAuthor().name()} - -
-@elseif(nsfw) -
- - Image ${attachment.filename()} uploaded by ${message.getAuthor().name()} - -
-@else -
- Image ${attachment.filename()} uploaded by ${message.getAuthor().name()} -
-@endif - diff --git a/src/main/jte/pages/LogEntryView.jte b/src/main/jte/pages/LogEntryView.jte index 6d9ba2dc..add753e7 100644 --- a/src/main/jte/pages/LogEntryView.jte +++ b/src/main/jte/pages/LogEntryView.jte @@ -1,5 +1,6 @@ @import com.github.khakers.modmailviewer.Main @import com.github.khakers.modmailviewer.data.MessageType +@import com.github.khakers.modmailviewer.util.AttachmentFormatUtils @import com.github.khakers.modmailviewer.util.DiscordUtils @import static com.github.khakers.modmailviewer.jte.JteContext.* @@ -8,6 +9,7 @@ !{var parser = Main.PARSER;} !{var renderer = Main.RENDERER;} !{var modmailLog = page.log;} +!{var nf = java.text.NumberFormat.getInstance(getLocale());} @template.layout.Page(page = page, content = @`
@@ -70,13 +72,21 @@
$unsafe{renderer.render(parser.parse(message.get().getContent()))}
- @if(!message.get().getAttachments().isEmpty()) - @for(var attachment: message.get().getAttachments()) - @if(attachment.isImage()) - @template.macros.imageAttachment(attachment = attachment, message = message.get(), nsfw = modmailLog.isNsfw()) - @endif - @endfor - @endif + @template.macros.AttachmentsContainer(attachments = message.get().getAttachments(), message = message) +<%-- @if(message.get().getAttachments() != null && !message.get().getAttachments().isEmpty())--%> +<%--
--%> +<%-- @for(var attachment: message.get().getAttachments().stream().filter(AttachmentFormatUtils::isMediaAttachment).toList())--%> +<%--
--%> +<%-- @template.macros.MediaAttachment(attachment = attachment, message = message.get(), nsfw = modmailLog.isNsfw(), galleryIndex = message.getIndex())--%> +<%--
--%> +<%-- @endfor--%> +<%--
--%> +<%--
--%> +<%-- @for(var attachment: message.get().getAttachments().stream().filter(a -> !AttachmentFormatUtils.isMediaAttachment(a)).toList())--%> +<%-- @template.macros.FileAttachment(attachment = attachment, message = message.get(), nsfw = modmailLog.isNsfw(), numberFormat = nf)--%> +<%-- @endfor--%> +<%--
--%> +<%-- @endif--%>
@@ -140,13 +150,22 @@ @if(message.get().isEdited()) (${localize("MESSAGE_EDITED_FLAG")}) @endif - @if(!message.get().getAttachments().isEmpty()) - @for(var attachment: message.get().getAttachments()) - @if(attachment.isImage()) - @template.macros.imageAttachment(attachment = attachment, message = message.get(), nsfw = modmailLog.isNsfw()) - @endif - @endfor - @endif + @template.macros.AttachmentsContainer(attachments = message.get().getAttachments(), message = message) + +<%-- @if(message.get().getAttachments() != null && !message.get().getAttachments().isEmpty())--%> +<%--
--%> +<%-- @for(var attachment: message.get().getAttachments().stream().filter(AttachmentFormatUtils::isMediaAttachment).toList())--%> +<%--
--%> +<%-- @template.macros.MediaAttachment(attachment = attachment, message = message.get(), nsfw = modmailLog.isNsfw(), galleryIndex = message.getIndex())--%> +<%--
--%> +<%-- @endfor--%> +<%--
--%> +<%--
--%> +<%-- @for(var attachment: message.get().getAttachments().stream().filter(a -> !AttachmentFormatUtils.isMediaAttachment(a)).toList())--%> +<%-- @template.macros.FileAttachment(attachment = attachment, message = message.get(), nsfw = modmailLog.isNsfw(), numberFormat = nf)--%> +<%-- @endfor--%> +<%--
--%> +<%-- @endif--%> diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index cbaaee5d..a4a321d0 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -77,10 +77,10 @@ img.emoji { vertical-align: -0.1em; } -.image { - max-width: 256px; - max-height: 256px; -} +/*.image {*/ +/* max-width: 256px;*/ +/* max-height: 256px;*/ +/*}*/ div.spoilerImage { @@ -90,12 +90,19 @@ div.spoilerImage { padding: 0; } -div.spoilerImage img { +div.spoilerImage a img, div.spoilerImage a video { vertical-align: bottom; filter: blur(30px); transition: 0.3s; } +/* + So lightboxes don't get opened on images that are still hidden by spoilers (or nsfw) + */ +div.spoilerImage input:not(:checked) ~ a { + pointer-events: none; +} + .spoilerImageButton { position: absolute; top: 0; @@ -130,7 +137,7 @@ div.spoilerImage input { height: 0; } -div.spoilerImage input:checked ~ img { +div.spoilerImage input:checked ~ a img, div.spoilerImage input:checked ~ a video { filter: none; } @@ -266,6 +273,31 @@ up-modal-box { vertical-align: .125em; } +.media-attachments { + max-width: 48rem; +} +.non-media-attachments { + max-width: 48rem; +} + +.media-attachments video, .media-attachments image { + max-height: 350px; +} + +.attachment-card+.attachment-card { + margin-top: .5rem; +} + +.attachment-card video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.attachment-card audio { + width: 100%; +} + .chart { height: 400px; max-height: 480px; diff --git a/src/main/resources/static/js/BaseModules.js b/src/main/resources/static/js/BaseModules.js new file mode 100644 index 00000000..93dbe077 --- /dev/null +++ b/src/main/resources/static/js/BaseModules.js @@ -0,0 +1,22 @@ +"use strict"; + +import '/webjars/glightbox/3.2.0/dist/js/glightbox.min.js'; + +const CSS_URL = new URL("/webjars/plyr/3.7.8/dist/plyr.css",window.location.origin); +const JS_URL = new URL("/webjars/plyr/3.7.8/dist/plyr.min.js",window.location.origin); +const ICON_URL = new URL("/webjars/plyr/3.7.8/dist/plyr.svg",window.location.origin); + +const lightbox = GLightbox({ + plyr: { + css: CSS_URL.toString(), + js: JS_URL.toString(), + config: { + iconUrl: ICON_URL.toString(), + } + } +}); + + +up.on("up:fragment:inserted", () => { + lightbox.reload(); +}); \ No newline at end of file