/*
* Copyright (C) 2008-2020, Juick
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.juick.www.api;
import com.juick.model.Message;
import com.juick.model.User;
import com.juick.model.CommandResult;
import com.juick.CommandsManager;
import com.juick.EmailManager;
import com.juick.ServerManager;
import com.juick.util.HttpBadRequestException;
import com.juick.util.HttpForbiddenException;
import com.juick.service.EmailService;
import com.juick.service.MessagesService;
import com.juick.service.StorageService;
import com.juick.service.UserService;
import com.juick.service.component.AccountVerificationEvent;
import com.juick.service.security.annotation.Visitor;
import io.swagger.v3.oas.annotations.Hidden;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.commons.mail.util.MimeMessageParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import javax.inject.Inject;
import javax.mail.Session;
import javax.mail.internet.AddressException;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.*;
@Controller
public class Service {
private static final Logger logger = LoggerFactory.getLogger("Session");
@Inject
private UserService userService;
@Inject
private EmailService emailService;
@Inject
private MessagesService messagesService;
@Inject
private CommandsManager commandsManager;
@Inject
private ApplicationEventPublisher applicationEventPublisher;
@Inject
private User serviceUser;
@Inject
private StorageService storageService;
@Value("${banned_emails:}")
private String[] ignoredEmails;
@Inject
private ServerManager serverManager;
private final Session session = Session.getDefaultInstance(new Properties());
@Hidden
@PostMapping("/api/mail")
@ResponseStatus(value = HttpStatus.OK)
public void processMail(@Visitor User current, InputStream data) throws Exception {
if (current.equals(serviceUser)) {
MimeMessage msg = new MimeMessage(session, data);
String[] returnPaths = msg.getHeader("Return-Path");
if (returnPaths != null) {
logger.info("got msg with return path {}", returnPaths[0]);
if (returnPaths[0].equals("<>")) {
return;
}
}
try {
var messageAddresses = msg.getFrom();
if (messageAddresses == null || messageAddresses.length == 0) {
logger.info("Missing from/sender headers");
return;
}
String from = ((InternetAddress) messageAddresses[0]).getAddress();
User visitor = userService.getUserByEmail(from);
if (!visitor.isAnonymous()) {
MimeMessageParser parser = new MimeMessageParser(msg);
parser.parse();
final String[] body = { parser.getPlainContent() };
if (body[0] == null) {
parser.getAttachmentList().stream().filter(a -> a.getContentType().equals("text/plain"))
.findFirst().ifPresent(a -> {
try {
body[0] = IOUtils.toString(a.getInputStream(), StandardCharsets.UTF_8);
logger.info("got text: {}", body[0]);
} catch (IOException e) {
logger.info("attachment error", e);
}
});
}
final String[] attachmentFName = new String[1];
parser.getAttachmentList().stream().filter(
a -> a.getContentType().equals("image/jpeg") || a.getContentType().equals("image/png"))
.findFirst().ifPresent(a -> {
logger.info("got attachment: {}", a.getContentType());
String attachmentType;
if (a.getContentType().equals("image/jpeg")) {
attachmentType = "jpg";
} else {
attachmentType = "png";
}
attachmentFName[0] = DigestUtils.md5Hex(UUID.randomUUID().toString()) + "."
+ attachmentType;
try {
logger.info("got inputstream: {}", a.getInputStream());
FileOutputStream fos = new FileOutputStream(
Paths.get(storageService.getTemporaryDirectory(), attachmentFName[0])
.toString());
IOUtils.copy(a.getInputStream(), fos);
fos.close();
} catch (IOException e) {
logger.info("attachment error", e);
}
});
String[] inReplyToHeaders = msg.getHeader("In-Reply-To");
if (inReplyToHeaders != null && inReplyToHeaders.length > 0) {
int mid, rid;
String inReplyTo = inReplyToHeaders[0].trim();
var originalMessage = messagesService.findMessageByProperty("messageId", inReplyTo);
if (originalMessage.isPresent()) {
mid = originalMessage.get().getLeft();
rid = originalMessage.get().getRight();
} else {
Scanner inReplyToScanner = new Scanner(inReplyTo)
.useDelimiter(EmailManager.MSGID_PATTERN);
mid = NumberUtils.toInt(inReplyToScanner.next(), 0);
rid = NumberUtils.toInt(inReplyToScanner.next(), 0);
inReplyToScanner.close();
}
if (mid > 0) {
logger.info("Message is reply to #{}/{}", mid, rid);
body[0] = rid > 0 ? String.format("#%d/%d %s", mid, rid, body[0])
: String.format("#%d %s", mid, body[0]);
} else {
logger.warn("Unknown In-Reply-To: {}", inReplyTo);
return;
}
}
URI attachmentUri = StringUtils.isNotEmpty(attachmentFName[0])
? URI.create(String.format("juick://%s", attachmentFName[0]))
: URI.create(StringUtils.EMPTY);
CommandResult result = commandsManager.processCommand(visitor, body[0], attachmentUri);
if (result.getNewMessage().isPresent()) {
String[] messageIds = msg.getHeader("Message-Id");
if (messageIds.length == 1) {
Message message = result.getNewMessage().get();
messagesService.setMessageProperty(message.getMid(), message.getRid(), "messageId",
messageIds[0]);
} else {
logger.warn("Wrong number of Message-Id headers");
}
}
} else {
if (!Arrays.asList(ignoredEmails).contains(from)) {
String verificationCode = RandomStringUtils.randomAlphanumeric(8).toUpperCase();
emailService.addVerificationCode(null, from, verificationCode);
applicationEventPublisher
.publishEvent(new AccountVerificationEvent(this, from, verificationCode));
}
}
} catch (AddressException e) {
logger.info("Got email with invalid from address");
}
} else {
throw new HttpForbiddenException();
}
}
@Hidden
@PostMapping("/api/mail/unsubscribe")
@ResponseStatus(value = HttpStatus.OK)
public void processMailUnsubscribe(@Visitor User current, InputStream data) throws Exception {
if (current.equals(serviceUser)) {
MimeMessage msg = new MimeMessage(session, data);
String from = msg.getFrom() == null || msg.getFrom().length > 1
? ((InternetAddress) msg.getSender()).getAddress()
: ((InternetAddress) msg.getFrom()[0]).getAddress();
User visitor = userService.getUserByEmail(from);
if (!visitor.isAnonymous()) {
if (!emailService.disableEmail(visitor, from)) {
throw new HttpBadRequestException();
}
}
} else {
throw new HttpForbiddenException();
}
}
private void endSession(SseEmitter emitter) {
serverManager.getSessions().stream().filter(s -> s.getEmitter().equals(emitter))
.forEach(session -> serverManager.getSessions().remove(session));
}
@GetMapping("/api/events")
public SseEmitter handle(@Visitor User visitor) {
logger.info("{} connected", visitor.getName());
if (!visitor.isAnonymous()) {
userService.updateLastSeen(visitor);
}
SseEmitter emitter = new SseEmitter(600000L);
serverManager.getSessions().add(new ServerManager.EventSession(visitor, emitter));
emitter.onCompletion(() -> endSession(emitter));
emitter.onTimeout(() -> endSession(emitter));
return emitter;
}
@ExceptionHandler(AsyncRequestTimeoutException.class)
public void eventErrorHandler(Exception ex) {
logger.debug("SSE timeout", ex);
}
}