/* * 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 . */ 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.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; var originalMessage = messagesService.findMessageByProperty("messageId", inReplyToHeaders[0]); if (originalMessage.isPresent()) { mid = originalMessage.get().getLeft(); rid = originalMessage.get().getRight(); } else { Scanner inReplyToScanner = new Scanner(inReplyToHeaders[0].trim()) .useDelimiter(EmailManager.MSGID_PATTERN); mid = Integer.parseInt(inReplyToScanner.next()); rid = Integer.parseInt(inReplyToScanner.next()); } 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]); } 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); } }