/* * Copyright (C) 2008-2024, 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.service; import com.juick.model.*; import com.juick.model.User; import com.juick.www.WebApp; import com.juick.util.MessageUtils; import com.juick.util.TagUtils; import io.pebbletemplates.pebble.PebbleEngine; import io.pebbletemplates.pebble.template.PebbleTemplate; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.ImmutablePair; import org.apache.commons.lang3.tuple.Pair; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.ConnectionCallback; import org.springframework.jdbc.core.RowMapper; import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import org.springframework.jdbc.core.namedparam.SqlParameterSource; import org.springframework.lang.NonNull; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import jakarta.inject.Inject; import java.io.IOException; import java.io.StringWriter; import java.io.Writer; import java.net.URI; import java.sql.*; import java.time.Instant; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.util.*; import java.util.stream.Collectors; /** * Created by aalexeev on 11/13/16. */ @Repository public class MessagesServiceImpl extends BaseJdbcService implements MessagesService { private static final Logger logger = LoggerFactory.getLogger(MessagesServiceImpl.class); @Inject private UserService userService; @Inject private TagService tagService; @Inject private SearchService searchService; @Inject private StorageService storageService; @Inject private WebApp webApp; @Value("${photos_url:https://i.juick.com/}") private String baseImagesUrl; @Inject private User archiveUser; @Inject private PebbleEngine pebbleEngine; private class MessageMapper implements RowMapper { @Override public Message mapRow(ResultSet rs, int rowNum) throws SQLException { Message msg = new Message(); msg.setMid(rs.getInt(1)); msg.setRid(rs.getInt(2)); msg.setReplyto(rs.getInt(3)); User user = new User(); user.setUid(rs.getInt(4)); user.setName(Optional.ofNullable(rs.getString(5)).orElse(AnonymousUser.INSTANCE.getName())); user.setBanned(rs.getBoolean(6)); user.setUri(URI.create(Optional.ofNullable(rs.getString(22)).orElse(StringUtils.EMPTY))); user.setPremium(rs.getInt("premium") > 0); msg.setUser(user); msg.setCreated(MessagesServiceImpl.this.getOffsetDateTime(rs, 7).toInstant()); msg.ReadOnly = rs.getBoolean(8); msg.setPrivacy(rs.getInt(9)); msg.setFriendsOnly(msg.getPrivacy() < 0); msg.setReplies(rs.getInt(10)); msg.setAttachmentType(rs.getString(11)); msg.Hidden = rs.getBoolean(13); msg.setRepliesBy(rs.getString(15)); msg.setText(rs.getString(16)); msg.setReplyQuote(MessageUtils.formatQuote(rs.getString(17))); msg.setUpdated(MessagesServiceImpl.this.getOffsetDateTime(rs, 18).toInstant()); int quoteUid = rs.getInt(19); User quoteUser = new User(); quoteUser.setUid(quoteUid); quoteUser.setName(rs.getString(20)); if (quoteUid == 0) { quoteUser.setName(AnonymousUser.INSTANCE.getName()); quoteUser.setUri(URI.create(Optional.ofNullable(rs.getString(23)).orElse(StringUtils.EMPTY))); } else { quoteUser.setAvatar(webApp.getAvatarUrl(quoteUser)); } msg.setTo(quoteUser); msg.setUpdatedAt(MessagesServiceImpl.this.getOffsetDateTime(rs, 21).toInstant()); msg.setReplyUri(URI.create(Optional.ofNullable(rs.getString(24)).orElse(StringUtils.EMPTY))); msg.setHtml(rs.getBoolean(25)); msg.setUnread(rs.getInt(26) > 0); msg.setSubscribed(rs.getInt("subscribed") > 0); if (StringUtils.isNotEmpty(msg.getAttachmentType())) { try { storageService.setAttachmentMetadata(baseImagesUrl, msg); } catch (Exception e) { logger.warn("exception reading images for mid {} rid {}", msg.getMid(), msg.getRid(), e); } } return msg; } } private final MessageMapper messageMapper = new MessageMapper(); /** * @see Java, * JDBC and MySQL Types */ @Transactional @Override public int createMessage(final int uid, final String txt, final String attachment, @NonNull final Set tags) { var now = Instant.now(); var query = returningId(""" INSERT INTO messages(user_id, attach, ts, readonly, updated, txt, updated_at)""", "VALUES(?, ?, ?, ?, ?, ?, ?)", "message_id"); Integer mid = getJdbcTemplate().execute((ConnectionCallback) con -> { var nowObject = toDateTime(now.atOffset(ZoneOffset.UTC)); var stmt = con.prepareStatement(query); stmt.setInt(1, uid); stmt.setString(2, attachment); stmt.setObject(3, nowObject, dateTimeType()); stmt.setBoolean(4, TagUtils.hasTag(tags, "readonly")); stmt.setObject(5, nowObject, dateTimeType()); stmt.setObject(6, StringUtils.defaultString(txt)); stmt.setObject(7, nowObject, dateTimeType()); try (var resultSet = stmt.executeQuery()) { if (resultSet.next()) { return resultSet.getInt(1); } return 0; } }); if (mid != null && mid > 0) { if (CollectionUtils.isNotEmpty(tags)) { var newTags = new ArrayList<>(tags); getJdbcTemplate().batchUpdate("INSERT INTO messages_tags(message_id, tag_id) VALUES (?, ?)", new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int i) throws SQLException { ps.setInt(1, mid); ps.setInt(2, newTags.get(i).getId()); } @Override public int getBatchSize() { return tags.size(); } }); } getNamedParameterJdbcTemplate() .update("UPDATE users SET lastmessage=:lastmessage, last_seen=:last_seen where id=:uid", new MapSqlParameterSource() .addValue("lastmessage", toDateTime(now.atOffset(ZoneOffset.UTC)), dateTimeType()) .addValue("last_seen", toDateTime(now.atOffset(ZoneOffset.UTC)), dateTimeType()) .addValue("uid", uid)); } return mid; } /** * @param mid * @param rid * @param user * @param txt * @param attachment * @return * @see Java, * JDBC and MySQL Types */ @Transactional @Override public int createReply(final int mid, final int rid, final User user, final String txt, final String attachment) { int ridnew = getReplyIDIncrement(mid, user.getUid()); if (ridnew > 0) { var now = Instant.now(); getNamedParameterJdbcTemplate() .update( "INSERT INTO replies(message_id, reply_id, user_id, replyto, attach, txt, ts, updated_at, user_uri) " + "VALUES (:mid, :rid, :uid, :replyto, :attach, :txt, :ts, :updated_at, :user_uri)", new MapSqlParameterSource() .addValue("mid", mid) .addValue("rid", ridnew) .addValue("uid", user.getUid()) .addValue("replyto", rid) .addValue("attach", attachment) .addValue("txt", txt) .addValue("ts", toDateTime(now.atOffset(ZoneOffset.UTC)), dateTimeType()) .addValue("updated_at", toDateTime(now.atOffset(ZoneOffset.UTC)), dateTimeType()) .addValue("user_uri", user.getUri().toASCIIString())); getNamedParameterJdbcTemplate() .update( "UPDATE messages SET replies = replies + 1, updated=:updated WHERE message_id = :message_id", new MapSqlParameterSource() .addValue("updated", toDateTime(now.atOffset(ZoneOffset.UTC)), dateTimeType()) .addValue("message_id", mid)); setLastReadComment(user, mid, ridnew); getNamedParameterJdbcTemplate() .update("UPDATE users SET lastmessage=:lastmessage, last_seen=:last_seen where id=:uid", new MapSqlParameterSource() .addValue("lastmessage", toDateTime(now.atOffset(ZoneOffset.UTC)), dateTimeType()) .addValue("last_seen", toDateTime(now.atOffset(ZoneOffset.UTC)), dateTimeType()) .addValue("uid", user.getUid())); } return ridnew; } public int getReplyIDIncrement(final int mid, final int uid) { return getJdbcTemplate().execute((ConnectionCallback) conn -> { conn.setAutoCommit(false); int replyNo; final boolean readOnly; final int userId; try (PreparedStatement ps = conn.prepareStatement( "SELECT maxreplyid+1, readonly, user_id FROM messages WHERE message_id=? " + forUpdate())) { ps.setInt(1, mid); try (ResultSet resultSet = ps.executeQuery()) { if (resultSet.next()) { replyNo = resultSet.getInt(1); readOnly = resultSet.getBoolean(2); userId = resultSet.getInt(3); } else { throw new IncorrectResultSizeDataAccessException( "while getting getReplyIDIncrement, mid=" + mid, 1, 0); } } } // author can reply to his readonly post if (!readOnly || uid == userId) { try (PreparedStatement ps = conn .prepareStatement("UPDATE messages SET maxreplyid=? WHERE message_id=?")) { ps.setInt(1, replyNo); ps.setInt(2, mid); if (ps.executeUpdate() != 1) { throw new IncorrectResultSizeDataAccessException("Cannot find a message to update: " + mid, 1, 0); } } } else { replyNo = -1; } conn.commit(); return replyNo; }); } @Transactional void updateRepliesBy(int mid) { List users = getJdbcTemplate().queryForList("SELECT users.nick FROM replies " + "INNER JOIN users ON replies.user_id=users.id WHERE replies.message_id=? " + "GROUP BY replies.user_id, users.nick ORDER BY COUNT(replies.reply_id) DESC " + limit(5), String.class, mid); String result = users.stream().map(u -> "@" + u).collect(Collectors.joining(",")); getJdbcTemplate().update("UPDATE messages SET repliesby=? WHERE message_id=?", result, mid); } @Transactional @Override public RecommendStatus recommendMessage(final int mid, final int vuid, final String userUri) { logger.info("Message {} recommended by {} ({})", mid, vuid, userUri); SqlParameterSource sqlParameterSource = new MapSqlParameterSource().addValue("uid", vuid) .addValue("uri", userUri).addValue("like_id", Reaction.LIKE).addValue("mid", mid); int wasDeleted = getNamedParameterJdbcTemplate().update( "DELETE FROM favorites WHERE user_id=:uid AND message_id=:mid AND like_id=:like_id AND user_uri=:uri", sqlParameterSource); if (wasDeleted > 0) { return RecommendStatus.Deleted; } else { var now = toDateTime(Instant.now().atOffset(ZoneOffset.UTC)); boolean wasAdded = getNamedParameterJdbcTemplate().update(""" INSERT INTO favorites(user_id, message_id, ts, like_id, user_uri) VALUES (:user_id, :message_id, :ts, :like_id, :user_uri) """, new MapSqlParameterSource() .addValue("user_id", vuid) .addValue("message_id", mid) .addValue("ts", now, dateTimeType()) .addValue("like_id", Reaction.LIKE) .addValue("user_uri", userUri)) == 1; if (wasAdded) { return RecommendStatus.Added; } } return RecommendStatus.Error; } @Override public RecommendStatus recommendMessage(int mid, int vuid) { return recommendMessage(mid, vuid, StringUtils.EMPTY); } @Override public List listReactions() { return jdbcTemplate.query("SELECT like_id, description FROM reactions", (rs, rowNum) -> { Reaction reaction = new Reaction(rs.getInt("like_id")); reaction.setDescription(rs.getString("description")); return reaction; }); } @Override public RecommendStatus likeMessage(int mid, int vuid, Integer reaction) { return likeMessage(mid, vuid, reaction, StringUtils.EMPTY); } @Transactional @Override public RecommendStatus likeMessage(int mid, int vuid, Integer reaction, String userUri) throws IllegalArgumentException { if (reaction == null) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource().addValue("uid", vuid) .addValue("uri", userUri).addValue("mid", mid); boolean wasDeleted = getNamedParameterJdbcTemplate().update( "DELETE FROM favorites WHERE user_id=:uid AND message_id=:mid AND user_uri=:uri", sqlParameterSource) > 0; if (wasDeleted) { return RecommendStatus.Deleted; } else { return RecommendStatus.Error; } } boolean wasAdded = getJdbcTemplate().update( "INSERT INTO favorites(user_id, message_id, ts, like_id, user_uri) VALUES (?, ?, ?, ?, ?)", vuid, mid, toDateTime(Instant.now().atOffset(ZoneOffset.UTC)), reaction, userUri) == 1; if (wasAdded) { return RecommendStatus.Added; } return RecommendStatus.Error; } @Transactional(readOnly = true) @Override public boolean canViewThread(final int mid, final int uid) { List list = getJdbcTemplate().query("SELECT user_id, privacy FROM messages WHERE message_id = ?", (rs, rowNum) -> { PrivacyOpts res = new PrivacyOpts(); res.setUid(rs.getInt(1)); res.setPrivacy(rs.getInt(2)); return res; }, mid); PrivacyOpts privacyOpts = list.isEmpty() ? null : list.get(0); return privacyOpts == null || privacyOpts.getPrivacy() >= 0 || uid == privacyOpts.getUid() || ((privacyOpts.getPrivacy() == -1 || privacyOpts.getPrivacy() == -2) && uid > 0 && userService.isInWL(privacyOpts.getUid(), uid)); } @Transactional(readOnly = true) @Override public boolean isReadOnly(final int mid) { List list = getJdbcTemplate().queryForList("SELECT readonly FROM messages WHERE message_id = ?", Integer.class, mid); return !list.isEmpty() && list.get(0) == 1; } @Override public void setReadOnly(final int mid, final boolean readonly) { getJdbcTemplate().update("UPDATE messages SET readonly=? WHERE message_id=?", readonly, mid); } @Transactional(readOnly = true) @Override public int getMessagePrivacy(final int mid) { List list = getJdbcTemplate().queryForList("SELECT privacy FROM messages WHERE message_id = ?", Integer.class, mid); return list.isEmpty() ? -4 : list.get(0); } public Optional getMessage(int mid) { return getMessage(0, mid); } @Transactional(readOnly = true) @Override public Optional getMessage(int uid, final int mid) { var messages = getMessages(uid, List.of(mid)); if (messages.size() == 1) { var message = messages.get(0); if (!message.getUser().isBanned()) { return Optional.of(messages.get(0)); } } return Optional.empty(); } @Transactional(readOnly = true) @Override public Message getReply(final int mid, final int rid) { var sql = """ SELECT replies.user_id, users.nick, replies.replyto, replies.ts, replies.attach, replies.txt, COALESCE(q.txt,m.txt, '') as quote, COALESCE(q.user_id, m.user_id) AS to_uid, COALESCE(qu.nick, mu.nick) AS to_name, replies.updated_at, replies.user_uri as uri, q.user_uri AS to_uri, replies.reply_uri AS reply_uri, replies.html, q.reply_uri FROM replies LEFT JOIN users ON replies.user_id = users.id LEFT JOIN replies q ON replies.message_id = q.message_id and replies.replyto = q.reply_id LEFT JOIN messages m ON replies.message_id = m.message_id LEFT JOIN users qu ON q.user_id=qu.id LEFT JOIN users mu ON m.user_id=mu.id WHERE replies.message_id = ? AND replies.reply_id = ? """; List list = getJdbcTemplate().query(sql, (rs, num) -> { Message msg = new Message(); msg.setMid(mid); msg.setRid(rid); msg.setUser(new User()); msg.getUser().setUid(rs.getInt(1)); msg.getUser().setName(rs.getString(2)); if (msg.getUser().getUid() == 0) { msg.getUser().setName(AnonymousUser.INSTANCE.getName()); msg.getUser() .setUri(URI.create(Optional.ofNullable(rs.getString(11)).orElse(StringUtils.EMPTY))); } msg.setReplyto(rs.getInt(3)); msg.setCreated(getOffsetDateTime(rs, 4).toInstant()); msg.setAttachmentType(rs.getString(5)); msg.setText(rs.getString(6)); String quote = rs.getString(7); if (!StringUtils.isEmpty(quote)) { msg.setReplyQuote(MessageUtils.formatQuote(quote)); } int quoteUid = rs.getInt(8); User quoteUser = new User(); quoteUser.setUid(quoteUid); quoteUser.setName(Optional.ofNullable(rs.getString(9)).orElse(AnonymousUser.INSTANCE.getName())); quoteUser.setUri(URI.create(Optional.ofNullable(rs.getString(12)).orElse(StringUtils.EMPTY))); msg.setTo(quoteUser); msg.setUpdatedAt(getOffsetDateTime(rs, 10).toInstant()); msg.setReplyUri(URI.create(Optional.ofNullable(rs.getString(13)).orElse(StringUtils.EMPTY))); msg.setHtml(rs.getBoolean(14)); msg.setReplyToUri(URI.create(Optional.ofNullable(rs.getString(15)).orElse(StringUtils.EMPTY))); if (StringUtils.isNotEmpty(msg.getAttachmentType())) { try { storageService.setAttachmentMetadata(baseImagesUrl, msg); } catch (Exception e) { logger.warn("exception reading images for mid {} rid {}", msg.getMid(), msg.getRid(), e); } } msg.setEntities(MessageUtils.getEntities(msg)); return msg; }, mid, rid); return list.isEmpty() ? null : list.get(0); } @Override public Message getReplyByUri(String replyUri) { List replies = getJdbcTemplate().query("SELECT message_id, reply_id from replies WHERE reply_uri=?", (rs, rowNum) -> getReply(rs.getInt(1), rs.getInt(2)), replyUri); return replies.isEmpty() ? null : replies.get(0); } @Transactional(readOnly = true) @Override public List> getMessagesRecommendations(final int visitorUid, final Collection mids) { return getNamedParameterJdbcTemplate().query( "SELECT DISTINCT favorites.message_id, users.id, users.nick, favorites.user_uri FROM favorites " + "INNER JOIN users ON (favorites.user_id = users.id) " + "INNER JOIN messages m ON favorites.message_id=m.message_id WHERE favorites.like_id=1 " + "AND NOT EXISTS (SELECT 1 FROM bl_users WHERE " + "(user_id = favorites.user_id AND bl_user_id = m.user_id) " + "OR (user_id = m.user_id AND bl_user_id = favorites.user_id) " + (visitorUid > 0 ? " OR (user_id = :visitorUid AND favorites.user_id = bl_user_id)" : "") + ") " + "AND favorites.message_id IN (:mids)", new MapSqlParameterSource("mids", mids).addValue("visitorUid", visitorUid), (rs, rowNum) -> { User user = new User(); user.setUid(rs.getInt(2)); var uri = URI.create(rs.getString(4)); if (StringUtils.isEmpty(uri.toASCIIString())) { user.setName(rs.getString(3)); } else { user.setUri(uri); String path = uri.getPath(); String name = path.substring(path.lastIndexOf('/') + 1); user.setName(name); } return new ImmutablePair<>(rs.getInt(1), user); }); } @Transactional(readOnly = true) @Override public List getAll(final int visitorUid, final int before) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource().addValue("before", before) .addValue("visitorUid", visitorUid); return getNamedParameterJdbcTemplate().queryForList("SELECT m.message_id FROM messages m WHERE " + (before > 0 ? " m.message_id < :before AND " : StringUtils.EMPTY) + " m.hidden = 0 AND (m.privacy > 0" + (visitorUid > 0 ? " OR m.user_id = :visitorUid) AND NOT EXISTS (" + " SELECT 1 FROM bl_users b WHERE b.user_id = :visitorUid AND b.bl_user_id = m.user_id)" : ")") + " AND NOT EXISTS (SELECT 1 FROM bl_tags bt WHERE bt.tag_id IN " + "(SELECT tag_id FROM messages_tags WHERE message_id = m.message_id) and :visitorUid = bt.user_id)" + " AND NOT EXISTS (SELECT 1 from users u WHERE u.banned = 1 and u.id = m.user_id and u.id <> :visitorUid)" + " ORDER BY m.message_id DESC " + limit(20), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getTag(final int tid, final int visitorUid, final int before, final int cnt) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource() .addValue("tid", tid) .addValue("before", before) .addValue("visitorUid", visitorUid); return getNamedParameterJdbcTemplate() .queryForList("SELECT messages.message_id FROM (tags INNER JOIN messages_tags " + "ON ((tags.synonym_id = :tid OR tags.tag_id = :tid) AND tags.tag_id = messages_tags.tag_id)) " + "INNER JOIN messages ON messages.message_id = messages_tags.message_id WHERE " + (before > 0 ? " messages.message_id < :before AND " : StringUtils.EMPTY) + "(messages.privacy > 0 OR messages.user_id = :visitorUid) " + "AND NOT EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :visitorUid and b.bl_user_id = messages.user_id) " + "ORDER BY messages.message_id DESC " + limit(cnt), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getTags(final String tids, final int visitorUid, final int before, final int cnt) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource() .addValue("before", before) .addValue("visitorUid", visitorUid); return getNamedParameterJdbcTemplate().queryForList("SELECT messages.message_id FROM messages_tags " + "INNER JOIN messages USING(message_id) WHERE messages_tags.tag_id IN (" + tids + ") " + (before > 0 ? " AND messages.message_id < :before " : StringUtils.EMPTY) + " AND (messages.privacy > 0 OR messages.user_id = :visitorUid) " + "ORDER BY messages.message_id DESC " + limit(cnt), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getPlace(final int placeId, final int visitorUid, final int before) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource().addValue("placeId", placeId) .addValue("before", before).addValue("visitorUid", visitorUid); return getNamedParameterJdbcTemplate().queryForList( "SELECT message_id FROM messages WHERE place_id = :placeId " + (before > 0 ? " AND message_id < :before " : StringUtils.EMPTY) + " AND (privacy > 0 OR user_id = :visitorUid) ORDER BY message_id DESC " + limit(20), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getMyFeed(final int uid, final int before, boolean recommended) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource().addValue("uid", uid).addValue("before", before); return getNamedParameterJdbcTemplate().queryForList("SELECT message_id FROM messages WHERE " + "(user_id=:uid OR " + "(EXISTS (SELECT 1 FROM subscr_users WHERE subscr_users.suser_id = :uid " + "AND subscr_users.user_id = messages.user_id) " + "AND NOT EXISTS (SELECT 1 FROM bl_tags bt WHERE bt.tag_id IN " + "(SELECT tag_id FROM messages_tags WHERE message_id = messages.message_id) AND :uid = bt.user_id) " + "AND (privacy >= 0 OR (privacy >= -2 AND privacy <= -1 " + "AND EXISTS (SELECT 1 FROM wl_users w WHERE w.wl_user_id = :uid and w.user_id = messages.user_id)))) " + (recommended ? "OR (EXISTS (SELECT 1 FROM favorites WHERE favorites.message_id=messages.message_id " + "AND favorites.user_id IN (SELECT user_id FROM subscr_users WHERE suser_id=:uid)) " + "AND NOT EXISTS (SELECT 1 FROM bl_tags bt WHERE bt.tag_id IN (SELECT tag_id FROM messages_tags " + "WHERE message_id = messages.message_id) and :uid = bt.user_id) " + "AND (privacy >= 0 OR (privacy >= -2 AND privacy <= -1 " + "AND EXISTS (SELECT 1 FROM wl_users w " + "WHERE w.wl_user_id = :uid and w.user_id = messages.user_id)))) " + "AND NOT EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :uid and b.bl_user_id = messages.user_id)" : StringUtils.EMPTY) + ") " + (before > 0 ? "AND message_id < :before " : StringUtils.EMPTY) + "ORDER BY message_id DESC " + limit(20), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getPrivate(final int uid, final int before) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource().addValue("uid", uid).addValue("before", before); return getNamedParameterJdbcTemplate() .queryForList("SELECT message_id FROM messages WHERE user_id = :uid AND privacy < 0" + (before > 0 ? " AND message_id < :before " : StringUtils.EMPTY) + "ORDER BY message_id DESC " + limit(20), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getDiscussions(final int uid, final Long to) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource() .addValue("uid", uid) .addValue("to", fromEpochMilli(to), dateTimeType()); if (uid == 0) { return getNamedParameterJdbcTemplate().query( "SELECT message_id FROM messages WHERE " + (to != 0 ? " updated < :to AND" : StringUtils.EMPTY) + " NOT EXISTS (SELECT 1 from users u WHERE u.banned = 1" + " AND u.id = messages.user_id and u.id <> :uid)" + " AND privacy >= 0" + " ORDER BY updated DESC, message_id DESC " + limit(20), sqlParameterSource, (rs, rowNum) -> rs.getInt(1)); } return getNamedParameterJdbcTemplate().query( "SELECT messages.message_id, messages.updated FROM subscr_messages " + "INNER JOIN messages ON messages.message_id=subscr_messages.message_id " + "WHERE suser_id = :uid " + (to != 0 ? "AND updated < :to " : StringUtils.EMPTY) + "AND (messages.privacy >= 0 OR (messages.privacy >= -2 AND messages.privacy <= -1 " + "AND EXISTS (SELECT 1 FROM wl_users w " + "WHERE w.wl_user_id = :uid and w.user_id = messages.user_id)))" + " AND NOT EXISTS (SELECT 1 from users u WHERE u.banned = 1" + " AND u.id = messages.user_id and u.id <> :uid) " + "ORDER BY updated DESC, messages.message_id DESC " + limit(20), sqlParameterSource, (rs, rowNum) -> rs.getInt(1)); } @Transactional(readOnly = true) @Override public List getRecommended(final int uid, final int before) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource().addValue("uid", uid).addValue("before", before); return getNamedParameterJdbcTemplate().queryForList("SELECT f.message_id FROM favorites f WHERE " + "EXISTS (SELECT 1 FROM subscr_users s WHERE s.suser_id = :uid and f.user_id = s.user_id)" + (before > 0 ? " AND f.message_id < :before " : StringUtils.EMPTY) + "ORDER BY f.message_id DESC " + limit(20), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getPhotos(final int visitorUid, final int before) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource().addValue("vid", visitorUid) .addValue("before", before); return getNamedParameterJdbcTemplate() .queryForList("SELECT m.message_id FROM messages m WHERE (m.privacy > 0 OR m.user_id = :vid) " + (before > 0 ? " AND m.message_id < :before " : StringUtils.EMPTY) + " AND m.attach IS NOT NULL " + " AND NOT EXISTS (SELECT 1 FROM bl_tags bt WHERE bt.tag_id IN " + "(SELECT tag_id FROM messages_tags WHERE message_id = m.message_id) and :vid = bt.user_id)" + " AND NOT EXISTS (SELECT 1 from users u WHERE u.banned = 1 and u.id = m.user_id and u.id <> :vid) " + " AND NOT EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :vid and b.bl_user_id = m.user_id) " + " ORDER BY m.message_id DESC " + limit(20), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getSearch(final User visitor, final String search, final int page) { return searchService.searchInAllMessages(visitor, search, page); } @Transactional(readOnly = true) @Override public List getUserBlog(final int uid, final int privacy, final int before) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource().addValue("uid", uid) .addValue("privacy", privacy).addValue("before", before); return getNamedParameterJdbcTemplate().queryForList( "SELECT message_id FROM messages INNER JOIN users" + " ON messages.user_id = users.id" + " WHERE user_id = :uid" + (before > 0 ? " AND message_id < :before" : StringUtils.EMPTY) + " AND privacy >= :privacy AND users.banned = 0 ORDER BY message_id DESC " + limit(20), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getUserTag(final int uid, final int tid, final int privacy, final int before) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource() .addValue("uid", uid) .addValue("tid", tid) .addValue("privacy", privacy) .addValue("before", before); return getNamedParameterJdbcTemplate() .queryForList("SELECT messages.message_id FROM messages_tags INNER JOIN messages" + " ON messages.message_id = messages_tags.message_id" + " INNER JOIN users" + " ON messages.user_id=users.id" + " WHERE messages.user_id = :uid AND messages_tags.tag_id = :tid" + (before > 0 ? " AND messages.message_id < :before " : StringUtils.EMPTY) + " AND messages.privacy >= :privacy AND users.banned = 0" + " ORDER BY messages.message_id DESC " + limit(20), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getUserBlogAtDay(final int uid, final int privacy, final int daysback) { var backDate = Instant.now().minus(daysback, ChronoUnit.DAYS).atOffset(ZoneOffset.UTC); SqlParameterSource sqlParameterSource = new MapSqlParameterSource() .addValue("uid", uid) .addValue("privacy", privacy) .addValue("date", toDateTime(backDate), dateTimeType()); return getNamedParameterJdbcTemplate().queryForList( "SELECT message_id FROM messages" + " INNER JOIN users" + " ON messages.user_id = users.id" + " WHERE user_id = :uid" + (daysback > 0 ? " AND ts >= :date" + " AND ts < :date" : StringUtils.EMPTY) + " AND privacy >= :privacy AND users.banned = 0 ORDER BY message_id DESC " + limit(20), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getUserBlogWithRecommendations(final User user, final User visitor, final int privacy, final int before) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource() .addValue("uid", user.getUid()) .addValue("vid", visitor.getUid()) .addValue("privacy", privacy).addValue("before", before); return getNamedParameterJdbcTemplate() .queryForList("SELECT message_id FROM " + "(SELECT favorites.message_id FROM favorites " + " INNER JOIN messages ON messages.message_id = favorites.message_id" + " INNER JOIN users ON messages.user_id = users.id" + " WHERE favorites.user_id = :uid AND users.banned = 0" + " AND NOT EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :vid and b.bl_user_id = messages.user_id) " + " AND NOT EXISTS (SELECT 1 FROM bl_tags bt WHERE bt.tag_id IN " + "(SELECT tag_id FROM messages_tags WHERE message_id = favorites.message_id) and :vid = bt.user_id)" + (before > 0 ? " AND messages.message_id < :before " : StringUtils.EMPTY) + " ORDER BY messages.message_id DESC " + limit(20) + ") as r" + " UNION ALL " + "SELECT message_id FROM " + "(SELECT message_id FROM messages" + " INNER JOIN users" + " ON messages.user_id = users.id" + " WHERE user_id = :uid AND users.banned = 0" + " AND NOT EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :vid and b.bl_user_id = messages.user_id) " + " AND NOT EXISTS (SELECT 1 FROM bl_tags bt WHERE bt.tag_id IN " + "(SELECT tag_id FROM messages_tags WHERE message_id = messages.message_id) and :vid = bt.user_id)" + (before > 0 ? " AND messages.message_id < :before" : StringUtils.EMPTY) + " AND privacy >= :privacy ORDER BY messages.message_id DESC " + limit(20) + ") as m " + "ORDER BY message_id DESC " + limit(20), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getUserRecommendations(final int uid, final int before) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource() .addValue("uid", uid) .addValue("before", before); return getNamedParameterJdbcTemplate().queryForList("SELECT message_id FROM favorites" + " INNER JOIN users" + " ON favorites.user_id = users.id" + " WHERE user_id = :uid AND users.banned = 0 " + (before > 0 ? " AND message_id < :before " : StringUtils.EMPTY) + " ORDER BY message_id DESC " + limit(20), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getUserPhotos(final int uid, final int privacy, final int before) { SqlParameterSource sqlParameterSource = new MapSqlParameterSource() .addValue("uid", uid) .addValue("privacy", privacy) .addValue("before", before); return getNamedParameterJdbcTemplate().queryForList( "SELECT message_id FROM messages" + " INNER JOIN users" + " ON messages.user_id = users.id" + " WHERE user_id = :uid and users.banned = 0" + (before > 0 ? " AND message_id < :before " : StringUtils.EMPTY) + " AND privacy >= :privacy AND attach IS NOT NULL ORDER BY message_id DESC " + limit(20), sqlParameterSource, Integer.class); } @Transactional(readOnly = true) @Override public List getUserSearch(final User visitor, final int UID, final String search, final int privacy, final int page) { return searchService.searchByStringAndUser(visitor, search, UID, page); } @Transactional(readOnly = true) @Override public List getMessages(int uid, final List mids) { if (CollectionUtils.isNotEmpty(mids)) { var query = withRecursive() + """ banned(message_id, reply_id) AS (SELECT message_id, reply_id FROM replies WHERE replies.message_id IN (:ids) AND (EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :uid AND b.bl_user_id = replies.user_id) OR EXISTS (SELECT 1 from users u WHERE u.banned = 1 and u.id = replies.user_id and u.id <> :uid)) UNION ALL SELECT replies.message_id, replies.reply_id FROM replies INNER JOIN banned ON banned.reply_id = replies.replyto AND replies.reply_id != replies.replyto AND banned.message_id=replies.message_id WHERE replies.message_id IN (:ids)) SELECT messages.message_id, 0 as rid, 0 as replyto, messages.user_id, users.nick, users.banned as usr_banned, messages.ts, messages.readonly, messages.privacy, 1.*messages.replies-COUNT(DISTINCT banned.reply_id) as replies, messages.attach, COUNT(DISTINCT favorites.user_id) AS likes, messages.hidden, '' as tags, messages.repliesby, COALESCE(messages.txt, '') txt, '' as q, messages.updated, 0 as to_uid, NULL as to_name, messages.updated_at, '' as m_user_uri, '' as to_uri, '' as msg_reply_uri, 0 as html, (1.*messages.replies - subscr_messages.last_read_rid) as unread, (SELECT CASE WHEN EXISTS(SELECT * from subscr_messages where message_id=messages.message_id and suser_id=:uid) THEN 1 ELSE 0 END) subscribed, users.premium FROM messages INNER JOIN users ON messages.user_id=users.id LEFT JOIN subscr_messages ON messages.message_id=subscr_messages.message_id AND subscr_messages.suser_id=:uid LEFT JOIN favorites ON messages.message_id = favorites.message_id AND favorites.like_id=1 LEFT JOIN banned ON messages.message_id = banned.message_id LEFT JOIN messages_tags ON messages_tags.message_id=messages.message_id LEFT JOIN tags ON tags.tag_id=messages_tags.tag_id WHERE messages.message_id IN (:ids) GROUP BY messages.message_id, messages.user_id, users.nick, users.banned, messages.ts, messages.readonly, messages.privacy, messages.attach, messages.hidden, messages.repliesby, messages.txt, messages.updated, messages.replies, updated_at, subscr_messages.last_read_rid, users.premium"""; List msgs = getNamedParameterJdbcTemplate().query(query, new MapSqlParameterSource("ids", mids) .addValue("uid", uid), messageMapper); msgs.forEach(m -> { var tags = tagService.getMessageTags(m.getMid()).stream() .map(TagStats::getTag).collect(Collectors.toSet()); m.setTags(tags); }); Map> likes = updateReactionsFor(mids); msgs.forEach(i -> i.setReactions(likes.get(i.getMid()))); msgs.sort(Comparator.comparing(item -> mids.indexOf(item.getMid()))); msgs.forEach(i -> i.setEntities(MessageUtils.getEntities(i))); List> allRecommendations = getMessagesRecommendations(uid, mids); msgs.forEach(m -> { m.setRecommendations(new HashSet<>(allRecommendations.stream() .filter(r -> r.getLeft().equals(m.getMid())).map(Pair::getRight).toList())); m.getRecommendations().forEach(r -> r.setAvatar(webApp.getAvatarUrl(r))); }); return msgs; } return Collections.emptyList(); } private Map> updateReactionsFor(final List mids) { // This method always called from the transactional block, so it should not be // marked as transactional itself return getNamedParameterJdbcTemplate().query(""" select f.message_id as mid, f.like_id as lid, r.description as descr, count(f.like_id) as cnt from favorites f LEFT JOIN reactions r ON f.like_id = r.like_id where f.message_id IN (:mids) group by f.message_id, f.like_id, r.description""", new MapSqlParameterSource("mids", mids), (ResultSet rs) -> { Map> results = new HashMap<>(); while (rs.next()) { int messageId = rs.getInt("mid"); int likeId = rs.getInt("lid"); int count = rs.getInt("cnt"); String description = rs.getString("descr"); Reaction reaction = new Reaction(likeId); reaction.setCount(count); reaction.setDescription(description); results.computeIfAbsent(messageId, HashSet::new).add(reaction); } return results; }); } @Transactional @Override public List getReplies(final User user, final int mid) { List replies = getNamedParameterJdbcTemplate().query(withRecursive() + " banned(reply_id, user_id) AS (" + "SELECT reply_id, user_id FROM replies " + "WHERE replies.message_id = :mid " + "AND EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :uid AND b.bl_user_id = replies.user_id) " + "UNION ALL SELECT replies.reply_id, replies.user_id FROM replies " + "INNER JOIN banned ON banned.reply_id = replies.replyto " + "WHERE replies.message_id = :mid) " + "SELECT replies.message_id as mid, replies.reply_id, replies.replyto, " + "replies.user_id, users.nick, users.banned, " + "replies.ts, " + "0 as readonly, 0 as privacy, 0 as replies, " + "replies.attach, 0 as likes, 0 as hidden, " + "NULL as tags, NULL as repliesby, replies.txt, " + "COALESCE(qw.txt, m.txt) as q, " + ":now, " + "COALESCE(qw.user_id, m.user_id) as to_uid, COALESCE(qu.nick, mu.nick) as to_name, " + "replies.updated_at, replies.user_uri as uri, " + "qw.user_uri as to_uri, replies.reply_uri, replies.html, 0 as unread, " + "0 as subscribed, users.premium " + "FROM replies LEFT JOIN users " + "ON replies.user_id = users.id " + "LEFT JOIN replies qw ON replies.message_id = qw.message_id and replies.replyto = qw.reply_id " + "LEFT JOIN messages m on replies.message_id = m.message_id " + "LEFT JOIN users qu ON qw.user_id=qu.id " + "LEFT JOIN users mu ON m.user_id=mu.id " + "WHERE replies.message_id = :mid " + "AND NOT EXISTS (SELECT 1 from users u WHERE u.banned = 1 and u.id = replies.user_id and u.id <> :uid)" + "AND NOT EXISTS (SELECT 1 FROM banned WHERE banned.reply_id = replies.reply_id) " + "AND NOT EXISTS (SELECT 1 FROM bl_users b WHERE b.user_id = :uid AND b.bl_user_id = m.user_id) " + "ORDER BY replies.reply_id ASC", new MapSqlParameterSource() .addValue("mid", mid) .addValue("uid", user.getUid()) .addValue("now", toDateTime(Instant.now().atOffset(ZoneOffset.UTC)), dateTimeType()), messageMapper); replies.forEach(i -> { i.setEntities(MessageUtils.getEntities(i)); i.getUser().setAvatar(webApp.getAvatarUrl(i.getUser())); }); return replies; } @Transactional @Override public boolean setMessagePrivacy(final int mid) { return getJdbcTemplate().update("UPDATE messages SET privacy=1 WHERE message_id=?", mid) > 0; } @Transactional @Override public boolean deleteMessage(final int uid, final int mid) { Instant now = Instant.now(); Instant messageDeletingWindow = now.minus(3, ChronoUnit.DAYS); Optional message = getMessage(uid, mid); if (message.isPresent()) { if (message.get().getUser().getUid() != uid) return false; Instant ts = message.get().getUpdatedAt(); SqlParameterSource parameters = new MapSqlParameterSource().addValue("mid", mid).addValue("uid", uid) .addValue("archive_uid", archiveUser.getUid()); if (ts.compareTo(messageDeletingWindow) >= 0) { // TODO: cascade delete getNamedParameterJdbcTemplate().update("DELETE FROM replies WHERE message_id = :mid", parameters); getNamedParameterJdbcTemplate().update("DELETE FROM messages_tags WHERE message_id = :mid", parameters); getNamedParameterJdbcTemplate().update("DELETE FROM subscr_messages WHERE message_id = :mid", parameters); getNamedParameterJdbcTemplate().update("DELETE FROM messages_properties WHERE message_id = :mid", parameters); return getNamedParameterJdbcTemplate() .update("DELETE FROM messages WHERE message_id = :mid AND user_id = :uid", parameters) > 0; } else { return getNamedParameterJdbcTemplate().update( "UPDATE messages SET user_id=:archive_uid WHERE message_id = :mid AND user_id = :uid", parameters) > 0; } } return false; } @Transactional @Override public boolean deleteReply(final int uid, final int mid, final int rid) { Instant now = Instant.now(); Instant messageDeletingWindow = now.minus(3, ChronoUnit.DAYS); var message = getMessage(uid, mid); if (message.isPresent()) { Message reply = getReply(mid, rid); if (reply != null) { Instant ts = reply.getUpdatedAt(); User author = message.get().getUser(); SqlParameterSource parameters = new MapSqlParameterSource().addValue("mid", mid).addValue("uid", uid) .addValue("rid", rid).addValue("archive_uid", archiveUser.getUid()); if (ts.compareTo(messageDeletingWindow) >= 0) { boolean result; // allow to delete author replies and replies to author if (author.getUid() == uid) { result = getNamedParameterJdbcTemplate() .update("DELETE FROM replies WHERE message_id=:mid AND reply_id=:rid", parameters) > 0; } else { result = getNamedParameterJdbcTemplate().update( "DELETE FROM replies WHERE message_id=:mid AND reply_id=:rid AND user_id=:uid", parameters) > 0; } if (result) { getNamedParameterJdbcTemplate() .update("UPDATE messages SET replies=replies-1 WHERE message_id=:mid", parameters); updateRepliesBy(mid); return true; } } else { // only archive author replies return getNamedParameterJdbcTemplate().update( "UPDATE replies SET user_id=:archive_uid WHERE message_id=:mid AND reply_id=:rid AND user_id=:uid", parameters) > 0; } } } return false; } @Transactional(readOnly = true) @Override public List getLastMessages(int hours) { return getNamedParameterJdbcTemplate() .queryForList( "SELECT message_id FROM messages WHERE messages.ts > :hours", new MapSqlParameterSource() .addValue("hours", toDateTime(Instant.now().minus(hours, ChronoUnit.HOURS).atOffset(ZoneOffset.UTC)), dateTimeType()), Integer.class); } @Transactional(readOnly = true) @Override public List getLastReplies(int hours) { var datetime = Instant.now().minus(hours, ChronoUnit.HOURS).atOffset(ZoneOffset.UTC); return getNamedParameterJdbcTemplate().query(""" SELECT users2.nick,replies.message_id,replies.reply_id, users.nick,replies.txt,replies.ts,replies.attach,replies.ts, replies.html FROM ((replies INNER JOIN users ON replies.user_id=users.id) INNER JOIN messages ON replies.message_id=messages.message_id) INNER JOIN users AS users2 ON messages.user_id=users2.id WHERE replies.ts>:datetime AND messages.privacy>0 """, new MapSqlParameterSource() .addValue("datetime", toDateTime(datetime), dateTimeType()), (rs, rowNum) -> { ResponseReply reply = new ResponseReply(); reply.setMuname(rs.getString(1)); reply.setMid(rs.getInt(2)); reply.setRid(rs.getInt(3)); reply.setUname(rs.getString(4)); reply.setDescription(rs.getString(5)); reply.setPubDate(rs.getTimestamp(6)); reply.setAttachmentType(rs.getString(7)); reply.setHtml(rs.getBoolean(8)); return reply; }); } @Transactional(readOnly = true) @Override public List getPopularCandidates() { var beforeTime = Instant.now().minus(2, ChronoUnit.HOURS); var sql = """ SELECT replies.message_id FROM replies INNER JOIN messages ON replies.message_id = messages.message_id LEFT JOIN favorites ON favorites.message_id = messages.message_id LEFT JOIN messages_tags ON messages_tags.message_id = messages.message_id LEFT JOIN tags ON messages_tags.tag_id=tags.tag_id WHERE replies.ts > :before AND NOT EXISTS (SELECT 1 FROM tags WHERE messages_tags.message_id=messages.message_id AND messages_tags.tag_id=tags.tag_id AND tags.notop = 1) AND NOT EXISTS (SELECT 1 FROM favorites WHERE message_id = messages.message_id AND user_id = 2) GROUP BY replies.message_id HAVING COUNT(DISTINCT(replies.user_id)) > 5 UNION ALL SELECT favorites.message_id FROM favorites INNER JOIN messages ON messages.message_id = favorites.message_id LEFT JOIN messages_tags ON messages_tags.message_id = messages.message_id LEFT JOIN tags ON messages_tags.tag_id=tags.tag_id WHERE favorites.ts > :before AND NOT EXISTS (SELECT 1 FROM tags WHERE messages_tags.message_id=messages.message_id AND messages_tags.tag_id=tags.tag_id AND tags.notop = 1) AND NOT EXISTS (SELECT 1 FROM favorites WHERE message_id = messages.message_id AND user_id = 2) GROUP BY favorites.message_id HAVING COUNT(DISTINCT favorites.user_id) > 2 """; return getNamedParameterJdbcTemplate().queryForList(sql, new MapSqlParameterSource() .addValue("before", toDateTime(beforeTime.atOffset(ZoneOffset.UTC)), dateTimeType()), Integer.class); } @Transactional @Override public void setLastReadComment(User user, Integer mid, Integer rid) { jdbcTemplate.update( "UPDATE subscr_messages SET last_read_rid=" + greatest() + "(?, last_read_rid) WHERE message_id=? AND suser_id=?", rid, mid, user.getUid()); } @Transactional @Override public void setRead(User user, Integer mid) { jdbcTemplate.update( "UPDATE subscr_messages SET last_read_rid=(select replies from messages " + "where messages.message_id=subscr_messages.message_id) WHERE message_id=? AND suser_id=?", mid, user.getUid()); } @Transactional(readOnly = true) @Override public List getUnread(User user) { return jdbcTemplate.queryForList( "select subscr_messages.message_id " + "from subscr_messages inner join messages on subscr_messages.message_id=messages.message_id " + "where subscr_messages.suser_id=? " + "AND NOT EXISTS (SELECT 1 FROM bl_users WHERE " + "user_id = ? AND bl_user_id = messages.user_id) AND " + "messages.replies>subscr_messages.last_read_rid", Integer.class, user.getUid(), user.getUid()); } @Transactional @Override public boolean updateMessage(Integer mid, Integer rid, String body, boolean foreign) { Instant now = Instant.now(); Instant messageEditingWindow = now.minus(3, ChronoUnit.DAYS); Instant replyEditingWindow = now.minus(15, ChronoUnit.MINUTES); if (rid == 0) { Optional message = getMessage(mid); if (message.isPresent()) { Instant ts = message.get().getUpdatedAt(); if (ts.compareTo(messageEditingWindow) >= 0 || foreign) { return namedParameterJdbcTemplate.update( "UPDATE messages SET txt=:txt, updated_at=:now WHERE message_id=:mid", new MapSqlParameterSource() .addValue("txt", body) .addValue("mid", mid) .addValue("now", toDateTime(now.atOffset(ZoneOffset.UTC)), dateTimeType())) > 0; } } return false; } else { Message reply = getReply(mid, rid); if (reply != null) { Instant ts = reply.getUpdatedAt(); if (ts.compareTo(replyEditingWindow) >= 0 || foreign) { return namedParameterJdbcTemplate.update( "UPDATE replies SET txt=:txt, updated_at=:now WHERE message_id=:mid AND reply_id=:rid", new MapSqlParameterSource() .addValue("txt", body) .addValue("mid", mid) .addValue("rid", rid) .addValue("now", toDateTime(now.atOffset(ZoneOffset.UTC)), dateTimeType())) > 0; } } return false; } } @Transactional @Override public boolean updateReplyUri(Message reply, URI replyUri) { return jdbcTemplate.update("UPDATE replies SET reply_uri=?, html=0 WHERE message_id=? AND reply_id=?", replyUri.toASCIIString(), reply.getMid(), reply.getRid()) > 0; } @Transactional(readOnly = true) @Override public boolean replyExists(URI replyUri) { return jdbcTemplate .queryForList("SELECT reply_id FROM replies WHERE reply_uri=?", Integer.class, replyUri.toASCIIString()) .size() > 0; } @Transactional @Override public boolean deleteReply(URI userUri, URI replyUri) { return jdbcTemplate.update("DELETE FROM replies WHERE user_uri=? AND reply_uri=?", userUri.toASCIIString(), replyUri.toASCIIString()) > 0; } @Transactional(readOnly = true) @Override public String getMessageProperty(Integer mid, Integer rid, String key) { List results = jdbcTemplate.queryForList( "SELECT property_value FROM messages_properties WHERE message_id=? AND reply_id=? AND property_key=?", String.class, mid, rid, key); if (results.size() > 0) { return results.get(0); } return StringUtils.EMPTY; } @Transactional @Override public void setMessageProperty(Integer mid, Integer rid, String key, String value) { SqlParameterSource parameterSource = new MapSqlParameterSource().addValue("mid", mid).addValue("rid", rid) .addValue("key", key).addValue("value", value); if (StringUtils.isNotEmpty(value)) { var exists = getMessageProperty(mid, rid, key); if (StringUtils.isEmpty(exists)) { getNamedParameterJdbcTemplate() .update("INSERT INTO messages_properties(message_id, reply_id, property_key, property_value) " + "VALUES(:mid, :rid, :key, :value)", parameterSource); } else { getNamedParameterJdbcTemplate().update("UPDATE messages_properties SET property_value=:value " + "WHERE message_id=:mid AND reply_id=:rid AND property_key=:key", parameterSource); } } else { getNamedParameterJdbcTemplate().update("DELETE FROM messages_properties " + "WHERE message_id=:mid AND reply_id=:rid AND property_key=:key", parameterSource); } } @Transactional(readOnly = true) @Override public Optional> findMessageByProperty(String key, String value) { List> results = jdbcTemplate.query( "SELECT message_id, reply_id FROM messages_properties " + "WHERE property_key=? AND property_value=?", (rs, rowNum) -> Pair.of(rs.getInt(1), rs.getInt(2)), key, value); if (results.size() > 0) { return Optional.of(results.get(0)); } return Optional.empty(); } @Transactional @Override public void setFriendsOnly(Integer mid, Boolean friendsOnly) { int privacy = friendsOnly ? -1 : 1; jdbcTemplate.update("UPDATE messages SET privacy=? WHERE message_id=?", privacy, mid); } public Optional renderPlaintext(String body, String messageUrl) { PebbleTemplate noteTemplate = pebbleEngine.getTemplate("email/plaintext"); Map context = new HashMap<>(); context.put("messageBody", body); context.put("messageUrl", messageUrl); try { Writer writer = new StringWriter(); noteTemplate.evaluate(writer, context); return Optional.of(writer.toString()); } catch (IOException e) { return Optional.empty(); } } public Optional renderHtml(String body, String messageUrl, Message msg, String hash) { PebbleTemplate noteTemplate = pebbleEngine.getTemplate("email/html"); Map context = new HashMap<>(); context.put("messageBody", body); context.put("messageUrl", messageUrl); context.put("msg", msg); context.put("hash", hash); try { Writer writer = new StringWriter(); noteTemplate.evaluate(writer, context); return Optional.of(writer.toString()); } catch (IOException e) { return Optional.empty(); } } }