Initial fixes
This commit is contained in:
parent
146483b3c4
commit
694ddece28
34
README.md
Normal file
34
README.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Gitea-Helpdesk
|
||||||
|
Gitea-Helpdesk is an extension to Gitea that allows users to create issues through emails, comment on existing ones and receiving notifications for new comments.
|
||||||
|
To do so, it must be connected to a postgresql database, a gitea instance and a mail server (pop3+smtp).
|
||||||
|
The mail server MUST support sub-addressing with `+` and the repository name and gitea username must match `[a-zA-Z0-9-_]+`.
|
||||||
|
To set up the service, build a jar and add a config file as follows to the current directory:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"host": "https://gitea.example.com", // Your gitea instance
|
||||||
|
"token": "0123456789abcdef0123456789abcdef12345678" // An access token
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"address": "helpdesk@example.com", // The e-mail address of the help desk
|
||||||
|
"waitTime": 10, // The amount of seconds to wait between each iteration
|
||||||
|
"smtp": {
|
||||||
|
"host": "smtp.example.com:465", // The address of the SMTP server
|
||||||
|
"password": "1234", // The password for login (the username is the address)
|
||||||
|
"ssl": true // Whether to enable ssl encryption
|
||||||
|
},
|
||||||
|
"pop3": {
|
||||||
|
"host": "pop3.example.com:995", // The address of the POP3 server
|
||||||
|
"password": "1234", // The password for login (the username is the address)
|
||||||
|
"ssl": true // Whether to enable ssl encryption
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"database": {
|
||||||
|
"connectionString": "postgresql://localhost/helpdesk?user=helpdesk" // The postgresql connection string (see postgresql jdbc documentation, sockets are supported)
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"port": 80, // The port to host the website at
|
||||||
|
"publicAddress": "http://127.0.0.1:80" // Where users will access the website
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
@ -20,10 +20,12 @@ dependencies {
|
|||||||
implementation("com.sun.mail:jakarta.mail:2.0.1")
|
implementation("com.sun.mail:jakarta.mail:2.0.1")
|
||||||
implementation("org.postgresql:postgresql:42.5.0")
|
implementation("org.postgresql:postgresql:42.5.0")
|
||||||
implementation("org.apache.commons:commons-dbcp2:2.9.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:2022.10.22+20-29-33")
|
||||||
implementation("io.gitlab.jfronny:commons-gson:2022.10.22+18-33-39")
|
implementation("io.gitlab.jfronny:commons-gson:2022.10.22+20-29-33")
|
||||||
implementation("com.kohlschutter.junixsocket:junixsocket-core:2.6.0")
|
implementation("com.kohlschutter.junixsocket:junixsocket-core:2.6.0")
|
||||||
implementation("org.jsoup:jsoup:1.15.3")
|
implementation("org.jsoup:jsoup:1.15.3")
|
||||||
implementation("net.freeutils:jlhttp:2.6")
|
implementation("net.freeutils:jlhttp:2.6")
|
||||||
compileOnly("org.jetbrains:annotations:23.0.0")
|
compileOnly("org.jetbrains:annotations:23.0.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.run.get().jvmArgs("-Djava.util.logging.config.file=logging.properties")
|
@ -22,9 +22,9 @@ import java.util.stream.Collectors;
|
|||||||
// https://www.javatpoint.com/example-of-receiving-attachment-with-email-using-java-mail-api
|
// https://www.javatpoint.com/example-of-receiving-attachment-with-email-using-java-mail-api
|
||||||
public class Main {
|
public class Main {
|
||||||
public static final Logger LOG = Logger.forName("Gitea-Helpdesk");
|
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 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])+)])");
|
||||||
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 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 final Pattern PATH_SEGMENT_PATTERN = Pattern.compile("[a-zA-Z0-9-_]+");
|
||||||
|
|
||||||
public static void main(String[] args) throws IOException, SQLException, MessagingException, Config.IllegalConfigException, InterruptedException {
|
public static void main(String[] args) throws IOException, SQLException, MessagingException, Config.IllegalConfigException, InterruptedException {
|
||||||
GsonHolders.registerSerializer();
|
GsonHolders.registerSerializer();
|
||||||
@ -54,6 +54,9 @@ public class Main {
|
|||||||
return br.lines().collect(Collectors.joining("\n"));
|
return br.lines().collect(Collectors.joining("\n"));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
|
} catch (NullPointerException e) {
|
||||||
|
LOG.error("Could not get resource: " + path);
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,27 +5,25 @@ import io.gitlab.jfronny.commons.throwable.ThrowingBiConsumer;
|
|||||||
import io.gitlab.jfronny.gitea.helpdesk.db.DBInterface;
|
import io.gitlab.jfronny.gitea.helpdesk.db.DBInterface;
|
||||||
import io.gitlab.jfronny.gitea.helpdesk.db.Subscription;
|
import io.gitlab.jfronny.gitea.helpdesk.db.Subscription;
|
||||||
import io.gitlab.jfronny.gitea.helpdesk.gitea.*;
|
import io.gitlab.jfronny.gitea.helpdesk.gitea.*;
|
||||||
import io.gitlab.jfronny.gitea.helpdesk.mail.MailInterface;
|
import io.gitlab.jfronny.gitea.helpdesk.mail.*;
|
||||||
import io.gitlab.jfronny.gitea.helpdesk.mail.WrappedMessage;
|
|
||||||
import io.gitlab.jfronny.gitea.helpdesk.web.WebInterface;
|
import io.gitlab.jfronny.gitea.helpdesk.web.WebInterface;
|
||||||
import jakarta.mail.MessagingException;
|
import jakarta.mail.MessagingException;
|
||||||
|
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.sql.SQLException;
|
|
||||||
|
|
||||||
public record UpdateTask(DBInterface db, MailInterface mail, GiteaInterface gitea, WebInterface web) implements Runnable {
|
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 TEMPLATE = Main.getResource("/mail/template.html");
|
||||||
private static final String MAIL_ERROR = mail("error.html");
|
private static final String MAIL_ERROR = Main.getResource("/mail/error.html");
|
||||||
private static final String MAIL_UNEXPECTED = mail("unexpected.html");
|
private static final String MAIL_UNEXPECTED = Main.getResource("/mail/unexpected.html");
|
||||||
private static final String MAIL_CREATE = mail("create.html");
|
private static final String MAIL_CREATE = Main.getResource("/mail/create.html");
|
||||||
private static final String MAIL_COMMENT = mail("comment.html");
|
private static final String MAIL_COMMENT = Main.getResource("/mail/comment.html");
|
||||||
private static final String MAIL_COMMENT_CLOSED = mail("comment_closed.html");
|
private static final String MAIL_COMMENT_CLOSED = Main.getResource("/mail/comment_closed.html");
|
||||||
private static final String MAIL_ISSUE_DELETED = mail("issue_deleted.html");
|
private static final String MAIL_ISSUE_DELETED = Main.getResource("/mail/issue_deleted.html");
|
||||||
private static final String MAIL_ISSUE_CLOSED = mail("issue_closed.html");
|
private static final String MAIL_ISSUE_CLOSED = Main.getResource("/mail/issue_closed.html");
|
||||||
|
|
||||||
private static String mail(String path) {
|
private static String template(String body) {
|
||||||
return TEMPLATE.formatted(Main.getResource("mail/" + path));
|
return TEMPLATE.formatted(WebInterface.THEME, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -33,7 +31,7 @@ public record UpdateTask(DBInterface db, MailInterface mail, GiteaInterface gite
|
|||||||
try {
|
try {
|
||||||
updateSubscriptions();
|
updateSubscriptions();
|
||||||
processEmails();
|
processEmails();
|
||||||
} catch (Exception e) {
|
} catch (Throwable e) {
|
||||||
Main.LOG.error("Could not run update task", e);
|
Main.LOG.error("Could not run update task", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -51,12 +49,12 @@ public record UpdateTask(DBInterface db, MailInterface mail, GiteaInterface gite
|
|||||||
try {
|
try {
|
||||||
issue = gitea.getIssue(subscription.repoOwner(), subscription.repo(), subscription.issue());
|
issue = gitea.getIssue(subscription.repoOwner(), subscription.repo(), subscription.issue());
|
||||||
} catch (FileNotFoundException fe) {
|
} catch (FileNotFoundException fe) {
|
||||||
reply.accept(MAIL_ISSUE_DELETED, "Issue deleted");
|
reply.accept(template(MAIL_ISSUE_DELETED), "Issue deleted");
|
||||||
db.removeSubscription(subscription.id());
|
db.removeSubscription(subscription.id());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (issue.state.equals("closed")) { //TODO test
|
if (issue.state.equals("closed")) { //TODO test
|
||||||
reply.accept(MAIL_ISSUE_CLOSED, "Issue closed");
|
reply.accept(template(MAIL_ISSUE_CLOSED), "Issue closed");
|
||||||
db.removeSubscription(subscription.id());
|
db.removeSubscription(subscription.id());
|
||||||
}
|
}
|
||||||
for (GiteaIssueComment comment : gitea.getComments(subscription.repoOwner(), subscription.repo(), subscription.issue())) {
|
for (GiteaIssueComment comment : gitea.getComments(subscription.repoOwner(), subscription.repo(), subscription.issue())) {
|
||||||
@ -68,9 +66,10 @@ public record UpdateTask(DBInterface db, MailInterface mail, GiteaInterface gite
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void processEmails() throws SQLException, MessagingException, IOException {
|
private void processEmails() throws MessagingException {
|
||||||
String[] addressParts = mail.getAddress().split("@");
|
String[] addressParts = mail.getAddress().split("@");
|
||||||
for (WrappedMessage message : mail.getInbox()) {
|
try (WrappedMessageSet wms = mail.getInbox()) {
|
||||||
|
for (WrappedMessage message : wms) {
|
||||||
try {
|
try {
|
||||||
String[] args = message.getRecipientSubAddress().split("\\+");
|
String[] args = message.getRecipientSubAddress().split("\\+");
|
||||||
switch (args[0]) {
|
switch (args[0]) {
|
||||||
@ -80,9 +79,9 @@ public record UpdateTask(DBInterface db, MailInterface mail, GiteaInterface gite
|
|||||||
String repo = args[2];
|
String repo = args[2];
|
||||||
checkArgs(owner, repo);
|
checkArgs(owner, repo);
|
||||||
GiteaIssue issue = gitea.createIssue(owner, repo, message.getSubject(), formatBody(message));
|
GiteaIssue issue = gitea.createIssue(owner, repo, message.getSubject(), formatBody(message));
|
||||||
String id = db.addSubscription(message.getSender(), owner, repo, issue.id, 0, message.getSender());
|
String id = db.addSubscription(message.getSender(), owner, repo, issue.id, 0, message.getSender(), true);
|
||||||
String unsubscribeUrl = web.getAddress() + "/unsubscribe?id=" + id; //TODO test
|
String unsubscribeUrl = web.getAddress() + "/unsubscribe?id=" + id; //TODO test
|
||||||
message.reply(addressParts[0] + "+reply+" + owner + "+" + repo + "+" + issue.id, MAIL_CREATE.formatted(issue.url, unsubscribeUrl));
|
message.reply(addressParts[0] + "+reply+" + owner + "+" + repo + "+" + issue.id + '@' + addressParts[1], template(MAIL_CREATE.formatted(issue.url, unsubscribeUrl)));
|
||||||
}
|
}
|
||||||
case "reply" -> {
|
case "reply" -> {
|
||||||
if (args.length != 2) throw new UnexpectedMailException("Reply classifier only allows one parameter");
|
if (args.length != 2) throw new UnexpectedMailException("Reply classifier only allows one parameter");
|
||||||
@ -105,28 +104,30 @@ public record UpdateTask(DBInterface db, MailInterface mail, GiteaInterface gite
|
|||||||
}
|
}
|
||||||
gitea.addComment(owner, repo, issueId, formatBody(message));
|
gitea.addComment(owner, repo, issueId, formatBody(message));
|
||||||
if (issue.state.equals("closed")) {
|
if (issue.state.equals("closed")) {
|
||||||
message.reply(addressParts[0] + "+reply+" + owner + "+" + repo + "+" + issue.id, MAIL_COMMENT_CLOSED.formatted(issue.url));
|
message.reply(addressParts[0] + "+reply+" + owner + "+" + repo + "+" + issue.id + '@' + addressParts[1], template(MAIL_COMMENT_CLOSED.formatted(issue.url)));
|
||||||
} else {
|
} else {
|
||||||
String id = db.addSubscription(message.getSender(), owner, repo, issue.id, 0, message.getSender());
|
String id = db.addSubscription(message.getSender(), owner, repo, issue.id, 0, message.getSender(), false);
|
||||||
String unsubscribeUrl = web.getAddress() + "/unsubscribe?id=" + id; //TODO test
|
String unsubscribeUrl = web.getAddress() + "/unsubscribe?id=" + id; //TODO test
|
||||||
message.reply(addressParts[0] + "+reply+" + owner + "+" + repo + "+" + issue.id, MAIL_COMMENT.formatted(issue.url, unsubscribeUrl));
|
message.reply(addressParts[0] + "+reply+" + owner + "+" + repo + "+" + issue.id + '@' + addressParts[1], template(MAIL_COMMENT.formatted(issue.url, unsubscribeUrl)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default -> throw new UnexpectedMailException("Did not expect classifier " + args[0]);
|
default -> throw new UnexpectedMailException("Did not expect classifier " + args[0]);
|
||||||
}
|
}
|
||||||
} catch (UnexpectedMailException | NumberFormatException t) {
|
} catch (UnexpectedMailException | NumberFormatException t) {
|
||||||
message.reply(mail.getAddress(), MAIL_UNEXPECTED.formatted(WebInterface.escapeHTML(StringFormatter.toString(t))));
|
message.reply(mail.getAddress(), template(MAIL_UNEXPECTED.formatted(WebInterface.escapeHTML(StringFormatter.toString(t)))));
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
Main.LOG.error("Could not parse mail", t);
|
Main.LOG.error("Could not parse mail", t);
|
||||||
message.reply(mail.getAddress(), MAIL_ERROR.formatted(WebInterface.escapeHTML(StringFormatter.toString(t))));
|
message.reply(mail.getAddress(), template(MAIL_ERROR.formatted(WebInterface.escapeHTML(StringFormatter.toString(t)))));
|
||||||
|
} finally {
|
||||||
|
message.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkArgs(String owner, String repo) throws UnexpectedMailException {
|
private void checkArgs(String owner, String repo) throws UnexpectedMailException {
|
||||||
//TODO document this limitation
|
if (!Main.PATH_SEGMENT_PATTERN.matcher(owner).matches()) throw new UnexpectedMailException("Unsupported owner string");
|
||||||
if (!Main.ALPHANUMERIC_PATTERN.matcher(owner).matches()) throw new UnexpectedMailException("Unsupported owner string");
|
if (!Main.PATH_SEGMENT_PATTERN.matcher(repo).matches()) throw new UnexpectedMailException("Unsupported repo string");
|
||||||
if (!Main.ALPHANUMERIC_PATTERN.matcher(repo).matches()) throw new UnexpectedMailException("Unsupported repo string");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String formatBody(WrappedMessage message) throws UnexpectedMailException, MessagingException, IOException {
|
private String formatBody(WrappedMessage message) throws UnexpectedMailException, MessagingException, IOException {
|
||||||
|
@ -15,9 +15,14 @@ public class DBInterface implements AutoCloseable {
|
|||||||
ds.setMinIdle(1);
|
ds.setMinIdle(1);
|
||||||
ds.setMaxIdle(10);
|
ds.setMaxIdle(10);
|
||||||
ds.setMaxOpenPreparedStatements(100);
|
ds.setMaxOpenPreparedStatements(100);
|
||||||
try (Statement st = ds.getConnection().createStatement()) {
|
ds.setAutoCommitOnReturn(true);
|
||||||
st.execute("""
|
ds.setDefaultAutoCommit(true);
|
||||||
CREATE OR REPLACE FUNCTION generate_uid(size INT) RETURNS TEXT AS $$
|
ds.start();
|
||||||
|
try (Connection cx = ds.getConnection()) {
|
||||||
|
try (Statement st = cx.createStatement()) {
|
||||||
|
st.executeUpdate("""
|
||||||
|
CREATE EXTENSION pgcrypto;
|
||||||
|
CREATE OR REPLACE FUNCTION helpdesk_generate_uid(size INT) RETURNS TEXT AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
characters TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
characters TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
bytes BYTEA := gen_random_bytes(size);
|
bytes BYTEA := gen_random_bytes(size);
|
||||||
@ -31,33 +36,36 @@ public class DBInterface implements AutoCloseable {
|
|||||||
END LOOP;
|
END LOOP;
|
||||||
RETURN output;
|
RETURN output;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql VOLATILE;""");
|
$$ LANGUAGE plpgsql VOLATILE;
|
||||||
st.execute("""
|
create table if not exists helpdesk_subscriptions(
|
||||||
create table if not exists subscriptions(
|
id text primary key default helpdesk_generate_uid(20),
|
||||||
id text primary key default generate_uid(20),
|
|
||||||
email text,
|
email text,
|
||||||
repo_owner text,
|
repo_owner text,
|
||||||
repo text,
|
repo text,
|
||||||
issue bigint,
|
issue bigint,
|
||||||
issue_comment bigint,
|
issue_comment bigint,
|
||||||
reference_chain text
|
reference_chain text,
|
||||||
)""");
|
creator bool
|
||||||
|
);""");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public <TEx extends Throwable> void forEachSubscription(ThrowingConsumer<Subscription, TEx> action) throws SQLException, TEx {
|
public <TEx extends Throwable> void forEachSubscription(ThrowingConsumer<Subscription, TEx> action) throws SQLException, TEx {
|
||||||
try (Statement st = ds.getConnection().createStatement();
|
try (Connection cx = ds.getConnection();
|
||||||
ResultSet rs = st.executeQuery("select * from subscriptions")) {
|
Statement st = cx.createStatement();
|
||||||
|
ResultSet rs = st.executeQuery("select * from helpdesk_subscriptions")) {
|
||||||
while (rs.next()) {
|
while (rs.next()) {
|
||||||
action.accept(get(rs));
|
action.accept(get(rs));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String addSubscription(String email, String repoOwner, String repo, long issue, long issueComment, String referenceChain) throws SQLException {
|
public String addSubscription(String email, String repoOwner, String repo, long issue, long issueComment, String referenceChain, boolean creator) throws SQLException {
|
||||||
try (PreparedStatement st = ds.getConnection().prepareStatement("""
|
try (Connection cx = ds.getConnection();
|
||||||
insert into subscriptions (email, repo_owner, repo, issue, issue_comment, reference_chain)
|
PreparedStatement st = cx.prepareStatement("""
|
||||||
values (?, ?, ?, ?, ?, ?)
|
insert into helpdesk_subscriptions (email, repo_owner, repo, issue, issue_comment, reference_chain, creator)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?)
|
||||||
returning id""")) {
|
returning id""")) {
|
||||||
st.setString(1, email);
|
st.setString(1, email);
|
||||||
st.setString(2, repoOwner);
|
st.setString(2, repoOwner);
|
||||||
@ -65,6 +73,7 @@ public class DBInterface implements AutoCloseable {
|
|||||||
st.setLong(4, issue);
|
st.setLong(4, issue);
|
||||||
st.setLong(5, issueComment);
|
st.setLong(5, issueComment);
|
||||||
st.setString(6, referenceChain);
|
st.setString(6, referenceChain);
|
||||||
|
st.setBoolean(7, creator);
|
||||||
try (ResultSet rs = st.executeQuery()) {
|
try (ResultSet rs = st.executeQuery()) {
|
||||||
rs.next();
|
rs.next();
|
||||||
return rs.getString("id");
|
return rs.getString("id");
|
||||||
@ -73,8 +82,9 @@ public class DBInterface implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Optional<Subscription> getSubscription(String id) throws SQLException {
|
public Optional<Subscription> getSubscription(String id) throws SQLException {
|
||||||
try (PreparedStatement st = ds.getConnection()
|
try (Connection cx = ds.getConnection();
|
||||||
.prepareStatement("select id, email, repo_owner, repo, issue, issue_comment, reference_chain from subscriptions where id = ?")) {
|
PreparedStatement st = cx
|
||||||
|
.prepareStatement("select id, email, repo_owner, repo, issue, issue_comment, reference_chain, creator from helpdesk_subscriptions where id = ?")) {
|
||||||
st.setString(1, id);
|
st.setString(1, id);
|
||||||
try (ResultSet rs = st.executeQuery()) {
|
try (ResultSet rs = st.executeQuery()) {
|
||||||
if (rs.next()) {
|
if (rs.next()) {
|
||||||
@ -85,14 +95,16 @@ public class DBInterface implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void removeSubscription(String id) throws SQLException {
|
public void removeSubscription(String id) throws SQLException {
|
||||||
try (PreparedStatement st = ds.getConnection().prepareStatement("delete from subscriptions where id = ?")) {
|
try (Connection cx = ds.getConnection();
|
||||||
|
PreparedStatement st = cx.prepareStatement("delete from helpdesk_subscriptions where id = ?")) {
|
||||||
st.setString(1, id);
|
st.setString(1, id);
|
||||||
st.executeUpdate();
|
st.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void updateSubscriptionIssueComment(String id, long issueComment) throws SQLException {
|
public void updateSubscriptionIssueComment(String id, long issueComment) throws SQLException {
|
||||||
try (PreparedStatement st = ds.getConnection().prepareStatement("update subscriptions set issue_comment = ? where id = ?")) {
|
try (Connection cx = ds.getConnection();
|
||||||
|
PreparedStatement st = cx.prepareStatement("update helpdesk_subscriptions set issue_comment = ? where id = ?")) {
|
||||||
st.setLong(1, issueComment);
|
st.setLong(1, issueComment);
|
||||||
st.setString(2, id);
|
st.setString(2, id);
|
||||||
st.executeUpdate();
|
st.executeUpdate();
|
||||||
@ -100,7 +112,7 @@ public class DBInterface implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void updateSubscriptionReferenceChain(String id, String referenceChain) throws SQLException {
|
public void updateSubscriptionReferenceChain(String id, String referenceChain) throws SQLException {
|
||||||
try (PreparedStatement st = ds.getConnection().prepareStatement("update subscriptions set reference_chain = ? where id = ?")) {
|
try (Connection cx = ds.getConnection(); PreparedStatement st = cx.prepareStatement("update helpdesk_subscriptions set reference_chain = ? where id = ?")) {
|
||||||
st.setString(1, referenceChain);
|
st.setString(1, referenceChain);
|
||||||
st.setString(2, id);
|
st.setString(2, id);
|
||||||
st.executeUpdate();
|
st.executeUpdate();
|
||||||
@ -115,7 +127,8 @@ public class DBInterface implements AutoCloseable {
|
|||||||
rs.getString("repo"),
|
rs.getString("repo"),
|
||||||
rs.getLong("issue"),
|
rs.getLong("issue"),
|
||||||
rs.getLong("issue_comment"),
|
rs.getLong("issue_comment"),
|
||||||
rs.getString("reference_chain")
|
rs.getString("reference_chain"),
|
||||||
|
rs.getBoolean("creator")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
package io.gitlab.jfronny.gitea.helpdesk.db;
|
package io.gitlab.jfronny.gitea.helpdesk.db;
|
||||||
|
|
||||||
public record Subscription(String id, String email, String repoOwner, String repo, long issue, long issueComment, String referenceChain) {
|
public record Subscription(String id, String email, String repoOwner, String repo, long issue, long issueComment, String referenceChain, boolean creator) {
|
||||||
}
|
}
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -6,8 +6,7 @@ import jakarta.mail.*;
|
|||||||
import jakarta.mail.internet.*;
|
import jakarta.mail.internet.*;
|
||||||
import org.jsoup.Jsoup;
|
import org.jsoup.Jsoup;
|
||||||
|
|
||||||
import java.util.*;
|
import java.util.Properties;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
public class MailInterface implements AutoCloseable {
|
public class MailInterface implements AutoCloseable {
|
||||||
private final Config.EMail config;
|
private final Config.EMail config;
|
||||||
@ -57,13 +56,10 @@ public class MailInterface implements AutoCloseable {
|
|||||||
return lastPop3;
|
return lastPop3;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Set<WrappedMessage> getInbox() throws MessagingException {
|
public WrappedMessageSet getInbox() throws MessagingException {
|
||||||
try (Folder inbox = getStore().getFolder("INBOX")) {
|
Folder inbox = getStore().getFolder("INBOX");
|
||||||
inbox.open(Folder.READ_ONLY);
|
inbox.open(Folder.READ_WRITE);
|
||||||
return Arrays.stream(inbox.getMessages())
|
return new WrappedMessageSet(inbox, config.address, this);
|
||||||
.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 {
|
public void reply(String from, String to, String content, String subject, String previousMessageId, String previousReferences, String previousInReplyTo) throws MessagingException {
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
package io.gitlab.jfronny.gitea.helpdesk.mail;
|
||||||
|
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
|
||||||
|
public class UncheckedMessagingException extends RuntimeException {
|
||||||
|
private final MessagingException exception;
|
||||||
|
|
||||||
|
public UncheckedMessagingException(MessagingException exception) {
|
||||||
|
this.exception = exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MessagingException getException() {
|
||||||
|
return exception;
|
||||||
|
}
|
||||||
|
}
|
@ -36,10 +36,7 @@ public class WrappedMessage {
|
|||||||
|
|
||||||
static String getText(Part p) throws MessagingException, IOException {
|
static String getText(Part p) throws MessagingException, IOException {
|
||||||
if (p.isMimeType("text/*")) {
|
if (p.isMimeType("text/*")) {
|
||||||
String s = (String)p.getContent();
|
return (String)p.getContent();
|
||||||
// p.isMimeType("text/html") ? Jsoup.parse(s).text() : s
|
|
||||||
//TODO ensure this works
|
|
||||||
return s;
|
|
||||||
}
|
}
|
||||||
if (p.isMimeType("multipart/alternative")) {
|
if (p.isMimeType("multipart/alternative")) {
|
||||||
// prefer html text over plain text
|
// prefer html text over plain text
|
||||||
@ -105,4 +102,8 @@ public class WrappedMessage {
|
|||||||
message.getHeader("In-Reply-To", "")
|
message.getHeader("In-Reply-To", "")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void delete() throws MessagingException {
|
||||||
|
message.setFlag(Flags.Flag.DELETED, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
package io.gitlab.jfronny.gitea.helpdesk.mail;
|
||||||
|
|
||||||
|
import jakarta.mail.Folder;
|
||||||
|
import jakarta.mail.MessagingException;
|
||||||
|
import jakarta.mail.internet.MimeMessage;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Iterator;
|
||||||
|
|
||||||
|
public class WrappedMessageSet implements Iterable<WrappedMessage>, AutoCloseable {
|
||||||
|
private final Folder fld;
|
||||||
|
private final String address;
|
||||||
|
private final MailInterface mailInterface;
|
||||||
|
|
||||||
|
public WrappedMessageSet(Folder fld, String address, MailInterface mailInterface) {
|
||||||
|
this.fld = fld;
|
||||||
|
this.address = address;
|
||||||
|
this.mailInterface = mailInterface;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws MessagingException {
|
||||||
|
fld.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Override
|
||||||
|
public Iterator<WrappedMessage> iterator() {
|
||||||
|
try {
|
||||||
|
return Arrays.stream(fld.getMessages())
|
||||||
|
.map(s -> new WrappedMessage((MimeMessage) s, address, mailInterface))
|
||||||
|
.iterator();
|
||||||
|
} catch (MessagingException e) {
|
||||||
|
throw new UncheckedMessagingException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -14,18 +14,20 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public class WebInterface implements AutoCloseable {
|
public class WebInterface implements AutoCloseable {
|
||||||
public static final String THEME = Main.getResource("web/theme.css");
|
public static final String THEME = Main.getResource("/web/theme.css");
|
||||||
private static final String TEMPLATE = Main.getResource("web/template.html");
|
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_MISSING_REPO = Main.getResource("/web/issue/missing_repo.html");
|
||||||
private static final String ISSUE_SUCCESS = page("issue/success.html");
|
private static final String ISSUE_SUCCESS = Main.getResource("/web/issue/success.html");
|
||||||
private static final String UNSUBSCRIBE_MISSING_ID = page("unsubscribe/missing_id.html");
|
private static final String UNSUBSCRIBE_MISSING_ID = Main.getResource("/web/unsubscribe/missing_id.html");
|
||||||
private static final String UNSUBSCRIBE_FAILURE = page("unsubscribe/failure.html");
|
private static final String UNSUBSCRIBE_FAILURE = Main.getResource("/web/unsubscribe/failure.html");
|
||||||
private static final String UNSUBSCRIBE_SUCCESS = page("unsubscribe/success.html");
|
private static final String UNSUBSCRIBE_SUCCESS = Main.getResource("/web/unsubscribe/success.html");
|
||||||
private static final String CLOSE_MISSING_ID = page("close/missing_id.html");
|
private static final String CLOSE_MISSING_ID = Main.getResource("/web/close/missing_id.html");
|
||||||
private static final String CLOSE_FAILURE = page("close/failure.html");
|
private static final String CLOSE_FAILURE = Main.getResource("/web/close/failure.html");
|
||||||
private static final String CLOSE_SUCCESS = page("close/success.html");
|
private static final String CLOSE_NOT_CREATOR = Main.getResource("/web/close/not_creator.html");
|
||||||
private static String page(String path) {
|
private static final String CLOSE_SUCCESS = Main.getResource("/web/close/success.html");
|
||||||
return TEMPLATE.formatted(Main.getResource("web/" + path));
|
|
||||||
|
private String template(String body) {
|
||||||
|
return TEMPLATE.replace("<%template-content%>", body);
|
||||||
}
|
}
|
||||||
|
|
||||||
private final HTTPServer server;
|
private final HTTPServer server;
|
||||||
@ -38,13 +40,13 @@ public class WebInterface implements AutoCloseable {
|
|||||||
host.addContext("/unsubscribe", (req, resp) -> {
|
host.addContext("/unsubscribe", (req, resp) -> {
|
||||||
String id = req.getParams().get("id");
|
String id = req.getParams().get("id");
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
resp.send(404, UNSUBSCRIBE_MISSING_ID);
|
resp.send(404, template(UNSUBSCRIBE_MISSING_ID));
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
db.removeSubscription(id);
|
db.removeSubscription(id);
|
||||||
resp.send(202, UNSUBSCRIBE_SUCCESS);
|
resp.send(202, template(UNSUBSCRIBE_SUCCESS));
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
resp.send(500, UNSUBSCRIBE_FAILURE.formatted(escapeHTML(StringFormatter.toString(e))));
|
resp.send(500, template(UNSUBSCRIBE_FAILURE.formatted(escapeHTML(StringFormatter.toString(e)))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@ -52,13 +54,17 @@ public class WebInterface implements AutoCloseable {
|
|||||||
host.addContext("/close", ((req, resp) -> {
|
host.addContext("/close", ((req, resp) -> {
|
||||||
String id = req.getParams().get("id");
|
String id = req.getParams().get("id");
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
resp.send(404, CLOSE_MISSING_ID);
|
resp.send(404, template(CLOSE_MISSING_ID));
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
db.getSubscription(id).ifPresentOrElse(sub -> {
|
db.getSubscription(id).ifPresentOrElse(sub -> {
|
||||||
try {
|
try {
|
||||||
|
if (sub.creator()) {
|
||||||
gitea.closeIssue(sub.repoOwner(), sub.repo(), sub.issue());
|
gitea.closeIssue(sub.repoOwner(), sub.repo(), sub.issue());
|
||||||
resp.send(202, CLOSE_SUCCESS);
|
resp.send(202, template(CLOSE_SUCCESS));
|
||||||
|
} else {
|
||||||
|
resp.send(403, template(CLOSE_NOT_CREATOR));
|
||||||
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new UncheckedIOException(e);
|
throw new UncheckedIOException(e);
|
||||||
} catch (URISyntaxException e) {
|
} catch (URISyntaxException e) {
|
||||||
@ -66,13 +72,13 @@ public class WebInterface implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}, () -> {
|
}, () -> {
|
||||||
try {
|
try {
|
||||||
resp.send(404, CLOSE_MISSING_ID);
|
resp.send(404, template(CLOSE_MISSING_ID));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new UncheckedIOException(e);
|
throw new UncheckedIOException(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
resp.send(500, CLOSE_FAILURE.formatted(escapeHTML(StringFormatter.toString(e))));
|
resp.send(500, template(CLOSE_FAILURE.formatted(escapeHTML(StringFormatter.toString(e)))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@ -82,11 +88,11 @@ public class WebInterface implements AutoCloseable {
|
|||||||
Map<String, String> params = req.getParams();
|
Map<String, String> params = req.getParams();
|
||||||
String owner = params.get("owner");
|
String owner = params.get("owner");
|
||||||
String repo = params.get("repo");
|
String repo = params.get("repo");
|
||||||
if (owner == null || repo == null || owner.contains("+") || repo.contains("+")) {
|
if (owner == null || repo == null || !Main.PATH_SEGMENT_PATTERN.matcher(owner).matches() || !Main.PATH_SEGMENT_PATTERN.matcher(repo).matches()) {
|
||||||
resp.send(404, ISSUE_MISSING_REPO);
|
resp.send(404, template(ISSUE_MISSING_REPO));
|
||||||
} else {
|
} else {
|
||||||
String mail = "mailto:create+" + addr[0] + '+' + owner + '+' + repo;
|
String mail = "mailto:" + addr[0] + "+create+" + owner + '+' + repo + '@' + addr[1];
|
||||||
resp.send(200, ISSUE_SUCCESS.formatted(escapeHTML(mail)));
|
resp.send(200, template(ISSUE_SUCCESS.formatted(escapeHTML(mail))));
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
2
src/main/resources/web/close/not_creator.html
Normal file
2
src/main/resources/web/close/not_creator.html
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<h1>Could not close this issue!</h1>
|
||||||
|
<p>You must be the original author of an issue to close it</p>
|
@ -1,4 +1,4 @@
|
|||||||
<h1>Create issue</h1>
|
<h1>Creating an 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>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>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>Additionally, you will be informed if any comments are posted on the issue and may add your own by replying to them</p>
|
||||||
|
@ -4,8 +4,8 @@
|
|||||||
<link rel="stylesheet" href="theme.css" type="text/css">
|
<link rel="stylesheet" href="theme.css" type="text/css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;margin: 0;position: absolute;top: 50%;left: 50%;transform: translate(-50%, -50%);">
|
||||||
%s
|
<%template-content%>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
Loading…
Reference in New Issue
Block a user