diff --git a/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java b/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java index 9175d22..1f3845a 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java +++ b/src/main/java/org/to/telegramfinalproject/Client/ActionHandler.java @@ -10,6 +10,7 @@ import org.to.telegramfinalproject.Models.SearchRequestModel; import org.to.telegramfinalproject.Models.SearchResultModel; import java.io.BufferedReader; +import java.io.DataOutputStream; import java.io.IOException; import java.io.PrintWriter; import java.time.LocalDateTime; @@ -26,6 +27,8 @@ public class ActionHandler { private final Scanner scanner; public static volatile boolean forceExitChat = false; public static ActionHandler instance; + private final DataOutputStream outBin; // NEW + //use for UI private volatile String lastStatus = "error"; // success | error @@ -57,14 +60,23 @@ public class ActionHandler { } } - public ActionHandler(PrintWriter out, BufferedReader in, Scanner scanner) { + + public ActionHandler(PrintWriter out, BufferedReader in, DataOutputStream outBin, Scanner scanner) { this.out = out; - this.in = in; + this.in = in; + this.outBin = outBin; // NEW this.scanner = scanner; ActionHandler.instance = this; - } +// public ActionHandler(PrintWriter out, BufferedReader in, Scanner scanner) { +// this.out = out; +// this.in = in; +// this.scanner = scanner; +// ActionHandler.instance = this; +// +// } + public void login(String username , String password){ JSONObject request = new JSONObject(); request.put("action", "login"); diff --git a/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java b/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java index d617574..abc20f6 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java +++ b/src/main/java/org/to/telegramfinalproject/Client/IncomingMessageListener.java @@ -39,16 +39,31 @@ public class IncomingMessageListener implements Runnable { String line; while ((line = in.readLine()) != null) { + if (TelegramClient.mediaBusy.get()) { + try { Thread.sleep(15); } catch (InterruptedException ignored) {} + continue; + } + JSONObject response = new JSONObject(line); System.out.println("📥 Received raw line: " + line); //if it has reqID answer + String mid = response.optString("message_id", ""); + if (!mid.isEmpty()) { + BlockingQueue q = TelegramClient.pendingResponses.get(mid); + if (q != null) { + q.put(response); + continue; // این پیام مصرف شد + } + } + if (response.has("request_id")) { String requestId = response.getString("request_id"); System.out.println("📬 Response with request_id: " + requestId); System.out.println("📬 Full response: " + response.toString(2)); + BlockingQueue queue = TelegramClient.pendingResponses.get(requestId); if (queue != null) { queue.put(response); diff --git a/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java b/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java index 32eff13..51bd3b7 100644 --- a/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java +++ b/src/main/java/org/to/telegramfinalproject/Client/TelegramClient.java @@ -128,10 +128,7 @@ package org.to.telegramfinalproject.Client; import org.json.JSONObject; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.io.PrintWriter; +import java.io.*; import java.net.Socket; import java.nio.charset.StandardCharsets; import java.util.Map; @@ -158,6 +155,14 @@ public class TelegramClient { public static final BlockingQueue responseQueue = new LinkedBlockingQueue<>(); public static final Map> pendingResponses = new ConcurrentHashMap<>(); public static UUID loggedInUserId = null; + private DataInputStream inBin; // NEW + private DataOutputStream outBin; // NEW + private static SocketMediaDownloader downloader; // NEW + public static final java.util.concurrent.atomic.AtomicBoolean mediaBusy = new java.util.concurrent.atomic.AtomicBoolean(false); // NEW + + public static SocketMediaDownloader getDownloader() { return downloader; } + public DataInputStream getInBin() { return inBin; } + public DataOutputStream getOutBin() { return outBin; } private volatile boolean listenerStarted = false; @@ -219,12 +224,26 @@ public class TelegramClient { socket = new Socket(SERVER_HOST, SERVER_PORT); in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); out = new PrintWriter(socket.getOutputStream(), true); + + InputStream rawIn = socket.getInputStream(); + OutputStream rawOut = socket.getOutputStream(); + + in = new BufferedReader(new InputStreamReader(rawIn, java.nio.charset.StandardCharsets.UTF_8)); + out = new PrintWriter(new OutputStreamWriter(rawOut, java.nio.charset.StandardCharsets.UTF_8), true); + + // NEW: binary streams + inBin = new DataInputStream(rawIn); + outBin = new DataOutputStream(rawOut); + + // NEW: media downloader helper + downloader = new SocketMediaDownloader(out, inBin, outBin); + System.out.println("✅ Connected to Telegram Server"); } private synchronized void initHandlerIfNeeded() { if (handler == null) { - handler = new ActionHandler(out, in, scanner); + handler = new ActionHandler(out, in, outBin, scanner); } } diff --git a/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java b/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java index a377ab4..5ec8ae4 100644 --- a/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java +++ b/src/main/java/org/to/telegramfinalproject/Database/MessageDatabase.java @@ -1,14 +1,13 @@ package org.to.telegramfinalproject.Database; import org.to.telegramfinalproject.Models.FileAttachment; +import org.to.telegramfinalproject.Models.MediaRow; import org.to.telegramfinalproject.Models.Message; +import org.to.telegramfinalproject.Utils.ChannelPermissionUtil; import java.sql.*; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; public class MessageDatabase { @@ -364,6 +363,209 @@ public class MessageDatabase { return s.substring(0, Math.max(0, maxLen - 1)).trim() + "…"; } + public static boolean saveMessageWithOptionalAttachments( + UUID messageId, UUID senderId, UUID receiverId, + String receiverType, String content, String messageType, + List attachments + ) { + Connection conn = null; + try { + conn = ConnectionDb.connect(); + conn.setAutoCommit(false); + + boolean isText = "TEXT".equalsIgnoreCase(messageType); + boolean isImage = "IMAGE".equalsIgnoreCase(messageType); + boolean isAudio = "AUDIO".equalsIgnoreCase(messageType); + if (!isText && !isImage && !isAudio) { + throw new IllegalArgumentException("messageType must be TEXT, IMAGE, or AUDIO"); + } + + if (isText) { + if (attachments != null && !attachments.isEmpty()) + throw new IllegalArgumentException("TEXT must not have attachments"); + if (content == null || content.isBlank()) + throw new IllegalArgumentException("TEXT must have non-empty content"); + } else { + if (attachments == null || attachments.isEmpty()) + throw new IllegalArgumentException("Non-TEXT must have at least one attachment"); + + for (FileAttachment a : attachments) { + if (a == null) throw new IllegalArgumentException("Attachment is null"); + String ft = a.getFileType(); + if (isImage && !"IMAGE".equalsIgnoreCase(ft)) + throw new IllegalArgumentException("All attachments must be IMAGE for messageType=IMAGE"); + if (isAudio && !"AUDIO".equalsIgnoreCase(ft)) + throw new IllegalArgumentException("All attachments must be AUDIO for messageType=AUDIO"); + } + } + + insertMessageTx(conn, messageId, senderId, receiverId, receiverType, content, messageType.toUpperCase()); + if (!isText) insertAttachmentsTx(conn, messageId, attachments); + + conn.commit(); + return true; + } catch (Exception e) { + if (conn != null) try { conn.rollback(); } catch (SQLException ignored) {} + e.printStackTrace(); + return false; + } finally { + if (conn != null) { + try { conn.setAutoCommit(true); } catch (SQLException ignored) {} + try { conn.close(); } catch (SQLException ignored) {} + } + } + } + + + public static boolean insertMessageTx(Connection conn, UUID messageId, UUID senderId, UUID receiverId, + String receiverType, String content, String messageType) throws SQLException { + String sql = "INSERT INTO messages (message_id, sender_id, receiver_type, receiver_id, content, message_type) " + + "VALUES (?, ?, ?, ?, ?, ?)"; + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setObject(1, messageId); + ps.setObject(2, senderId); + ps.setString(3, receiverType); + ps.setObject(4, receiverId); + if (content == null || content.isBlank()) ps.setNull(5, java.sql.Types.VARCHAR); else ps.setString(5, content); + ps.setString(6, messageType); + return ps.executeUpdate() > 0; + } + } + + public static boolean insertAttachmentsTx(Connection conn, UUID messageId, List attachments) throws SQLException { + if (attachments == null || attachments.isEmpty()) return true; + + final String sql = """ + INSERT INTO message_attachments( + attachment_id, message_id, + file_url, file_type, file_name, file_size, mime_type, + width, height, duration_seconds, thumbnail_url, + media_key, storage_path + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + """; + + try (PreparedStatement ps = conn.prepareStatement(sql)) { + for (FileAttachment att : attachments) { + if (att == null) throw new IllegalArgumentException("Attachment is null"); + UUID attachmentId = att.getAttachmentId() != null ? att.getAttachmentId() : UUID.randomUUID(); + UUID mediaKey = att.getMediaKey() != null ? att.getMediaKey() : attachmentId; // ساده‌ترین حالت + + String ft = att.getFileType(); + if (!"IMAGE".equalsIgnoreCase(ft) && !"AUDIO".equalsIgnoreCase(ft)) { + throw new IllegalArgumentException("file_type must be IMAGE or AUDIO"); + } + if (att.getStoragePath() == null || att.getStoragePath().isBlank()) { + throw new IllegalArgumentException("storage_path is required for socket downloads"); + } + + int i = 1; + ps.setObject(i++, attachmentId); + ps.setObject(i++, messageId); + //file url (display link) + if (att.getFileUrl() == null || att.getFileUrl().isBlank()) ps.setNull(i++, java.sql.Types.VARCHAR); + else ps.setString(i++, att.getFileUrl()); + + ps.setString(i++, ft.toUpperCase()); + ps.setString(i++, att.getFileName()); + if (att.getFileSize() == null) ps.setNull(i++, java.sql.Types.BIGINT); else ps.setLong(i++, att.getFileSize()); + if (att.getMimeType() == null) ps.setNull(i++, java.sql.Types.VARCHAR); else ps.setString(i++, att.getMimeType()); + if (att.getWidth() == null) ps.setNull(i++, java.sql.Types.INTEGER); else ps.setInt(i++, att.getWidth()); + if (att.getHeight() == null) ps.setNull(i++, java.sql.Types.INTEGER); else ps.setInt(i++, att.getHeight()); + if (att.getDurationSeconds() == null) ps.setNull(i++, java.sql.Types.INTEGER); else ps.setInt(i++, att.getDurationSeconds()); + if (att.getThumbnailUrl() == null || att.getThumbnailUrl().isBlank()) ps.setNull(i++, java.sql.Types.VARCHAR); + else ps.setString(i++, att.getThumbnailUrl()); + + ps.setObject(i++, mediaKey); + ps.setString(i++, att.getStoragePath()); + + ps.addBatch(); + + att.setAttachmentId(attachmentId); + att.setMediaKey(mediaKey); + } + ps.executeBatch(); + return true; + } + } + + public static MediaRow findMediaByKey(UUID mediaKey) throws SQLException { + String sql = """ + SELECT + ma.attachment_id, + ma.message_id, + ma.media_key, + ma.file_name, + ma.file_size, + ma.mime_type, + ma.file_type, + ma.width, + ma.height, + ma.duration_seconds, + ma.thumbnail_url, + ma.file_url, + ma.storage_path, + m.receiver_type, + m.receiver_id, + m.sender_id + FROM message_attachments ma + JOIN messages m ON m.message_id = ma.message_id + WHERE ma.media_key = ? + LIMIT 1 + """; + + try (Connection c = ConnectionDb.connect(); + PreparedStatement ps = c.prepareStatement(sql)) { + ps.setObject(1, mediaKey); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + + MediaRow a = new MediaRow(); + a.attachmentId = (UUID) rs.getObject("attachment_id"); + a.messageId = (UUID) rs.getObject("message_id"); + a.mediaKey = (UUID) rs.getObject("media_key"); + a.fileName = rs.getString("file_name"); + + long sz = rs.getLong("file_size"); + a.fileSize = rs.wasNull() ? null : sz; // MediaRow.fileSize = Long + + a.mimeType = rs.getString("mime_type"); + a.fileType = rs.getString("file_type"); + int w = rs.getInt("width"); a.width = rs.wasNull() ? null : w; + int h = rs.getInt("height"); a.height = rs.wasNull() ? null : h; + int d = rs.getInt("duration_seconds"); a.durationSeconds = rs.wasNull() ? null : d; + a.thumbnailUrl = rs.getString("thumbnail_url"); + a.fileUrl = rs.getString("file_url"); + a.storagePath = rs.getString("storage_path"); + a.receiverType = rs.getString("receiver_type"); + a.receiverId = (UUID) rs.getObject("receiver_id"); + a.senderId = (UUID) rs.getObject("sender_id"); + return a; + } + } + } + + public static boolean canAccess(UUID requester, MediaRow mr) { + if (requester == null || mr == null || mr.receiverType == null) return false; + + // اختیاری: فرستنده همیشه مجاز + if (requester.equals(mr.senderId)) return true; + + switch (mr.receiverType.toLowerCase(Locale.ROOT)) { + case "private": + // receiver_id در پیام‌های private = UUID چت خصوصی + return PrivateChatDatabase.isParticipant(mr.receiverId, requester); + + case "group": + return GroupDatabase.isMember(mr.receiverId, requester); + + case "channel": + return ChannelPermissionUtil.isUserInChannel(requester, mr.receiverId); + + default: + return false; + } + } + public void markMessageAsRead(UUID messageId, UUID userId) { diff --git a/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java b/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java index 10f6aa2..701dd2c 100644 --- a/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java +++ b/src/main/java/org/to/telegramfinalproject/Server/ClientHandler.java @@ -12,6 +12,9 @@ import org.to.telegramfinalproject.Utils.GroupPermissionUtil; import java.io.*; import java.net.Socket; import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; import java.time.LocalDateTime; import java.util.*; @@ -20,6 +23,15 @@ public class ClientHandler implements Runnable { private final AuthService authService = new AuthService(); private User currentUser; + // ClientHandler.java + private static void log(String msg) { + System.out.println(java.time.LocalDateTime.now() + " [ClientHandler] " + msg); + } + private static void logf(String fmt, Object... args) { + log(String.format(fmt, args)); + } + + public ClientHandler(Socket socket) { this.socket = socket; @@ -30,11 +42,73 @@ public class ClientHandler implements Runnable { UUID userId = null; try ( - BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); - PrintWriter out = new PrintWriter(socket.getOutputStream(), true) + +// InputStream rawIn = socket.getInputStream(); +// OutputStream rawOut = socket.getOutputStream(); +// +// BufferedReader in = new BufferedReader(new InputStreamReader(rawIn, java.nio.charset.StandardCharsets.UTF_8)); +// PrintWriter out = new PrintWriter(new OutputStreamWriter(rawOut, java.nio.charset.StandardCharsets.UTF_8), true); + +// DataInputStream dis = new DataInputStream(rawIn); +// DataOutputStream dos = new DataOutputStream(new BufferedOutputStream(rawOut)); + +// BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); +// PrintWriter out = new PrintWriter(socket.getOutputStream(), true) +// BufferedInputStream bis = new BufferedInputStream(socket.getInputStream()); + // DataInputStream dis = new DataInputStream(bis); //for binary headers + + //PrintWriter out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), java.nio.charset.StandardCharsets.UTF_8), true); + InputStream rawIn = socket.getInputStream(); + OutputStream rawOut = socket.getOutputStream(); + + BufferedInputStream bis = new BufferedInputStream(rawIn); + BufferedOutputStream bos = new BufferedOutputStream(rawOut); + + DataInputStream dis = new DataInputStream(bis); + DataOutputStream dos = new DataOutputStream(bos); + PrintWriter out = new PrintWriter(new OutputStreamWriter(bos, java.nio.charset.StandardCharsets.UTF_8), true); + ) { + +// DataInputStream bin = new DataInputStream(new BufferedInputStream(socket.getInputStream())); + String inputLine; - while ((inputLine = in.readLine()) != null) { + while ((inputLine = readUtf8Line(bis)) != null) { + + String line = inputLine.trim(); + + + if("AVATAR".equalsIgnoreCase(line)){ + + out.println(new JSONObject().put("status","ready").toString()); + out.flush(); + handleAvatarFrame(dis, out, this.currentUser.getInternal_uuid()); + continue; + + + } + + if ("MEDIA".equalsIgnoreCase(inputLine.trim())) { + handleMediaFrame(dis, out); + continue; + } + + + if ("MEDIA_DL".equalsIgnoreCase(line)) { + UUID cu = (currentUser == null ? null : currentUser.getInternal_uuid()); + logf("MEDIA_DL received. currentUser.internal_uuid=%s", cu); + + if (cu == null) { + log("MEDIA_DL rejected: currentUser is null or no internal_uuid"); + sendDlErr(dos, "not authorized"); + continue; + } + handleMediaDownload(dis, dos, cu); + continue; + } + + + JSONObject requestJson = new JSONObject(inputLine); String action = requestJson.getString("action"); ResponseModel response = null; @@ -3145,4 +3219,540 @@ public class ClientHandler implements Runnable { } + + private static final int MAGIC_AVATAR = 0x41565431; // "AVT1" + private static final long MAX_AVATAR = 3L * 1024 * 1024; + + private void handleAvatarFrame(DataInputStream dis, PrintWriter out, UUID currentUserId) { + try { + int magic = dis.readInt(); + if (magic != MAGIC_AVATAR) { out.println(err("bad magic")); out.flush(); return; } + + int headerLen = dis.readInt(); + if (headerLen <= 0 || headerLen > 64*1024) { out.println(err("bad header length")); out.flush(); return; } + + byte[] headerBytes = dis.readNBytes(headerLen); + if (headerBytes.length != headerLen) { out.println(err("header truncated")); out.flush(); return; } + + JSONObject h = new JSONObject(new String(headerBytes, java.nio.charset.StandardCharsets.UTF_8)); + + long contentLen = dis.readLong(); + if (contentLen <= 0 || contentLen > MAX_AVATAR) { + skip(dis, contentLen); + out.println(err("file too large/invalid")); out.flush(); return; + } + + String targetType = h.optString("target_type", "user").toLowerCase(); + UUID targetId; + if ("user".equals(targetType)) { + String s = h.optString("target_id", ""); + targetId = s.isEmpty() ? currentUserId : UUID.fromString(s); + if (!targetId.equals(currentUserId)) { + skip(dis, contentLen); + out.println(err("forbidden")); out.flush(); return; + } + } else { + String s = h.optString("target_id", ""); + if (s.isEmpty()) { skip(dis, contentLen); out.println(err("missing target_id")); out.flush(); return; } + targetId = UUID.fromString(s); + if (!hasManagePermission(currentUserId, targetType, targetId)) { + skip(dis, contentLen); + out.println(err("forbidden")); out.flush(); return; + } + } + + String fileName = h.optString("file_name", "avatar.bin"); + String mimeType = h.optString("mime_type", "application/octet-stream"); + if (!isAllowedImageMime(mimeType)) { skip(dis, contentLen); out.println(err("unsupported mime")); out.flush(); return; } + if (fileName.length() > 200) fileName = fileName.substring(0,200); + + java.nio.file.Path baseDir = java.nio.file.Paths.get("uploads").toAbsolutePath().normalize(); + String subdir = "avatars/" + java.time.LocalDate.now(); + java.nio.file.Path dir = baseDir.resolve(subdir).normalize(); + java.nio.file.Files.createDirectories(dir); + + String ext = guessExt_Profile(fileName, mimeType); // ⬅️ این یکی را صدا بزن + String storedName = java.util.UUID.randomUUID() + ext; + java.nio.file.Path targetPath = dir.resolve(storedName).normalize(); + + try (OutputStream fos = new BufferedOutputStream(java.nio.file.Files.newOutputStream( + targetPath, java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.TRUNCATE_EXISTING))) { + long remaining = contentLen; + byte[] buf = new byte[8192]; + while (remaining > 0) { + int toRead = (int)Math.min(buf.length, remaining); + int n = dis.read(buf, 0, toRead); + if (n == -1) throw new EOFException("stream ended early"); + fos.write(buf, 0, n); + remaining -= n; + } + } + String requestId = h.optString("message_id", null); // ⬅️ اضافه + + long fileSize = java.nio.file.Files.size(targetPath); + String fileUrl = "/" + subdir.replace('\\','/') + "/" + storedName; // /avatars/YYYY-MM-DD/uuid.jpg + + boolean ok = updateProfileImageUrl(targetType, targetId, fileUrl); + + JSONObject ack = new JSONObject() + .put("status", ok ? "success" : "error") + .put("message_id", requestId == null ? JSONObject.NULL : requestId) // ⬅️ اضافه + .put("target_type", targetType) + .put("target_id", targetId.toString()) + .put("file_name", fileName) + .put("mime_type", mimeType) + .put("file_size", fileSize) + .put("display_url", fileUrl); + + out.println(ack.toString()); + out.flush(); + + if (ok) { + // (اختیاری) Broadcast به اعضای مرتبط + // broadcastProfileChange(targetType, targetId, fileUrl); + } + + } catch (Exception e) { + e.printStackTrace(); + out.println(err("exception")); + out.flush(); + } + } + + private static String err(String m) { return new JSONObject().put("status","error").put("message",m).toString(); } + + private static boolean isAllowedImageMime(String mime) { + if (mime == null) return false; + return mime.equals("image/jpeg") || mime.equals("image/png") || mime.equals("image/webp"); + } + + private static String guessExt_Profile(String fileName, String mime) { + String lower = fileName.toLowerCase(); + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return ".jpg"; + if (lower.endsWith(".png")) return ".png"; + if (lower.endsWith(".webp")) return ".webp"; + return switch (mime) { + case "image/jpeg" -> ".jpg"; + case "image/png" -> ".png"; + case "image/webp" -> ".webp"; + default -> ".bin"; + }; + } + + + + private boolean updateProfileImageUrl(String targetType, UUID targetId, String url) { + String sql = switch (targetType) { + case "user" -> "UPDATE users SET image_url=? WHERE internal_uuid=?"; + case "channel" -> "UPDATE channels SET image_url=? WHERE id=?"; + case "group" -> "UPDATE groups SET image_url=? WHERE id=?"; + default -> null; + }; + if (sql == null) return false; + try (Connection c = ConnectionDb.connect(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, url); + ps.setObject(2, targetId); + return ps.executeUpdate() > 0; + } catch (SQLException e) { e.printStackTrace(); return false; } + } + + private void handleMediaFrame(DataInputStream dis, PrintWriter out) { + try { + final int MAGIC_EXPECTED = 0x4D444D31; // "MDM1" + int magic = dis.readInt(); + if (magic != MAGIC_EXPECTED) { + out.println(new JSONObject().put("status","error").put("message","bad magic").toString()); out.flush(); return; + } + + int headerLen = dis.readInt(); + if (headerLen <= 0 || headerLen > 64 * 1024) { + out.println(new JSONObject().put("status","error").put("message","bad header length").toString()); out.flush(); return; + } + + byte[] headerBytes = dis.readNBytes(headerLen); + if (headerBytes.length != headerLen) { + out.println(new JSONObject().put("status","error").put("message","header truncated").toString()); out.flush(); return; + } + + JSONObject h = new JSONObject(new String(headerBytes, java.nio.charset.StandardCharsets.UTF_8)); + + long contentLen = dis.readLong(); + long MAX_MEDIA = 25L * 1024 * 1024; + if (contentLen <= 0 || contentLen > MAX_MEDIA) { + skip(dis, contentLen); + out.println(new JSONObject().put("status","error").put("message","file too large/invalid").toString()); out.flush(); return; + } + + UUID messageId = UUID.fromString(h.getString("message_id")); + UUID senderId = UUID.fromString(h.getString("sender_id")); + String rType = h.getString("receiver_type"); // private/group/channel + UUID receiverId = UUID.fromString(h.getString("receiver_id")); + String messageType = h.getString("message_type").toUpperCase(); // IMAGE | AUDIO + + if (!"IMAGE".equals(messageType) && !"AUDIO".equals(messageType)) { + skip(dis, contentLen); + out.println(new JSONObject().put("status","error").put("message","unsupported message_type").toString()); out.flush(); return; + } + + String fileName = h.optString("file_name", "file.bin"); + String mimeType = h.optString("mime_type", "application/octet-stream"); + String text = h.optString("text", ""); // کپشن اختیاری + + Integer width = h.has("width") && !h.isNull("width") ? h.getInt("width") : null; + Integer height = h.has("height") && !h.isNull("height") ? h.getInt("height") : null; + + if (fileName.length() > 200) fileName = fileName.substring(0, 200); + + java.nio.file.Path baseDir = java.nio.file.Paths.get("uploads").toAbsolutePath().normalize(); + java.nio.file.Files.createDirectories(baseDir); + String kind = "IMAGE".equals(messageType) ? "images" : "audios"; + String subdir = kind + "/" + java.time.LocalDate.now(); + java.nio.file.Path dir = baseDir.resolve(subdir).normalize(); + java.nio.file.Files.createDirectories(dir); + + String ext = guessExt(fileName, mimeType); + String storedName = java.util.UUID.randomUUID() + ext; + java.nio.file.Path target = dir.resolve(storedName).normalize(); + + try (OutputStream fos = new BufferedOutputStream(java.nio.file.Files.newOutputStream( + target, java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.TRUNCATE_EXISTING))) { + long remaining = contentLen; + byte[] buf = new byte[8192]; + while (remaining > 0) { + int toRead = (int) Math.min(buf.length, remaining); + int n = dis.read(buf, 0, toRead); + if (n == -1) throw new EOFException("stream ended early"); + fos.write(buf, 0, n); + remaining -= n; + } + } + + long fileSize = java.nio.file.Files.size(target); + + String storagePath = target.toString(); + String fileUrl = "/" + subdir.replace('\\','/') + "/" + storedName; + String mt = messageType; // "IMAGE" یا "AUDIO" + int safeWidth = ("IMAGE".equals(mt) && width != null) ? width : 0; + int safeHeight = ("IMAGE".equals(mt) && height != null) ? height : 0; + FileAttachment att = new FileAttachment(); + att.setFileUrl(fileUrl); + att.setFileType(messageType); // IMAGE/AUDIO + att.setFileName(fileName); + att.setFileSize(fileSize); + att.setMimeType(mimeType); + att.setWidth(safeWidth); + att.setHeight(safeHeight); + att.setDurationSeconds(0); + att.setThumbnailUrl(null); + att.setStoragePath(storagePath); + java.util.List atts = java.util.List.of(att); + + boolean ok = MessageDatabase.saveMessageWithOptionalAttachments( + messageId, senderId, receiverId, rType, text, messageType, atts + ); + + UUID mediaKey = null; + try (PreparedStatement q = ConnectionDb.connect().prepareStatement( + "SELECT media_key FROM message_attachments WHERE message_id = ? AND storage_path = ? LIMIT 1" + )) { + q.setObject(1, messageId); + q.setString(2, storagePath); + try (ResultSet rs = q.executeQuery()) { + if (rs.next()) mediaKey = (UUID) rs.getObject(1); + } + } catch (SQLException sqle) { + sqle.printStackTrace(); + } + + JSONObject ack = new JSONObject() + .put("status", ok ? "success" : "error") + .put("message_id", messageId.toString()) + .put("media_key", mediaKey != null ? mediaKey.toString() : JSONObject.NULL) + .put("file_name", fileName) + .put("file_size", fileSize) + .put("mime_type", mimeType) + .put("display_path", fileUrl); + + out.println(ack.toString()); + out.flush(); + + + // بعد از out.flush(); و فقط اگر ok==true + if (ok) { + try { + // 1) دریافت پیام از DB تا send_at و... دقیق باشد + Message m = MessageDatabase.findById(messageId); // اگر چنین متدی نداری، با پارامترهای همین متد بساز/پر کن + + // 2) لیست دریافت‌کنندگان بر اساس نوع چت + List receivers = getReceiversForChat(receiverId, rType.toLowerCase()); + + + // 3) ساخت payload شامل اتچمنت (media) + User sender = userDatabase.findByInternalUUID(senderId); + JSONObject payload = new JSONObject() + .put("action", "new_message") + .put("data", new JSONObject() + .put("id", m.getMessage_id().toString()) + .put("chat_id", receiverId.toString()) + .put("chat_type", rType.toLowerCase()) + .put("sender_id", senderId.toString()) + .put("sender_name", sender != null ? sender.getProfile_name() : JSONObject.NULL) + .put("message_type", messageType.toLowerCase()) + .put("text", (text == null || text.isEmpty()) ? JSONObject.NULL : text) + .put("media", new JSONObject() + .put("media_id", mediaKey != null ? mediaKey.toString() : JSONObject.NULL) + .put("file_name", fileName) + .put("mime_type", mimeType) + .put("size_bytes", fileSize) + .put("url", fileUrl) + .put("thumbnail_url", JSONObject.NULL) + .put("width", safeWidth) + .put("height", safeHeight) + .put("duration_ms", 0) + ) + .put("send_at", m.getSend_at().toString()) + .put("status", "SENT") + ); + + // 4) ارسال به همه اعضا (از جمله خودِ فرستنده اگر می‌خواهی UI آن هم یکپارچه آپدیت شود) + for (UUID uid : receivers) { + RealTimeEventDispatcher.sendToUser(uid, payload); + } + + // (اختیاری) رویداد آپدیت چت‌لیست برای sort بر اساس آخرین پیام + RealTimeEventDispatcher.notifyChatUpdated(receiverId, rType, m); + + } catch (Exception ex) { + ex.printStackTrace(); + // اگر ذخیره شد ولی Broadcast شکست خورد، می‌توانی Log کنی یا Retry سبک انجام دهی + } + } + + + } catch (Exception e) { + e.printStackTrace(); + out.println(new JSONObject().put("status","error").put("message","exception").toString()); + out.flush(); + } + } + + private static final int MAGIC_DL = 0x4D444D32; // "MDM2" + +// private void handleMediaDownload(DataInputStream inBin, DataOutputStream outBin, UUID requesterId) { +// try { +// int magic = inBin.readInt(); +// if (magic != MAGIC_DL) { sendDlErr(outBin, "bad magic"); return; } +// +// int hlen = inBin.readInt(); +// if (hlen <= 0 || hlen > 64 * 1024) { sendDlErr(outBin, "bad header length"); return; } +// +// byte[] hb = inBin.readNBytes(hlen); +// if (hb.length != hlen) { sendDlErr(outBin, "header truncated"); return; } +// +// JSONObject hdr = new JSONObject(new String(hb, java.nio.charset.StandardCharsets.UTF_8)); +// if (!"download".equalsIgnoreCase(hdr.optString("op"))) { sendDlErr(outBin, "bad op"); return; } +// +// UUID mediaKey = UUID.fromString(hdr.getString("media_key")); +// long offset = Math.max(0L, hdr.optLong("offset", 0L)); +// +// MediaRow mr = MessageDatabase.findMediaByKey(mediaKey); +// if (mr == null) { sendDlErr(outBin, "not found"); return; } +// if (!MessageDatabase.canAccess(requesterId, mr)) { sendDlErr(outBin, "not authorized"); return; } +// +// java.nio.file.Path path = java.nio.file.Paths.get(mr.storagePath).normalize(); +// long size = java.nio.file.Files.size(path); +// if (offset > size) offset = 0L; +// +// JSONObject ok = new JSONObject() +// .put("status","success") +// .put("media_key", mediaKey.toString()) +// .put("file_name", mr.fileName) +// .put("mime_type", mr.mimeType) +// .put("file_size", size); +// +// byte[] okb = ok.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); +// +// outBin.writeInt(MAGIC_DL); +// outBin.writeInt(okb.length); +// outBin.write(okb); +// outBin.writeLong(size - offset); +// +// try (java.io.InputStream fis = new java.io.BufferedInputStream(java.nio.file.Files.newInputStream(path))) { +// if (offset > 0) fis.skipNBytes(offset); +// byte[] buf = new byte[8192]; +// long remain = size - offset; +// while (remain > 0) { +// int n = fis.read(buf, 0, (int) Math.min(buf.length, remain)); +// if (n == -1) break; +// outBin.write(buf, 0, n); +// remain -= n; +// } +// } +// outBin.flush(); +// +// } catch (Exception e) { +// e.printStackTrace(); +// try { sendDlErr(outBin, "exception"); } catch (Exception ignored) {} +// } +// } + + private void handleMediaDownload(DataInputStream inBin, DataOutputStream outBin, UUID requesterId) { + try { + logf("MEDIA_DL start. requester=%s", requesterId); + + int magic = inBin.readInt(); + if (magic != MAGIC_DL) { sendDlErr(outBin, "bad magic"); return; } + + int hlen = inBin.readInt(); + if (hlen <= 0 || hlen > 64 * 1024) { sendDlErr(outBin, "bad header length"); return; } + + byte[] hb = inBin.readNBytes(hlen); + if (hb.length != hlen) { sendDlErr(outBin, "header truncated"); return; } + + String hdrStr = new String(hb, java.nio.charset.StandardCharsets.UTF_8); + logf("MEDIA_DL header: %s", hdrStr); + + JSONObject hdr = new JSONObject(hdrStr); + if (!"download".equalsIgnoreCase(hdr.optString("op"))) { sendDlErr(outBin, "bad op"); return; } + + UUID mediaKey = UUID.fromString(hdr.getString("media_key")); + long offset = Math.max(0L, hdr.optLong("offset", 0L)); + logf("Parsed mediaKey=%s offset=%d", mediaKey, offset); + + MediaRow mr = MessageDatabase.findMediaByKey(mediaKey); + if (mr == null) { sendDlErr(outBin, "not found"); return; } + + logf("MediaRow: chatType=%s chatId=%s sender=%s receiver=%s storage=%s", + mr.chatType, mr.chatId, mr.senderId, mr.receiverId, mr.storagePath); + + try (java.sql.Connection c = ConnectionDb.connect(); + java.sql.PreparedStatement st = c.prepareStatement( + "SELECT 1 FROM channel_subscribers WHERE channel_id = ? AND user_id = ? LIMIT 1")) { + st.setObject(1, mr.chatId, java.sql.Types.OTHER); + st.setObject(2, requesterId, java.sql.Types.OTHER); + boolean direct; + try (java.sql.ResultSet r = st.executeQuery()) { direct = r.next(); } + logf("[DL] direct channel membership ch=%s user=%s => %s", mr.chatId, requesterId, direct); + } catch (Exception e) { + logf("[DL] direct membership check ERROR: %s", e.toString()); + } + + boolean allowed = MessageDatabase.canAccess(requesterId, mr); + logf("canAccess(..) -> %s", allowed); + if (!allowed) { sendDlErr(outBin, "not authorized"); return; } + + java.nio.file.Path path = java.nio.file.Paths.get(mr.storagePath).normalize(); + long size = java.nio.file.Files.size(path); + if (offset > size) offset = 0L; + + JSONObject ok = new JSONObject() + .put("status","success") + .put("media_key", mediaKey.toString()) + .put("file_name", mr.fileName) + .put("mime_type", mr.mimeType) + .put("file_size", size); + + byte[] okb = ok.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + + outBin.writeInt(MAGIC_DL); + outBin.writeInt(okb.length); + outBin.write(okb); + outBin.writeLong(size - offset); + logf("Sending OK header. file=%s size=%d offset=%d", mr.fileName, size, offset); + + try (java.io.InputStream fis = new java.io.BufferedInputStream(java.nio.file.Files.newInputStream(path))) { + if (offset > 0) fis.skipNBytes(offset); + byte[] buf = new byte[8192]; + long remain = size - offset; + while (remain > 0) { + int n = fis.read(buf, 0, (int) Math.min(buf.length, remain)); + if (n == -1) break; + outBin.write(buf, 0, n); + remain -= n; + } + } + outBin.flush(); + log("MEDIA_DL done."); + + } catch (Exception e) { + e.printStackTrace(); + try { sendDlErr(outBin, "exception"); } catch (Exception ignored) {} + } + } + + private void sendDlErr(DataOutputStream outBin, String msg) throws java.io.IOException { + JSONObject j = new JSONObject().put("status","error").put("message", msg); + byte[] b = j.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); + outBin.writeInt(MAGIC_DL); + outBin.writeInt(b.length); + outBin.write(b); + outBin.writeLong(0L); + outBin.flush(); + } + + private static void skip(DataInputStream dis, long n) throws IOException { + if (n <= 0) return; + byte[] buf = new byte[8192]; + long left = n; + while (left > 0) { + int toRead = (int) Math.min(buf.length, left); + int r = dis.read(buf, 0, toRead); + if (r == -1) break; // EOF + left -= r; + } + } + + private static String guessExt(String original, String mime) { + if (original != null && original.contains(".")) { + String ext = original.substring(original.lastIndexOf('.')); + if (ext.length() <= 10) return ext.toLowerCase(); + } + if (mime == null) return ""; + + String m = mime.toLowerCase(); + + if (m.equals("image/png")) return ".png"; + if (m.equals("image/jpeg") || m.equals("image/jpg")) return ".jpg"; + if (m.equals("image/gif")) return ".gif"; + if (m.equals("image/webp")) return ".webp"; + + if (m.equals("audio/mpeg") || m.equals("audio/mp3")) return ".mp3"; + if (m.equals("audio/ogg")) return ".ogg"; + if (m.equals("audio/opus")) return ".opus"; + if (m.equals("audio/wav") || m.equals("audio/x-wav")) return ".wav"; + if (m.equals("audio/m4a") || m.equals("audio/mp4")) return ".m4a"; + +// if (m.equals("video/mp4")) return ".mp4"; +// if (m.equals("video/webm")) return ".webm"; + + // fallback + if (m.startsWith("image/")) return ""; + if (m.startsWith("audio/")) return ""; + if (m.startsWith("video/")) return ""; + + return ""; + } + + + private static String readUtf8Line(BufferedInputStream bis) throws java.io.IOException { + StringBuilder sb = new StringBuilder(); + while (true) { + int b = bis.read(); + if (b == -1) { + return sb.length() == 0 ? null : sb.toString(); + } + if (b == '\n') { + int len = sb.length(); + if (len > 0 && sb.charAt(len - 1) == '\r') sb.setLength(len - 1); + return sb.toString(); + } + sb.append((char) b); + } + } + + + + + + } \ No newline at end of file