diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 1adf021..5a137ce 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -18,6 +18,34 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter") } -tasks.test { - useJUnitPlatform() -} \ No newline at end of file + +tasks.jar { + manifest.attributes["Main-Class"] = "de.srsoftware.umbrella.core.Launcher" + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + val dependencies = configurations + .runtimeClasspath + .get() + .map(::zipTree) // OR .map { zipTree(it) } + from(dependencies) +} + +fun download(url : String, destination : String){ + var destFile = projectDir.toPath().resolve(destination).toFile(); + destFile.parentFile.mkdirs() + if (!destFile.exists()) { + System.out.println("Downloading "+url) + ant.invokeMethod("get", mapOf("src" to url, "dest" to destFile)) + } +} + +tasks.register("downloadLib"){ + download("https://github.com/AshurAxelR/JParsedown/raw/refs/heads/master/src/com/xrbpowered/jparsedown/JParsedown.java", "src/main/java/com/xrbpowered/jparsedown/JParsedown.java") +} + +tasks.withType() { + exclude("META-INF/*.RSA", "META-INF/*.SF", "META-INF/*.DSA") +} + +tasks.named("compileJava") { + dependsOn("downloadLib") +} diff --git a/core/src/main/java/.gitignore b/core/src/main/java/.gitignore new file mode 100644 index 0000000..ffb7ad6 --- /dev/null +++ b/core/src/main/java/.gitignore @@ -0,0 +1 @@ +com/ \ No newline at end of file diff --git a/messages/build.gradle.kts b/messages/build.gradle.kts new file mode 100644 index 0000000..df574cc --- /dev/null +++ b/messages/build.gradle.kts @@ -0,0 +1,14 @@ +description = "Umbrella : Message subsystem" + +dependencies{ + implementation(project(":core")) + implementation(project(":user")) + implementation("de.srsoftware:configuration.api:1.0.2") + implementation("de.srsoftware:tools.jdbc:1.3.2") + implementation("de.srsoftware:tools.mime:1.1.2") + implementation("de.srsoftware:tools.optionals:1.0.0") + implementation("de.srsoftware:tools.util:2.0.3") + implementation("org.bitbucket.b_c:jose4j:0.9.6") + implementation("org.json:json:20240303") + implementation("org.xerial:sqlite-jdbc:3.49.0.0") +} \ No newline at end of file diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/Constants.java b/messages/src/main/java/de/srsoftware/umbrella/message/Constants.java new file mode 100644 index 0000000..b58b181 --- /dev/null +++ b/messages/src/main/java/de/srsoftware/umbrella/message/Constants.java @@ -0,0 +1,18 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.message; + +public class Constants { + public static final String AUTH = "mail.smtp.auth"; + public static final String ENVELOPE_FROM = "mail.smtp.from"; + public static final String FIELD_MESSAGES = "messages"; + public static final String FIELD_HOST = "host"; + public static final String FIELD_PORT = "port"; + public static final String HOST = "mail.smtp.host"; + public static final String JSONARRAY = "json array"; + public static final String JSONOBJECT = "json object"; + public static final String PORT = "mail.smtp.port"; + public static final String RECEIVERS = "receivers"; + public static final String SSL = "mail.smtp.ssl.enable"; + public static final String SUBMISSION = "submission"; + +} diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/MessageDb.java b/messages/src/main/java/de/srsoftware/umbrella/message/MessageDb.java new file mode 100644 index 0000000..0499721 --- /dev/null +++ b/messages/src/main/java/de/srsoftware/umbrella/message/MessageDb.java @@ -0,0 +1,11 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.message; + +import de.srsoftware.umbrella.core.UmbrellaException; +import de.srsoftware.umbrella.message.model.Settings; +import de.srsoftware.umbrella.user.model.UmbrellaUser; + +public interface MessageDb { + public Settings getSettings(UmbrellaUser user) throws UmbrellaException; + public Settings update(UmbrellaUser user, Settings settings) throws UmbrellaException; +} diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/SqliteDb.java b/messages/src/main/java/de/srsoftware/umbrella/message/SqliteDb.java new file mode 100644 index 0000000..f2a0c4b --- /dev/null +++ b/messages/src/main/java/de/srsoftware/umbrella/message/SqliteDb.java @@ -0,0 +1,126 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.message; + +import de.srsoftware.umbrella.core.UmbrellaException; +import de.srsoftware.umbrella.user.model.UmbrellaUser; +import de.srsoftware.umbrella.message.model.Settings; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashSet; +import java.util.stream.Collectors; + +import static de.srsoftware.tools.jdbc.Condition.equal; +import static de.srsoftware.tools.jdbc.Query.*; +import static de.srsoftware.umbrella.core.Constants.*; +import static de.srsoftware.umbrella.message.model.Settings.Times; +import static java.lang.System.Logger.Level.ERROR; +import static java.lang.System.Logger.Level.WARNING; +import static java.text.MessageFormat.format; + +public class SqliteDb implements MessageDb{ + private static final System.Logger LOG = System.getLogger(SqliteDb.class.getSimpleName()); + private final Connection db; + private static final String DB_VERSION = "message_db_version"; + private static final int INITIAL_DB_VERSION = 1; + private static final String TABLE_SUBMISSIONS = "message_submission"; + + public SqliteDb(Connection conn){ + db = conn; + init(); + } + + private void createSubmissionTable() { + var createTable = """ +CREATE TABLE IF NOT EXISTS {0} ( {1} Integer PRIMARY KEY, {2} VARCHAR(255) NOT NULL); +"""; + try { + var stmt = db.prepareStatement(format(createTable,TABLE_SUBMISSIONS, USER_ID, VALUE)); + stmt.execute(); + stmt.close(); + } catch (SQLException e) { + LOG.log(ERROR,ERROR_FAILED_CREATE_TABLE,TABLE_SUBMISSIONS,e); + throw new RuntimeException(e); + } + } + + + private int createSettingsTable() { + var createTable = """ +CREATE TABLE IF NOT EXISTS {0} ( {1} VARCHAR(255) PRIMARY KEY, {2} VARCHAR(255) NOT NULL); +"""; + try { + var stmt = db.prepareStatement(format(createTable,TABLE_SETTINGS, KEY, VALUE)); + stmt.execute(); + stmt.close(); + } catch (SQLException e) { + LOG.log(ERROR,ERROR_FAILED_CREATE_TABLE,TABLE_SETTINGS,e); + throw new RuntimeException(e); + } + + Integer version = null; + try { + var rs = select(VALUE).from(TABLE_SETTINGS).where(KEY, equal(DB_VERSION)).exec(db); + if (rs.next()) version = rs.getInt(VALUE); + rs.close(); + if (version == null) { + version = INITIAL_DB_VERSION; + insertInto(TABLE_SETTINGS, KEY, VALUE).values(DB_VERSION,version).execute(db).close(); + } + + return version; + } catch (SQLException e) { + LOG.log(ERROR,ERROR_FAILED_CREATE_TABLE,DB_VERSION,TABLE_SETTINGS,e); + throw new RuntimeException(e); + } + } + + private int createTables() { + createSubmissionTable(); + return createSettingsTable(); + } + + @Override + public Settings getSettings(UmbrellaUser user) throws UmbrellaException { + try { + Settings settings = null; + var rs = select(VALUE).from(TABLE_SUBMISSIONS).where(USER_ID,equal(user.id())).exec(db); + if (rs.next()) settings = toSettings(rs); + rs.close(); + if (settings != null) return settings; + throw new UmbrellaException(500,"No submission settings stored for {0}",user); + } catch (SQLException e) { + LOG.log(ERROR,"Failed to read settings for {0} from {1} table",user,TABLE_SUBMISSIONS,e); + throw new UmbrellaException(500,"Failed to read settings for {0} from {1} table",user,TABLE_SUBMISSIONS).causedBy(e); + } + } + + private void init() { + var version = createTables(); + } + + private Settings toSettings(ResultSet rs) throws SQLException { + var submission = rs.getString(VALUE); + var parts = submission.split(","); + var times = new HashSet(); + for (var part : parts) try { + times.add(Times.valueOf(part)); + } catch (IllegalArgumentException e) { + LOG.log(WARNING,"encountered {0}, which is not a valid Times enumeration value!",part); + } + return new Settings(times); + } + + @Override + public Settings update(UmbrellaUser user, Settings settings) throws UmbrellaException { + var times = settings.times().stream().map(Times::toString).collect(Collectors.joining(",")); + try { + replaceInto(TABLE_SUBMISSIONS, USER_ID, VALUE).values(user.id(),times).execute(db).close(); + return settings; + } catch (SQLException e) { + LOG.log(WARNING,"Failed to store submission data for {0}!",user.name(),e); + throw new UmbrellaException(500,"Failed to store submission data for {0}!",user.name()).causedBy(e); + } + } +} diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/model/Attachment.java b/messages/src/main/java/de/srsoftware/umbrella/message/model/Attachment.java new file mode 100644 index 0000000..e7fe8b3 --- /dev/null +++ b/messages/src/main/java/de/srsoftware/umbrella/message/model/Attachment.java @@ -0,0 +1,37 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.message.model; + +import static de.srsoftware.umbrella.core.Constants.*; + +import de.srsoftware.umbrella.core.UmbrellaException; +import java.util.Arrays; +import java.util.Base64; +import java.util.Objects; +import java.util.Set; +import org.json.JSONObject; + +public record Attachment(String name, String mime, byte[] content) { + + private static final Base64.Decoder BASE64 = Base64.getDecoder(); + + public static Attachment of(JSONObject json) throws UmbrellaException { + for (var key : Set.of(NAME, MIME, DATA)) { + if (!json.has(key)) throw new UmbrellaException(400,ERROR_MISSING_FIELD,key); + } + if (!(json.get(NAME) instanceof String name)) throw new UmbrellaException(400,ERROR_INVALID_FIELD, NAME,STRING); + if (!(json.get(MIME) instanceof String mime)) throw new UmbrellaException(400,ERROR_INVALID_FIELD, MIME,STRING); + if (!(json.get(DATA) instanceof String data)) throw new UmbrellaException(400,ERROR_INVALID_FIELD, DATA,STRING); + return new Attachment(name,mime, BASE64.decode(data)); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Attachment that)) return false; + return Objects.equals(name, that.name) && Objects.equals(mime, that.mime) && Objects.deepEquals(content, that.content); + } + + @Override + public int hashCode() { + return Objects.hash(name, mime, Arrays.hashCode(content)); + } +} diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/model/CombinedMessage.java b/messages/src/main/java/de/srsoftware/umbrella/message/model/CombinedMessage.java new file mode 100644 index 0000000..036389b --- /dev/null +++ b/messages/src/main/java/de/srsoftware/umbrella/message/model/CombinedMessage.java @@ -0,0 +1,70 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.message.model; + +import static java.lang.System.Logger.Level.DEBUG; +import static java.lang.System.Logger.Level.TRACE; +import static java.text.MessageFormat.format; + +import de.srsoftware.umbrella.user.model.UmbrellaUser; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Function; + +public class CombinedMessage { + private static final System.Logger LOG = System.getLogger(CombinedMessage.class.getSimpleName()); + + private final Set attachments = new HashSet<>(); + private final StringBuilder body = new StringBuilder(); + private final List mergedMessages = new ArrayList<>(); + private final Function translate; + private UmbrellaUser sender = null; + private String subject = null; + + public CombinedMessage(Function translate){ + LOG.log(DEBUG,"Creating combined message…"); + this.translate = translate; + } + + public void merge(Message message) { + LOG.log(TRACE,"Merging {0} into combined message…",message); + switch (mergedMessages.size()){ + case 0: + body.append(message.body()); + sender = message.sender(); + subject = message.subject(); + break; + case 1: + body.insert(0,format("# {0}:\n# {1}:\n\n",sender,subject)); // insert sender and subject of first message right before the body of the first message + subject = translate.apply("Collected messages"); + // no break here, we need to append the subject and content + default: + body.append("\n\n# ").append(message.sender()).append(":\n"); + body.append("# ").append(message.subject()).append(":\n\n"); + body.append(message.body()); + } + attachments.addAll(message.attachments()); + mergedMessages.add(message); + } + + public Set attachments() { + return attachments; + } + + public String body() { + return body.toString(); + } + + public UmbrellaUser sender() { + return sender; + } + + public String subject() { + return subject; + } + + public List messages() { + return mergedMessages; + } +} diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/model/Envelope.java b/messages/src/main/java/de/srsoftware/umbrella/message/model/Envelope.java new file mode 100644 index 0000000..8861be3 --- /dev/null +++ b/messages/src/main/java/de/srsoftware/umbrella/message/model/Envelope.java @@ -0,0 +1,57 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.message.model; + +import static de.srsoftware.umbrella.core.Constants.ERROR_INVALID_FIELD; +import static de.srsoftware.umbrella.core.Constants.ERROR_MISSING_FIELD; +import static de.srsoftware.umbrella.message.Constants.*; +import static java.text.MessageFormat.format; + +import de.srsoftware.umbrella.core.UmbrellaException; +import de.srsoftware.umbrella.user.model.User; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.json.JSONArray; +import org.json.JSONObject; + +public class Envelope { + private Message message; + private Set receivers; + + public Envelope(Message message, HashSet receivers) { + this.message = message; + this.receivers = receivers; + } + + public static Envelope from(JSONObject json) throws UmbrellaException { + if (!json.has(RECEIVERS)) throw new UmbrellaException(400,ERROR_MISSING_FIELD,RECEIVERS); + var message = Message.from(json); + var obj = json.get(RECEIVERS); + if (obj instanceof JSONObject) obj = new JSONArray(List.of(obj)); + if (!(obj instanceof JSONArray receiverList)) throw new UmbrellaException(400,ERROR_INVALID_FIELD,RECEIVERS,JSONARRAY); + var receivers = new HashSet(); + for (var o : receiverList){ + if (!(o instanceof JSONObject receiverData)) throw new UmbrellaException(400,ERROR_INVALID_FIELD,"entries of "+RECEIVERS,JSONOBJECT); + receivers.add(User.of(receiverData)); + } + return new Envelope(message,receivers); + } + + public boolean isFor(User receiver) { + return receivers.contains(receiver); + } + + public Message message(){ + return message; + } + + public Set receivers(){ + return receivers; + } + + @Override + public String toString() { + return format("{0} (to: {1}), subject: {2}",getClass().getSimpleName(),receivers.stream().map(User::email).collect(Collectors.joining(", ")),message.subject()); + } +} diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/model/MailQueue.java b/messages/src/main/java/de/srsoftware/umbrella/message/model/MailQueue.java new file mode 100644 index 0000000..6fc119c --- /dev/null +++ b/messages/src/main/java/de/srsoftware/umbrella/message/model/MailQueue.java @@ -0,0 +1,53 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.message.model; + + +import de.srsoftware.umbrella.user.model.User; +import java.util.*; + +/** + * This maps recipient email addresses to the pending messages of that recipient + */ +public class MailQueue extends ArrayList{ + + private record Receiver(User user, Message message){} + + private final HashMap> exceptions = new HashMap<>(); + + public interface Listener{ + public void messagesAdded(); + public void setQueue(MailQueue queue); + } + + private final Set listeners = new HashSet<>(); + + public void addListener(Listener listener) { + listeners.add(listener); + listener.setQueue(this); + } + + public void commit() { + listeners.forEach(Listener::messagesAdded); + } + + public List envelopesFor(User recv) { + return stream().filter(env -> env.isFor(recv)).toList(); + } + + public void failedAt(User receiver, CombinedMessage combined, Exception ex) { + for (var message : combined.messages()) exceptions.computeIfAbsent(new Receiver(receiver,message), k -> new ArrayList<>()).add(ex); + } + + /** + * return the email addresses of the recipients of all messages in the queue + * + * @return a list of email addresses + */ + public List receivers() { + return stream().map(Envelope::receivers) + .flatMap(Set::stream) + .filter(Objects::nonNull) + .distinct() + .toList(); + } +} diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/model/Message.java b/messages/src/main/java/de/srsoftware/umbrella/message/model/Message.java new file mode 100644 index 0000000..39766b4 --- /dev/null +++ b/messages/src/main/java/de/srsoftware/umbrella/message/model/Message.java @@ -0,0 +1,60 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.message.model; + +import static de.srsoftware.tools.Optionals.isSet; +import static de.srsoftware.umbrella.core.Constants.*; +import static de.srsoftware.umbrella.message.Constants.*; +import static java.text.MessageFormat.format; + +import de.srsoftware.umbrella.core.UmbrellaException; +import de.srsoftware.umbrella.user.model.UmbrellaUser; +import de.srsoftware.umbrella.user.model.User; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import org.json.JSONArray; +import org.json.JSONObject; + +public record Message(UmbrellaUser sender, String subject, String body, List attachments) { + public static Message from(JSONObject json) throws UmbrellaException { + for (var key : Set.of(SENDER, SUBJECT, BODY)) { + if (!json.has(key)) throw new UmbrellaException(400,ERROR_MISSING_FIELD,key); + } + if (!(json.get(SENDER) instanceof JSONObject senderObject)) throw new UmbrellaException(400,ERROR_INVALID_FIELD, SENDER,JSONOBJECT); + if (!(json.get(SUBJECT) instanceof String subject && isSet(subject))) throw new UmbrellaException(400,ERROR_INVALID_FIELD, SUBJECT,STRING); + if (!(json.get(BODY) instanceof String body && isSet(body))) throw new UmbrellaException(400,ERROR_INVALID_FIELD, BODY,STRING); + + var user = User.of(senderObject); + if (!(user instanceof UmbrellaUser sender)) throw new UmbrellaException(400,"Sender is not an umbrella user!"); + var attachments = new ArrayList(); + if (json.has(ATTACHMENTS)){ + var jsonAttachments = json.get(ATTACHMENTS); + if (jsonAttachments instanceof JSONObject obj) jsonAttachments = new JSONArray(List.of(obj)); + if (jsonAttachments instanceof JSONArray arr){ + for (var att : arr){ + if (!(att instanceof JSONObject o)) throw new UmbrellaException(400,"Attachments contains entry that is not an object: {}",att); + var attachment = Attachment.of(o); + attachments.add(attachment); + } + } + } + return new Message(sender,subject,body,attachments); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Message message)) return false; + return Objects.equals(sender, message.sender) && Objects.equals(subject, message.subject) && Objects.equals(body, message.body) && Objects.equals(attachments, message.attachments); + } + + @Override + public int hashCode() { + return Objects.hash(subject, body, attachments); + } + + @Override + public String toString() { + return format("{0}(from: {1}), subject: {2}",getClass().getSimpleName(),sender,subject); + } +} diff --git a/messages/src/main/java/de/srsoftware/umbrella/message/model/Settings.java b/messages/src/main/java/de/srsoftware/umbrella/message/model/Settings.java new file mode 100644 index 0000000..9562bd1 --- /dev/null +++ b/messages/src/main/java/de/srsoftware/umbrella/message/model/Settings.java @@ -0,0 +1,38 @@ +/* © SRSoftware 2025 */ +package de.srsoftware.umbrella.message.model; + +import static de.srsoftware.umbrella.message.Constants.SUBMISSION; + +import de.srsoftware.tools.Mappable; +import java.util.Map; +import java.util.Set; + +public record Settings(Set times) implements Mappable { + + public enum Times{ + INSTANTLY, + AT8, + AT10, + AT12, + AT14, + AT16, + AT18, + AT20; + + public boolean matches(int hour){ + if (this == INSTANTLY) return false; + return Integer.parseInt(toString().substring(2)) == hour; + } + } + + public boolean sendAt(Integer scheduledHour) { + return times.contains(Times.INSTANTLY) || (scheduledHour != null && times.stream().anyMatch(time -> time.matches(scheduledHour))); + } + + + @Override + public Map toMap() { + return Map.of(SUBMISSION,times); + } + +} diff --git a/settings.gradle.kts b/settings.gradle.kts index ea1ba03..760edb1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,9 +1,9 @@ rootProject.name = "Umbrella25" include("backend") +include("core") +include("legacy") +include("messages") include("translations") include("user") include("web") - -include("core") -include("legacy") \ No newline at end of file diff --git a/user/src/main/java/de/srsoftware/umbrella/user/model/User.java b/user/src/main/java/de/srsoftware/umbrella/user/model/User.java index 89f5932..0f88304 100644 --- a/user/src/main/java/de/srsoftware/umbrella/user/model/User.java +++ b/user/src/main/java/de/srsoftware/umbrella/user/model/User.java @@ -2,9 +2,12 @@ package de.srsoftware.umbrella.user.model; import static de.srsoftware.tools.Optionals.nullable; +import static de.srsoftware.umbrella.core.Constants.*; +import static de.srsoftware.umbrella.core.Constants.LANGUAGE; import static java.text.MessageFormat.format; import java.util.Objects; +import org.json.JSONObject; public class User { @@ -37,4 +40,20 @@ public class User { public String toString() { return format("{1}({0})", nullable(name()).orElse(email()),getClass().getSimpleName()); } + + public static User of(JSONObject json){ + if (json.has(USER)) json = json.getJSONObject(USER); + var name = json.has(NAME) ? json.getString(NAME) : null; + var email = json.has(EMAIL) ? json.getString(EMAIL) : null; + if (json.has(ID) && json.has(THEME)) { + return new UmbrellaUser( + json.getLong(ID), + name, + email, + json.getString(THEME), + json.has(LANGUAGE) ? json.getString(LANGUAGE) : null + ); + } + return new User(name,email); + } }