Untested initial impl
This commit is contained in:
commit
146483b3c4
|
@ -0,0 +1,38 @@
|
||||||
|
.gradle
|
||||||
|
build/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
|
.idea
|
||||||
|
out/
|
||||||
|
!**/src/main/**/out/
|
||||||
|
!**/src/test/**/out/
|
||||||
|
|
||||||
|
### Eclipse ###
|
||||||
|
.apt_generated
|
||||||
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
bin/
|
||||||
|
!**/src/main/**/bin/
|
||||||
|
!**/src/test/**/bin/
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
### Mac OS ###
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
config.json5
|
|
@ -0,0 +1,29 @@
|
||||||
|
plugins {
|
||||||
|
java
|
||||||
|
application
|
||||||
|
id("com.github.johnrengelman.shadow") version "7.1.2"
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("io.gitlab.jfronny.gitea.helpdesk.Main")
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "io.gitlab.jfronny"
|
||||||
|
version = "1.0-SNAPSHOT"
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
maven("https://gitlab.com/api/v4/projects/35745143/packages/maven")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("com.sun.mail:jakarta.mail:2.0.1")
|
||||||
|
implementation("org.postgresql:postgresql:42.5.0")
|
||||||
|
implementation("org.apache.commons:commons-dbcp2:2.9.0")
|
||||||
|
implementation("io.gitlab.jfronny:commons:2022.10.22+18-33-39")
|
||||||
|
implementation("io.gitlab.jfronny:commons-gson:2022.10.22+18-33-39")
|
||||||
|
implementation("com.kohlschutter.junixsocket:junixsocket-core:2.6.0")
|
||||||
|
implementation("org.jsoup:jsoup:1.15.3")
|
||||||
|
implementation("net.freeutils:jlhttp:2.6")
|
||||||
|
compileOnly("org.jetbrains:annotations:23.0.0")
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
rootProject.name = "gitea-helpdesk"
|
|
@ -0,0 +1,100 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk;
|
||||||
|
|
||||||
|
public class Config {
|
||||||
|
public Gitea gitea;
|
||||||
|
public EMail email;
|
||||||
|
public Database database;
|
||||||
|
public Web web;
|
||||||
|
|
||||||
|
public static class Gitea {
|
||||||
|
public String host;
|
||||||
|
public String token;
|
||||||
|
|
||||||
|
public void validate() throws IllegalConfigException {
|
||||||
|
if (host == null) throw new IllegalConfigException("Missing host in gitea");
|
||||||
|
if (host.endsWith("/")) host = host.substring(0, host.length() - 1);
|
||||||
|
if (token == null) throw new IllegalConfigException("Missing token in gitea");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class EMail {
|
||||||
|
public String address;
|
||||||
|
public SMTP smtp;
|
||||||
|
public POP3 pop3;
|
||||||
|
public Integer waitTime;
|
||||||
|
|
||||||
|
public static class SMTP {
|
||||||
|
public String host;
|
||||||
|
public String password;
|
||||||
|
public Boolean ssl;
|
||||||
|
|
||||||
|
public void validate() throws IllegalConfigException {
|
||||||
|
if (host == null) throw new IllegalConfigException("Missing host in email/smtp");
|
||||||
|
if (!Main.HOST_PATTERN.matcher(host).matches()) throw new IllegalConfigException("Illegal host in email/smtp");
|
||||||
|
if (password == null) throw new IllegalConfigException("Missing password in email/smtp");
|
||||||
|
if (ssl == null) throw new IllegalConfigException("Missing ssl in email/smtp");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class POP3 {
|
||||||
|
public String host;
|
||||||
|
public String password;
|
||||||
|
public Boolean ssl;
|
||||||
|
|
||||||
|
public void validate() throws IllegalConfigException {
|
||||||
|
if (host == null) throw new IllegalConfigException("Missing host in email/pop3");
|
||||||
|
if (!Main.HOST_PATTERN.matcher(host).matches()) throw new IllegalConfigException("Illegal host in email/pop3");
|
||||||
|
if (password == null) throw new IllegalConfigException("Missing password in email/pop3");
|
||||||
|
if (ssl == null) throw new IllegalConfigException("Missing ssl in email/pop3");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validate() throws IllegalConfigException {
|
||||||
|
if (address == null) throw new IllegalConfigException("Missing address in email");
|
||||||
|
if (!Main.MAIL_PATTERN.matcher(address).matches()) throw new IllegalConfigException("Illegal mail address");
|
||||||
|
if (smtp == null) throw new IllegalConfigException("Missing smtp in email");
|
||||||
|
smtp.validate();
|
||||||
|
if (pop3 == null) throw new IllegalConfigException("Missing pop3 in email");
|
||||||
|
pop3.validate();
|
||||||
|
if (waitTime == null) throw new IllegalConfigException("Missing waitTime in email");
|
||||||
|
if (waitTime < 0) throw new IllegalConfigException("Illegal wait time");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Database {
|
||||||
|
public String connectionString;
|
||||||
|
|
||||||
|
public void validate() throws IllegalConfigException {
|
||||||
|
if (connectionString == null) throw new IllegalConfigException("Missing connectionString in database");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Web {
|
||||||
|
public Integer port;
|
||||||
|
public String publicAddress;
|
||||||
|
|
||||||
|
public void validate() throws IllegalConfigException {
|
||||||
|
if (port == null) throw new IllegalConfigException("Missing port in web");
|
||||||
|
if (port <= 0) throw new IllegalConfigException("Illegal port");
|
||||||
|
if (publicAddress == null) throw new IllegalConfigException("Missing publicAddress in web");
|
||||||
|
if (publicAddress.endsWith("/")) publicAddress = publicAddress.substring(0, publicAddress.length() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validate() throws IllegalConfigException {
|
||||||
|
if (gitea == null) throw new IllegalConfigException("Lacking gitea config");
|
||||||
|
gitea.validate();
|
||||||
|
if (email == null) throw new IllegalConfigException("Lacking gitea config");
|
||||||
|
email.validate();
|
||||||
|
if (database == null) throw new IllegalConfigException("Lacking gitea config");
|
||||||
|
database.validate();
|
||||||
|
if (web == null) throw new IllegalConfigException("Lacking web in config");
|
||||||
|
web.validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class IllegalConfigException extends Exception {
|
||||||
|
public IllegalConfigException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk;
|
||||||
|
|
||||||
|
import io.gitlab.jfronny.commons.HttpUtils;
|
||||||
|
import io.gitlab.jfronny.commons.log.Logger;
|
||||||
|
import io.gitlab.jfronny.commons.serialize.Serializer;
|
||||||
|
import io.gitlab.jfronny.commons.serialize.gson.api.v1.GsonHolders;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.db.DBInterface;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.gitea.GiteaInterface;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.mail.MailInterface;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.web.WebInterface;
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
// https://git.meli.delivery/meli/issue-bot
|
||||||
|
// https://www.javatpoint.com/example-of-receiving-attachment-with-email-using-java-mail-api
|
||||||
|
public class Main {
|
||||||
|
public static final Logger LOG = Logger.forName("Gitea-Helpdesk");
|
||||||
|
public static final Pattern MAIL_PATTERN = Pattern.compile("(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])\\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)])\n");
|
||||||
|
public static final Pattern HOST_PATTERN = Pattern.compile("(?:[0-9a-zA-Z-._~]+|[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3})(?::[0-9]{1,5})?");
|
||||||
|
public static final Pattern ALPHANUMERIC_PATTERN = Pattern.compile("[a-zA-Z0-9]+");
|
||||||
|
|
||||||
|
public static void main(String[] args) throws IOException, SQLException, MessagingException, Config.IllegalConfigException, InterruptedException {
|
||||||
|
GsonHolders.registerSerializer();
|
||||||
|
HttpUtils.setUserAgent("Gitea-Helpdesk/1.0");
|
||||||
|
LOG.info("Running Gitea-Helpdesk");
|
||||||
|
Config config;
|
||||||
|
try (Reader r = Files.newBufferedReader(Path.of("config.json5"))) {
|
||||||
|
config = Serializer.getInstance().deserialize(r, Config.class);
|
||||||
|
}
|
||||||
|
config.validate();
|
||||||
|
GiteaInterface gitea = new GiteaInterface(config.gitea);
|
||||||
|
try (DBInterface db = new DBInterface(config.database);
|
||||||
|
MailInterface mail = new MailInterface(config.email);
|
||||||
|
WebInterface web = new WebInterface(config.web, config.email.address, db, gitea)) {
|
||||||
|
UpdateTask updateTask = new UpdateTask(db, mail, gitea, web);
|
||||||
|
while (true) {
|
||||||
|
updateTask.run();
|
||||||
|
Thread.sleep(config.email.waitTime * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getResource(String path) {
|
||||||
|
try (InputStream is = Main.class.getResourceAsStream(path);
|
||||||
|
InputStreamReader isr = new InputStreamReader(Objects.requireNonNull(is));
|
||||||
|
BufferedReader br = new BufferedReader(isr)) {
|
||||||
|
return br.lines().collect(Collectors.joining("\n"));
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk;
|
||||||
|
|
||||||
|
public class UnexpectedMailException extends Exception {
|
||||||
|
public UnexpectedMailException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,135 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk;
|
||||||
|
|
||||||
|
import io.gitlab.jfronny.commons.StringFormatter;
|
||||||
|
import io.gitlab.jfronny.commons.throwable.ThrowingBiConsumer;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.db.DBInterface;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.db.Subscription;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.gitea.*;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.mail.MailInterface;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.mail.WrappedMessage;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.web.WebInterface;
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
public record UpdateTask(DBInterface db, MailInterface mail, GiteaInterface gitea, WebInterface web) implements Runnable {
|
||||||
|
private static final String TEMPLATE = Main.getResource("mail/template.html").formatted(WebInterface.THEME, "%s");
|
||||||
|
private static final String MAIL_ERROR = mail("error.html");
|
||||||
|
private static final String MAIL_UNEXPECTED = mail("unexpected.html");
|
||||||
|
private static final String MAIL_CREATE = mail("create.html");
|
||||||
|
private static final String MAIL_COMMENT = mail("comment.html");
|
||||||
|
private static final String MAIL_COMMENT_CLOSED = mail("comment_closed.html");
|
||||||
|
private static final String MAIL_ISSUE_DELETED = mail("issue_deleted.html");
|
||||||
|
private static final String MAIL_ISSUE_CLOSED = mail("issue_closed.html");
|
||||||
|
|
||||||
|
private static String mail(String path) {
|
||||||
|
return TEMPLATE.formatted(Main.getResource("mail/" + path));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
try {
|
||||||
|
updateSubscriptions();
|
||||||
|
processEmails();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Main.LOG.error("Could not run update task", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateSubscriptions() throws Exception {
|
||||||
|
String[] addressParts = mail.getAddress().split("@");
|
||||||
|
db.forEachSubscription(subscription -> {
|
||||||
|
ThrowingBiConsumer<String, String, Exception> reply = (content, subject) -> {
|
||||||
|
String[] previousMessages = subscription.referenceChain().split(" ");
|
||||||
|
String previousMessageId = previousMessages[previousMessages.length - 1];
|
||||||
|
//TODO test
|
||||||
|
mail.reply(addressParts[0] + "+reply+" + subscription.id(), subscription.email(), content, subject, previousMessageId, subscription.referenceChain(), null);
|
||||||
|
};
|
||||||
|
GiteaIssue issue;
|
||||||
|
try {
|
||||||
|
issue = gitea.getIssue(subscription.repoOwner(), subscription.repo(), subscription.issue());
|
||||||
|
} catch (FileNotFoundException fe) {
|
||||||
|
reply.accept(MAIL_ISSUE_DELETED, "Issue deleted");
|
||||||
|
db.removeSubscription(subscription.id());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (issue.state.equals("closed")) { //TODO test
|
||||||
|
reply.accept(MAIL_ISSUE_CLOSED, "Issue closed");
|
||||||
|
db.removeSubscription(subscription.id());
|
||||||
|
}
|
||||||
|
for (GiteaIssueComment comment : gitea.getComments(subscription.repoOwner(), subscription.repo(), subscription.issue())) {
|
||||||
|
if (comment.id > subscription.issueComment()) {
|
||||||
|
reply.accept(comment.body, issue.title);
|
||||||
|
db.updateSubscriptionIssueComment(subscription.id(), comment.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void processEmails() throws SQLException, MessagingException, IOException {
|
||||||
|
String[] addressParts = mail.getAddress().split("@");
|
||||||
|
for (WrappedMessage message : mail.getInbox()) {
|
||||||
|
try {
|
||||||
|
String[] args = message.getRecipientSubAddress().split("\\+");
|
||||||
|
switch (args[0]) {
|
||||||
|
case "create" -> {
|
||||||
|
if (args.length != 3) throw new UnexpectedMailException("Create classifier only allows two parameters");
|
||||||
|
String owner = args[1];
|
||||||
|
String repo = args[2];
|
||||||
|
checkArgs(owner, repo);
|
||||||
|
GiteaIssue issue = gitea.createIssue(owner, repo, message.getSubject(), formatBody(message));
|
||||||
|
String id = db.addSubscription(message.getSender(), owner, repo, issue.id, 0, message.getSender());
|
||||||
|
String unsubscribeUrl = web.getAddress() + "/unsubscribe?id=" + id; //TODO test
|
||||||
|
message.reply(addressParts[0] + "+reply+" + owner + "+" + repo + "+" + issue.id, MAIL_CREATE.formatted(issue.url, unsubscribeUrl));
|
||||||
|
}
|
||||||
|
case "reply" -> {
|
||||||
|
if (args.length != 2) throw new UnexpectedMailException("Reply classifier only allows one parameter");
|
||||||
|
Subscription sub = db.getSubscription(args[1]).orElseThrow(() -> new UnexpectedMailException("Reply classifier does not represent an active issue"));
|
||||||
|
GiteaIssueComment commentId = gitea.addComment(sub.repoOwner(), sub.repo(), sub.issue(), formatBody(message));
|
||||||
|
db.updateSubscriptionIssueComment(sub.id(), commentId.id);
|
||||||
|
db.updateSubscriptionReferenceChain(sub.id(), sub.referenceChain() + " " + message.getSender());
|
||||||
|
}
|
||||||
|
case "comment" -> {
|
||||||
|
if (args.length == 4) throw new UnexpectedMailException("Comment classifier requires four parameters");
|
||||||
|
String owner = args[1];
|
||||||
|
String repo = args[2];
|
||||||
|
long issueId = Long.parseLong(args[3]);
|
||||||
|
checkArgs(owner, repo);
|
||||||
|
GiteaIssue issue;
|
||||||
|
try {
|
||||||
|
issue = gitea.getIssue(owner, repo, issueId);
|
||||||
|
} catch (FileNotFoundException fe) {
|
||||||
|
throw new UnexpectedMailException("This issue does not exist");
|
||||||
|
}
|
||||||
|
gitea.addComment(owner, repo, issueId, formatBody(message));
|
||||||
|
if (issue.state.equals("closed")) {
|
||||||
|
message.reply(addressParts[0] + "+reply+" + owner + "+" + repo + "+" + issue.id, MAIL_COMMENT_CLOSED.formatted(issue.url));
|
||||||
|
} else {
|
||||||
|
String id = db.addSubscription(message.getSender(), owner, repo, issue.id, 0, message.getSender());
|
||||||
|
String unsubscribeUrl = web.getAddress() + "/unsubscribe?id=" + id; //TODO test
|
||||||
|
message.reply(addressParts[0] + "+reply+" + owner + "+" + repo + "+" + issue.id, MAIL_COMMENT.formatted(issue.url, unsubscribeUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> throw new UnexpectedMailException("Did not expect classifier " + args[0]);
|
||||||
|
}
|
||||||
|
} catch (UnexpectedMailException | NumberFormatException t) {
|
||||||
|
message.reply(mail.getAddress(), MAIL_UNEXPECTED.formatted(WebInterface.escapeHTML(StringFormatter.toString(t))));
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Main.LOG.error("Could not parse mail", t);
|
||||||
|
message.reply(mail.getAddress(), MAIL_ERROR.formatted(WebInterface.escapeHTML(StringFormatter.toString(t))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkArgs(String owner, String repo) throws UnexpectedMailException {
|
||||||
|
//TODO document this limitation
|
||||||
|
if (!Main.ALPHANUMERIC_PATTERN.matcher(owner).matches()) throw new UnexpectedMailException("Unsupported owner string");
|
||||||
|
if (!Main.ALPHANUMERIC_PATTERN.matcher(repo).matches()) throw new UnexpectedMailException("Unsupported repo string");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatBody(WrappedMessage message) throws UnexpectedMailException, MessagingException, IOException {
|
||||||
|
return "Submitted by " + message.getSenderName() + ":\n\n" + message.getText();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,126 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk.db;
|
||||||
|
|
||||||
|
import io.gitlab.jfronny.commons.throwable.ThrowingConsumer;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.Config;
|
||||||
|
import org.apache.commons.dbcp2.BasicDataSource;
|
||||||
|
|
||||||
|
import java.sql.*;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public class DBInterface implements AutoCloseable {
|
||||||
|
private final BasicDataSource ds = new BasicDataSource();
|
||||||
|
|
||||||
|
public DBInterface(Config.Database config) throws SQLException {
|
||||||
|
ds.setUrl("jdbc:" + config.connectionString);
|
||||||
|
ds.setMinIdle(1);
|
||||||
|
ds.setMaxIdle(10);
|
||||||
|
ds.setMaxOpenPreparedStatements(100);
|
||||||
|
try (Statement st = ds.getConnection().createStatement()) {
|
||||||
|
st.execute("""
|
||||||
|
CREATE OR REPLACE FUNCTION generate_uid(size INT) RETURNS TEXT AS $$
|
||||||
|
DECLARE
|
||||||
|
characters TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
bytes BYTEA := gen_random_bytes(size);
|
||||||
|
l INT := length(characters);
|
||||||
|
i INT := 0;
|
||||||
|
output TEXT := '';
|
||||||
|
BEGIN
|
||||||
|
WHILE i < size LOOP
|
||||||
|
output := output || substr(characters, get_byte(bytes, i) % l + 1, 1);
|
||||||
|
i := i + 1;
|
||||||
|
END LOOP;
|
||||||
|
RETURN output;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql VOLATILE;""");
|
||||||
|
st.execute("""
|
||||||
|
create table if not exists subscriptions(
|
||||||
|
id text primary key default generate_uid(20),
|
||||||
|
email text,
|
||||||
|
repo_owner text,
|
||||||
|
repo text,
|
||||||
|
issue bigint,
|
||||||
|
issue_comment bigint,
|
||||||
|
reference_chain text
|
||||||
|
)""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public <TEx extends Throwable> void forEachSubscription(ThrowingConsumer<Subscription, TEx> action) throws SQLException, TEx {
|
||||||
|
try (Statement st = ds.getConnection().createStatement();
|
||||||
|
ResultSet rs = st.executeQuery("select * from subscriptions")) {
|
||||||
|
while (rs.next()) {
|
||||||
|
action.accept(get(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String addSubscription(String email, String repoOwner, String repo, long issue, long issueComment, String referenceChain) throws SQLException {
|
||||||
|
try (PreparedStatement st = ds.getConnection().prepareStatement("""
|
||||||
|
insert into subscriptions (email, repo_owner, repo, issue, issue_comment, reference_chain)
|
||||||
|
values (?, ?, ?, ?, ?, ?)
|
||||||
|
returning id""")) {
|
||||||
|
st.setString(1, email);
|
||||||
|
st.setString(2, repoOwner);
|
||||||
|
st.setString(3, repo);
|
||||||
|
st.setLong(4, issue);
|
||||||
|
st.setLong(5, issueComment);
|
||||||
|
st.setString(6, referenceChain);
|
||||||
|
try (ResultSet rs = st.executeQuery()) {
|
||||||
|
rs.next();
|
||||||
|
return rs.getString("id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Subscription> getSubscription(String id) throws SQLException {
|
||||||
|
try (PreparedStatement st = ds.getConnection()
|
||||||
|
.prepareStatement("select id, email, repo_owner, repo, issue, issue_comment, reference_chain from subscriptions where id = ?")) {
|
||||||
|
st.setString(1, id);
|
||||||
|
try (ResultSet rs = st.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return Optional.of(get(rs));
|
||||||
|
} else return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeSubscription(String id) throws SQLException {
|
||||||
|
try (PreparedStatement st = ds.getConnection().prepareStatement("delete from subscriptions where id = ?")) {
|
||||||
|
st.setString(1, id);
|
||||||
|
st.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateSubscriptionIssueComment(String id, long issueComment) throws SQLException {
|
||||||
|
try (PreparedStatement st = ds.getConnection().prepareStatement("update subscriptions set issue_comment = ? where id = ?")) {
|
||||||
|
st.setLong(1, issueComment);
|
||||||
|
st.setString(2, id);
|
||||||
|
st.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateSubscriptionReferenceChain(String id, String referenceChain) throws SQLException {
|
||||||
|
try (PreparedStatement st = ds.getConnection().prepareStatement("update subscriptions set reference_chain = ? where id = ?")) {
|
||||||
|
st.setString(1, referenceChain);
|
||||||
|
st.setString(2, id);
|
||||||
|
st.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Subscription get(ResultSet rs) throws SQLException {
|
||||||
|
return new Subscription(
|
||||||
|
rs.getString("id"),
|
||||||
|
rs.getString("email"),
|
||||||
|
rs.getString("repo_owner"),
|
||||||
|
rs.getString("repo"),
|
||||||
|
rs.getLong("issue"),
|
||||||
|
rs.getLong("issue_comment"),
|
||||||
|
rs.getString("reference_chain")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws SQLException {
|
||||||
|
ds.close();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk.db;
|
||||||
|
|
||||||
|
public record Subscription(String id, String email, String repoOwner, String repo, long issue, long issueComment, String referenceChain) {
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk.db;
|
||||||
|
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
public class UncheckedSQLException extends RuntimeException {
|
||||||
|
private final SQLException exception;
|
||||||
|
|
||||||
|
public UncheckedSQLException(SQLException exception) {
|
||||||
|
this.exception = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SQLException getException() {
|
||||||
|
return exception;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk.gitea;
|
||||||
|
|
||||||
|
public class CreateIssueCommentOption {
|
||||||
|
public String body;
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk.gitea;
|
||||||
|
|
||||||
|
public class CreateIssueOption {
|
||||||
|
public String body;
|
||||||
|
public String title;
|
||||||
|
}
|
|
@ -0,0 +1,61 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk.gitea;
|
||||||
|
|
||||||
|
import io.gitlab.jfronny.commons.HttpUtils;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.Config;
|
||||||
|
import io.gitlab.jfronny.gson.reflect.TypeToken;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class GiteaInterface {
|
||||||
|
private static final Type issueListType = new TypeToken<List<GiteaIssueComment>>() {}.getType();
|
||||||
|
private final Config.Gitea gitea;
|
||||||
|
|
||||||
|
public GiteaInterface(Config.Gitea gitea) {
|
||||||
|
this.gitea = gitea;
|
||||||
|
}
|
||||||
|
|
||||||
|
public GiteaIssue getIssue(String owner, String repo, long issue) throws URISyntaxException, IOException {
|
||||||
|
return HttpUtils.get(gitea.host + "/api/v1/repos/" + owner + "/" + repo + "/issues/" + issue)
|
||||||
|
.header("Authorization", "token " + gitea.token)
|
||||||
|
.sendSerialized(GiteaIssue.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void closeIssue(String owner, String repo, long issue) throws URISyntaxException, IOException {
|
||||||
|
HttpUtils.patch(gitea.host + "/api/v1/repos/" + owner + "/" + repo + "/issues/" + issue)
|
||||||
|
.bodyJson("{\"state\":\"closed\"}")
|
||||||
|
.header("Authorization", "token " + gitea.token)
|
||||||
|
.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
public GiteaIssue createIssue(String owner, String repo, String title, String body) throws URISyntaxException, IOException {
|
||||||
|
CreateIssueOption opt = new CreateIssueOption();
|
||||||
|
opt.title = title;
|
||||||
|
opt.body = body;
|
||||||
|
return HttpUtils.post(gitea.host + "/api/v1/repos/" + owner + "/" + repo + "/issues")
|
||||||
|
.bodySerialized(opt)
|
||||||
|
.header("Authorization", "token " + gitea.token)
|
||||||
|
.sendSerialized(GiteaIssue.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public GiteaIssueComment addComment(String owner, String repo, long issue, String body) throws URISyntaxException, IOException {
|
||||||
|
CreateIssueCommentOption opt = new CreateIssueCommentOption();
|
||||||
|
opt.body = body;
|
||||||
|
return HttpUtils.post(gitea.host + "/api/v1/repos/" + owner + "/" + repo + "/issues/" + issue + "/comments")
|
||||||
|
.bodySerialized(opt)
|
||||||
|
.header("Authorization", "token " + gitea.token)
|
||||||
|
.sendSerialized(GiteaIssueComment.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<GiteaIssueComment> getComments(String owner, String repo, long issue) throws URISyntaxException, IOException {
|
||||||
|
return HttpUtils.get(gitea.host + "/api/v1/repos/" + owner + "/" + repo + "/issues/" + issue + "/comments")
|
||||||
|
.header("Authorization", "token " + gitea.token)
|
||||||
|
.sendSerialized(issueListType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAddress() {
|
||||||
|
return gitea.host;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk.gitea;
|
||||||
|
|
||||||
|
public class GiteaIssue {
|
||||||
|
public Long id;
|
||||||
|
public String url;
|
||||||
|
public String title;
|
||||||
|
public String body;
|
||||||
|
public String state;
|
||||||
|
|
||||||
|
public static class Repository {
|
||||||
|
public String full_name;
|
||||||
|
public Long id;
|
||||||
|
public String name;
|
||||||
|
public String owner;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk.gitea;
|
||||||
|
|
||||||
|
public class GiteaIssueComment {
|
||||||
|
public String body;
|
||||||
|
public Long id;
|
||||||
|
public String issue_url;
|
||||||
|
public String pull_request_url;
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk.mail;
|
||||||
|
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
public record Attachment(String fileName, InputStreamGenerator content) {
|
||||||
|
public interface InputStreamGenerator {
|
||||||
|
InputStream getInputStream() throws IOException, MessagingException;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,112 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk.mail;
|
||||||
|
|
||||||
|
import com.sun.mail.pop3.POP3Store;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.Config;
|
||||||
|
import jakarta.mail.*;
|
||||||
|
import jakarta.mail.internet.*;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
public class MailInterface implements AutoCloseable {
|
||||||
|
private final Config.EMail config;
|
||||||
|
private final Session pop3;
|
||||||
|
private final Session smtp;
|
||||||
|
private POP3Store lastPop3;
|
||||||
|
|
||||||
|
public MailInterface(Config.EMail config) {
|
||||||
|
this.config = config;
|
||||||
|
{
|
||||||
|
Properties properties = new Properties();
|
||||||
|
String[] splitHost = config.pop3.host.split(":");
|
||||||
|
if (splitHost.length == 1) {
|
||||||
|
properties.put("mail.pop3.host", config.pop3.host);
|
||||||
|
} else {
|
||||||
|
properties.put("mail.pop3.host", splitHost[0]);
|
||||||
|
properties.put("mail.pop3.port", Integer.parseInt(splitHost[1]));
|
||||||
|
}
|
||||||
|
if (config.pop3.ssl) properties.put("mail.pop3.ssl.enable", true);
|
||||||
|
this.pop3 = Session.getInstance(properties);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Properties properties = new Properties();
|
||||||
|
String[] splitHost = config.smtp.host.split(":");
|
||||||
|
if (splitHost.length == 1) {
|
||||||
|
properties.put("mail.smtp.host", config.smtp.host);
|
||||||
|
} else {
|
||||||
|
properties.put("mail.smtp.host", splitHost[0]);
|
||||||
|
properties.put("mail.smtp.port", Integer.parseInt(splitHost[1]));
|
||||||
|
}
|
||||||
|
if (config.smtp.ssl) properties.put("mail.smtp.ssl.enable", true);
|
||||||
|
properties.put("mail.smtp.auth", true);
|
||||||
|
this.smtp = Session.getInstance(properties);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private POP3Store getStore() throws MessagingException {
|
||||||
|
boolean reopen = lastPop3 == null;
|
||||||
|
if (lastPop3 != null && !lastPop3.isConnected()) {
|
||||||
|
lastPop3.close();
|
||||||
|
reopen = true;
|
||||||
|
}
|
||||||
|
if (reopen) {
|
||||||
|
lastPop3 = (POP3Store) pop3.getStore("pop3");
|
||||||
|
lastPop3.connect(config.address, config.pop3.password);
|
||||||
|
}
|
||||||
|
return lastPop3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<WrappedMessage> getInbox() throws MessagingException {
|
||||||
|
try (Folder inbox = getStore().getFolder("INBOX")) {
|
||||||
|
inbox.open(Folder.READ_ONLY);
|
||||||
|
return Arrays.stream(inbox.getMessages())
|
||||||
|
.map(s -> new WrappedMessage((MimeMessage) s, config.address, this))
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reply(String from, String to, String content, String subject, String previousMessageId, String previousReferences, String previousInReplyTo) throws MessagingException {
|
||||||
|
MimeMessage reply = new MimeMessage(smtp);
|
||||||
|
reply.setFrom(from);
|
||||||
|
reply.addRecipients(Message.RecipientType.TO, to);
|
||||||
|
reply.setSubject(subject.startsWith("Re: ") ? subject : "Re: " + subject);
|
||||||
|
|
||||||
|
final MimeBodyPart textPart = new MimeBodyPart();
|
||||||
|
textPart.setContent(Jsoup.parse(content).text(), "text/plain");
|
||||||
|
final MimeBodyPart htmlPart = new MimeBodyPart();
|
||||||
|
htmlPart.setContent(content, "text/html");
|
||||||
|
final MimeMultipart mp = new MimeMultipart("alternative");
|
||||||
|
mp.addBodyPart(textPart);
|
||||||
|
mp.addBodyPart(htmlPart);
|
||||||
|
reply.setContent(mp);
|
||||||
|
|
||||||
|
if (previousMessageId != null) reply.setHeader("In-Reply-To", previousMessageId);
|
||||||
|
|
||||||
|
if (previousReferences == null) previousReferences = previousInReplyTo;
|
||||||
|
if (previousMessageId != null) {
|
||||||
|
if (previousReferences != null) previousReferences = MimeUtility.unfold(previousReferences) + " " + previousMessageId;
|
||||||
|
else previousReferences = previousMessageId;
|
||||||
|
}
|
||||||
|
if (previousReferences != null) reply.setHeader("References", MimeUtility.fold(12, previousReferences));
|
||||||
|
send(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void send(Message message) throws MessagingException {
|
||||||
|
Address[] recipients = message.getAllRecipients();
|
||||||
|
if (recipients.length != 1) throw new MessagingException("Unexpected number of recipients: " + recipients.length);
|
||||||
|
try (Transport transport = smtp.getTransport(recipients[0])) {
|
||||||
|
transport.connect(config.address, config.smtp.password);
|
||||||
|
transport.sendMessage(message, recipients);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAddress() {
|
||||||
|
return config.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws MessagingException {
|
||||||
|
if (lastPop3 != null) lastPop3.close();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,108 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk.mail;
|
||||||
|
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.UnexpectedMailException;
|
||||||
|
import jakarta.mail.*;
|
||||||
|
import jakarta.mail.internet.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class WrappedMessage {
|
||||||
|
public final MimeMessage message;
|
||||||
|
private final String[] selfParts;
|
||||||
|
private final MailInterface mail;
|
||||||
|
|
||||||
|
public WrappedMessage(MimeMessage message, String selfAddress, MailInterface mail) {
|
||||||
|
this.message = message;
|
||||||
|
this.selfParts = selfAddress.split("@");
|
||||||
|
this.mail = mail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRecipientSubAddress() throws MessagingException, UnexpectedMailException {
|
||||||
|
for (Address recipient : message.getAllRecipients()) {
|
||||||
|
String[] parts = recipient.toString().split("@");
|
||||||
|
if (parts.length != 2) throw new UnexpectedMailException("Invalid recipient");
|
||||||
|
if (!parts[1].equals(selfParts[1])) continue;
|
||||||
|
if (!parts[0].startsWith(selfParts[0] + "+")) continue;
|
||||||
|
return parts[0].substring(selfParts[0].length() + 1);
|
||||||
|
}
|
||||||
|
throw new UnexpectedMailException("Lacking proper recipient");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getText() throws MessagingException, IOException {
|
||||||
|
return getText(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String getText(Part p) throws MessagingException, IOException {
|
||||||
|
if (p.isMimeType("text/*")) {
|
||||||
|
String s = (String)p.getContent();
|
||||||
|
// p.isMimeType("text/html") ? Jsoup.parse(s).text() : s
|
||||||
|
//TODO ensure this works
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
if (p.isMimeType("multipart/alternative")) {
|
||||||
|
// prefer html text over plain text
|
||||||
|
Multipart mp = (Multipart)p.getContent();
|
||||||
|
String text = null;
|
||||||
|
for (int i = 0; i < mp.getCount(); i++) {
|
||||||
|
Part bp = mp.getBodyPart(i);
|
||||||
|
if (bp.isMimeType("text/plain")) {
|
||||||
|
if (text == null) text = getText(bp);
|
||||||
|
} else if (bp.isMimeType("text/html")) {
|
||||||
|
String s = getText(bp);
|
||||||
|
if (s != null) return s;
|
||||||
|
} else return getText(bp);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
} else if (p.isMimeType("multipart/*")) {
|
||||||
|
Multipart mp = (Multipart)p.getContent();
|
||||||
|
for (int i = 0; i < mp.getCount(); i++) {
|
||||||
|
String s = getText(mp.getBodyPart(i));
|
||||||
|
if (s != null) return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<Attachment> getAttachments() throws MessagingException, IOException {
|
||||||
|
if (message.isMimeType("multipart/*")) {
|
||||||
|
Multipart mp = (Multipart) message.getContent();
|
||||||
|
Set<Attachment> attachments = new LinkedHashSet<>();
|
||||||
|
for (int i = 0; i < mp.getCount(); i++) {
|
||||||
|
MimeBodyPart part = (MimeBodyPart) mp.getBodyPart(i);
|
||||||
|
if (Part.ATTACHMENT.equalsIgnoreCase(part.getDisposition())) {
|
||||||
|
attachments.add(new Attachment(part.getFileName(), part::getInputStream));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Set.copyOf(attachments);
|
||||||
|
} else return Set.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSubject() throws MessagingException {
|
||||||
|
return message.getSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSender() throws MessagingException {
|
||||||
|
return message.getFrom()[0].toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSenderName() throws MessagingException {
|
||||||
|
if (message.getFrom()[0] instanceof InternetAddress addr) {
|
||||||
|
return addr.getPersonal();
|
||||||
|
}
|
||||||
|
return "Sender Suppressed";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reply(String from, String content) throws MessagingException {
|
||||||
|
mail.reply(
|
||||||
|
from,
|
||||||
|
getSender(),
|
||||||
|
content,
|
||||||
|
getSubject(),
|
||||||
|
message.getHeader("Message-Id", null),
|
||||||
|
message.getHeader("References", " "),
|
||||||
|
message.getHeader("In-Reply-To", "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,127 @@
|
||||||
|
package io.gitlab.jfronny.gitea.helpdesk.web;
|
||||||
|
|
||||||
|
import io.gitlab.jfronny.commons.StringFormatter;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.Config;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.Main;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.db.DBInterface;
|
||||||
|
import io.gitlab.jfronny.gitea.helpdesk.gitea.GiteaInterface;
|
||||||
|
import net.freeutils.httpserver.HTTPServer;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.UncheckedIOException;
|
||||||
|
import java.net.URISyntaxException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class WebInterface implements AutoCloseable {
|
||||||
|
public static final String THEME = Main.getResource("web/theme.css");
|
||||||
|
private static final String TEMPLATE = Main.getResource("web/template.html");
|
||||||
|
private static final String ISSUE_MISSING_REPO = page("issue/missing_repo.html");
|
||||||
|
private static final String ISSUE_SUCCESS = page("issue/success.html");
|
||||||
|
private static final String UNSUBSCRIBE_MISSING_ID = page("unsubscribe/missing_id.html");
|
||||||
|
private static final String UNSUBSCRIBE_FAILURE = page("unsubscribe/failure.html");
|
||||||
|
private static final String UNSUBSCRIBE_SUCCESS = page("unsubscribe/success.html");
|
||||||
|
private static final String CLOSE_MISSING_ID = page("close/missing_id.html");
|
||||||
|
private static final String CLOSE_FAILURE = page("close/failure.html");
|
||||||
|
private static final String CLOSE_SUCCESS = page("close/success.html");
|
||||||
|
private static String page(String path) {
|
||||||
|
return TEMPLATE.formatted(Main.getResource("web/" + path));
|
||||||
|
}
|
||||||
|
|
||||||
|
private final HTTPServer server;
|
||||||
|
private final Config.Web web;
|
||||||
|
|
||||||
|
public WebInterface(Config.Web web, String address, DBInterface db, GiteaInterface gitea) throws IOException {
|
||||||
|
server = new HTTPServer(web.port);
|
||||||
|
this.web = web;
|
||||||
|
HTTPServer.VirtualHost host = server.getVirtualHost(null);
|
||||||
|
host.addContext("/unsubscribe", (req, resp) -> {
|
||||||
|
String id = req.getParams().get("id");
|
||||||
|
if (id == null) {
|
||||||
|
resp.send(404, UNSUBSCRIBE_MISSING_ID);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
db.removeSubscription(id);
|
||||||
|
resp.send(202, UNSUBSCRIBE_SUCCESS);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
resp.send(500, UNSUBSCRIBE_FAILURE.formatted(escapeHTML(StringFormatter.toString(e))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
host.addContext("/close", ((req, resp) -> {
|
||||||
|
String id = req.getParams().get("id");
|
||||||
|
if (id == null) {
|
||||||
|
resp.send(404, CLOSE_MISSING_ID);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
db.getSubscription(id).ifPresentOrElse(sub -> {
|
||||||
|
try {
|
||||||
|
gitea.closeIssue(sub.repoOwner(), sub.repo(), sub.issue());
|
||||||
|
resp.send(202, CLOSE_SUCCESS);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
} catch (URISyntaxException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}, () -> {
|
||||||
|
try {
|
||||||
|
resp.send(404, CLOSE_MISSING_ID);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new UncheckedIOException(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Throwable e) {
|
||||||
|
resp.send(500, CLOSE_FAILURE.formatted(escapeHTML(StringFormatter.toString(e))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}));
|
||||||
|
String[] addr = address.split("@");
|
||||||
|
host.addContext("/issue", (req, resp) -> {
|
||||||
|
Map<String, String> params = req.getParams();
|
||||||
|
String owner = params.get("owner");
|
||||||
|
String repo = params.get("repo");
|
||||||
|
if (owner == null || repo == null || owner.contains("+") || repo.contains("+")) {
|
||||||
|
resp.send(404, ISSUE_MISSING_REPO);
|
||||||
|
} else {
|
||||||
|
String mail = "mailto:create+" + addr[0] + '+' + owner + '+' + repo;
|
||||||
|
resp.send(200, ISSUE_SUCCESS.formatted(escapeHTML(mail)));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
host.addContext("/theme.css", (req, resp) -> {
|
||||||
|
byte[] content = THEME.getBytes(StandardCharsets.UTF_8);
|
||||||
|
resp.sendHeaders(200, content.length, -1, "W/\"" + Integer.toHexString(THEME.hashCode()) + "\"", "text/css; charset=utf-8", null);
|
||||||
|
resp.getBody().write(content);
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
server.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
server.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String escapeHTML(String s) {
|
||||||
|
StringBuilder out = new StringBuilder();
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
if (c > 127 || c == '"' || c == '\'' || c == '<' || c == '>' || c == '&') {
|
||||||
|
out.append("&#");
|
||||||
|
out.append((int) c);
|
||||||
|
out.append(';');
|
||||||
|
} else if (c == '\n') {
|
||||||
|
out.append("<br>\n");
|
||||||
|
} else {
|
||||||
|
out.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAddress() {
|
||||||
|
return web.publicAddress;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
<h1>Successfully added comment</h1>
|
||||||
|
<p>You can find your new comment <a href="%s">here</a></p>
|
||||||
|
<p>If you wish to unsubscribe from new comments, you may do so <a href="%s">here</a></p>
|
||||||
|
<p>Please be aware that you will not be sent these a second time!</p>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<h1>Successfully added comment</h1>
|
||||||
|
<p>You can find your new comment <a href="%s">here</a></p>
|
||||||
|
<p>No subscription for new comments was registered since this issue is closed</p>
|
|
@ -0,0 +1,4 @@
|
||||||
|
<h1>Successfully added issue</h1>
|
||||||
|
<p>You can find your new issue <a href="%s">here</a></p>
|
||||||
|
<p>If you wish to unsubscribe from new comments, you may do so <a href="%s">here</a></p>
|
||||||
|
<p>Please be aware that you will not be sent these a second time!</p>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<h1>Could not parse mail</h1>
|
||||||
|
<p>There was an error while trying to parse your E-Mail. Please read the details below and report this issue</p>
|
||||||
|
<p>%s</p>
|
|
@ -0,0 +1,2 @@
|
||||||
|
<h1>This issue was closed</h1>
|
||||||
|
<p>This issue was closed, your subscription was automatically revoked</p>
|
|
@ -0,0 +1,2 @@
|
||||||
|
<h1>This issue was moved or deleted</h1>
|
||||||
|
<p>This issue was moved or deleted and is no longer accessible</p>
|
|
@ -0,0 +1,8 @@
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>%s</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
%s
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,3 @@
|
||||||
|
<h1>Unexpected</h1>
|
||||||
|
<p>It seems the E-Mail you sent was not expected. Please read the details below</p>
|
||||||
|
<p>%s</p>
|
|
@ -0,0 +1,2 @@
|
||||||
|
<h1>Could not close this issue, please report this issue!</h1>
|
||||||
|
<p>%s</p>
|
|
@ -0,0 +1 @@
|
||||||
|
<h1>URL lacks id, cannot close</h1>
|
|
@ -0,0 +1 @@
|
||||||
|
<h1>Successfully closed issue!</h1>
|
|
@ -0,0 +1,2 @@
|
||||||
|
<h1>Invalid issue</h1>
|
||||||
|
<p>Lacking either a proper owner or repo (plusses are not supported in either)</p>
|
|
@ -0,0 +1,7 @@
|
||||||
|
<h1>Create issue</h1>
|
||||||
|
<p>By clicking on the following link, you agree that the name you use to send your E-Mail and its content will be displayed in the issue created by mailing.</p>
|
||||||
|
<p>You will receive a timely response containing a link to the created issue.</p>
|
||||||
|
<p>Additionally, you will be informed if any comments are posted on the issue and may add your own by replying to them</p>
|
||||||
|
<p>If you wish to unsubscribe, that option will be present in the original reply you receive, but will not be repeated</p>
|
||||||
|
<p>Please note that only plain text messages without attachments are currently supported due to API limitations</p>
|
||||||
|
<h2><a href="%s">Create Issue</a></h2>
|
|
@ -0,0 +1,11 @@
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Gitea Helpdesk</title>
|
||||||
|
<link rel="stylesheet" href="theme.css" type="text/css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
%s
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,2 @@
|
||||||
|
@import url("https://fonts.googleapis.com/css?family=Nunito");html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,textarea,input,select,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{font-family:"Nunito",sans-serif;color:#000}body,html{padding:0;margin:0;overflow-x:hidden;background-color:#fff}nav{font-family:"Nunito",sans-serif;background-color:#212121;color:#fff;display:-webkit-box;display:-ms-flexbox;display:flex}nav header{padding:8px;display:inline}nav header a{text-decoration:none;color:#fff}nav header a:hover{color:#00c853}nav header+input:checked+div{display:block}nav div{display:none;margin-left:auto}nav div ul{list-style:none;display:-webkit-box;display:-ms-flexbox;display:flex;margin:0 10px}nav div ul li a{display:inline-block;padding:8px;color:#fff;text-decoration:none}nav div ul li:hover{background-color:#00c853}nav div ul li ul{right:0;position:relative;background:pink}@media (max-width: 630px){nav{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}nav header{text-align:center}nav div{margin:auto}nav div ul{padding:0}nav div ul li{border-top-left-radius:5px;border-top-right-radius:5px}nav div ul li[active],nav div ul li.active{border:1px solid #00c853;border-bottom:none}blockquote{border-left:4px solid #00c853;padding:8px 5px;margin:0}blockquote p{font-size:0.4rem}}@media (min-width: 630px){nav div ul li.active,nav div ul li[active]{border:1px solid #00c853;border-bottom:none;border-top:none}nav header label{display:none}}section[container]{max-width:38em;margin:auto;padding:5px}h1{font-size:2.35em}h2{font-size:2em}h3{font-size:1.75em}h4{font-size:1.5em}h5{font-size:1.25em}h6{font-size:1em}a{color:#00c853}a:hover{color:#212121}mark{background-color:#00c853}code{font-family:monospace;background-color:#bdbdbd;padding-left:5px;padding-right:5px}blockquote{border-left:4px solid #00c853;padding:8px 10px;width:100%}blockquote p{font-style:italic;font-size:1.1rem}blockquote footer::before{content:"\2014 \00A0"}blockquote footer cite{font-style:italic;color:#bdbdbd}pre{background:#eee;overflow-x:auto;text-align:left;padding:5px}pre code{display:block;padding:0 10px;background:transparent}table{display:table;padding:5px;border-collapse:collapse}table thead,table tbody{text-align:left}table tr th,table tr td{padding:5px 10px;border-bottom:1px solid #00c853}div[overflow]{overflow-x:auto;max-width:100vw}div[overflow] ::-webkit-scrollbar{height:0}img{max-width:100%;border-radius:5px}form div{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;margin:10px 5px}form div p{margin:0px}form input,form select{font-size:1rem;padding:5px;border:1px solid #bdbdbd;color:#212121}form input:active,form input:focus,form select:active,form select:focus{outline-color:#00c853}form input[type="submit"]{padding:10px;background-color:#00c853;color:#000;border-radius:5px;border:none;cursor:pointer}form input[type="submit"]:active,form input[type="submit"]:focus{outline:none}form input[type="submit"]:active{background-color:#212121;color:#00c853}form input[type="submit"]:disabled{background:#bdbdbd;cursor:not-allowed}form input[type="submit"][secondary]{background-color:#212121;color:#00c853}form input[type="submit"][secondary]:active{background-color:#00c853;color:initial}textarea{color:#212121;width:-webkit-fill-available;font-size:1rem;padding:5px}textarea:active,textarea:focus{outline-color:#00c853}button{padding:10px;background-color:#00c853;color:#000;border-radius:5px;border:none;cursor:pointer}button:active,button:focus{outline:none}button:active{background-color:#212121;color:#00c853}button:disabled{background:#bdbdbd;cursor:not-allowed}button[secondary]{background-color:#212121;color:#00c853}button[secondary]:active{background-color:#00c853;color:initial}body>footer{background-color:#212121;position:relative;bottom:0;width:100%;padding:5px;color:#fff}
|
||||||
|
/*# sourceMappingURL=brightlight-green.css.map */
|
|
@ -0,0 +1,2 @@
|
||||||
|
<h1>Could not unsubscribe, please report this issue!</h1>
|
||||||
|
<p>%s</p>
|
|
@ -0,0 +1 @@
|
||||||
|
<h1>URL lacks id, cannot unsubscribe</h1>
|
|
@ -0,0 +1 @@
|
||||||
|
<h1>Successfully removed!</h1>
|
Loading…
Reference in New Issue