diff --git a/SHiNE-server/src/main/java/server/files/DmFileApiConfigurator.java b/SHiNE-server/src/main/java/server/files/DmFileApiConfigurator.java new file mode 100644 index 00000000..7b2f1ec3 --- /dev/null +++ b/SHiNE-server/src/main/java/server/files/DmFileApiConfigurator.java @@ -0,0 +1,14 @@ +package server.files; + +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; + +/** Регистрирует HTTP API content-addressed хранилища DM-файлов. */ +public final class DmFileApiConfigurator { + + private DmFileApiConfigurator() {} + + public static void register(ServletContextHandler context) { + context.addServlet(new ServletHolder(new DmFileServlet()), "/dm-files/*"); + } +} diff --git a/SHiNE-server/src/main/java/server/files/DmFileServlet.java b/SHiNE-server/src/main/java/server/files/DmFileServlet.java new file mode 100644 index 00000000..3c5f2ea4 --- /dev/null +++ b/SHiNE-server/src/main/java/server/files/DmFileServlet.java @@ -0,0 +1,370 @@ +package server.files; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import server.logic.ws_protocol.Base64Ws; +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; +import shine.db.dao.ActiveSessionsDAO; +import shine.db.entities.ActiveSessionEntry; +import utils.config.AppConfig; +import utils.crypto.Ed25519Util; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.sql.SQLException; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * HTTP-хранилище зашифрованных файлов из личных сообщений. + * + * Сервер намеренно ничего не знает об исходном имени, MIME и ключе AES-GCM: + * он получает только ciphertext и хранит его под content-addressed именем + * Base58(SHA-256(ciphertext)). Ключ файла передаётся внутри E2EE DM. + */ +public final class DmFileServlet extends HttpServlet { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + private static final Pattern FILE_ID_PATTERN = Pattern.compile("^[1-9A-HJ-NP-Za-km-z]{32,44}$"); + + private static final String HEADER_SESSION_ID = "X-Shine-Session-Id"; + private static final String HEADER_TIME_MS = "X-Shine-Time-Ms"; + private static final String HEADER_CONTENT_LENGTH = "X-Shine-Content-Length"; + private static final String HEADER_SIGNATURE = "X-Shine-Signature"; + + private static final long DEFAULT_MAX_BYTES = 50L * 1024L * 1024L + 16L; + private static final long MAX_AUTH_SKEW_MS = 60_000L; + + private final boolean enabled; + private final Path storageDir; + private final long maxBytes; + + public DmFileServlet() { + AppConfig config = AppConfig.getInstance(); + this.enabled = readBoolean(config, "dm.files.enabled", true); + this.storageDir = Path.of(readString(config, "dm.files.storageDir", "data/dm-files")) + .toAbsolutePath() + .normalize(); + this.maxBytes = Math.max(16L, readLong(config, "dm.files.maxBytes", DEFAULT_MAX_BYTES)); + } + + @Override + protected void doOptions(HttpServletRequest req, HttpServletResponse resp) { + applyCors(resp); + resp.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + @Override + protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws IOException { + applyCors(resp); + if (!enabled) { + writeError(resp, HttpServletResponse.SC_NOT_FOUND, "FILES_DISABLED", "Передача файлов отключена на сервере"); + return; + } + + String fileId = extractFileId(req); + if (fileId == null) { + writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "BAD_FILE_ID", "Некорректный идентификатор файла"); + return; + } + + UploadAuth auth; + try { + auth = readAndVerifyUploadAuth(req, fileId); + } catch (AuthFailure failure) { + writeError(resp, failure.httpStatus, failure.code, failure.getMessage()); + return; + } + + if (auth.declaredLength > maxBytes) { + writeError(resp, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "FILE_TOO_LARGE", "Файл превышает лимит сервера"); + return; + } + + Files.createDirectories(storageDir); + Path target = safeTarget(fileId); + if (target == null) { + writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "BAD_FILE_ID", "Некорректный идентификатор файла"); + return; + } + + // Content-addressed объект неизменяем. Повторная корректно подписанная загрузка того же + // ciphertext безопасна и просто возвращает существующий объект. + if (Files.isRegularFile(target)) { + if (Files.size(target) != auth.declaredLength) { + writeError(resp, HttpServletResponse.SC_CONFLICT, "FILE_ID_CONFLICT", "Файл с таким hash уже существует с другим размером"); + return; + } + writeUploadOk(resp, fileId, auth.declaredLength, true); + return; + } + + Path temp = Files.createTempFile(storageDir, ".dm-upload-", ".tmp"); + boolean keepTemp = false; + try { + MessageDigest digest = sha256Digest(); + long actualLength = 0L; + byte[] buffer = new byte[64 * 1024]; + try (InputStream in = req.getInputStream(); OutputStream out = Files.newOutputStream(temp)) { + int read; + while ((read = in.read(buffer)) >= 0) { + if (read == 0) continue; + actualLength += read; + if (actualLength > maxBytes || actualLength > auth.declaredLength) { + writeError(resp, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "FILE_TOO_LARGE", "Получено больше байтов, чем разрешено"); + return; + } + digest.update(buffer, 0, read); + out.write(buffer, 0, read); + } + } + + if (actualLength != auth.declaredLength) { + writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "LENGTH_MISMATCH", "Размер загруженного файла не совпал с подписанным размером"); + return; + } + + String actualId = toBase58(digest.digest()); + if (!Objects.equals(fileId, actualId)) { + writeError(resp, HttpServletResponse.SC_BAD_REQUEST, "HASH_MISMATCH", "SHA-256 загруженного ciphertext не совпал с адресом файла"); + return; + } + + boolean alreadyExists = false; + try { + try { + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temp, target); + } + keepTemp = true; // temp уже перемещён, удалять нечего + } catch (FileAlreadyExistsException race) { + alreadyExists = true; + } + + writeUploadOk(resp, fileId, actualLength, alreadyExists); + } finally { + if (!keepTemp) { + Files.deleteIfExists(temp); + } + } + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + serveFile(req, resp, false); + } + + @Override + protected void doHead(HttpServletRequest req, HttpServletResponse resp) throws IOException { + serveFile(req, resp, true); + } + + private void serveFile(HttpServletRequest req, HttpServletResponse resp, boolean headOnly) throws IOException { + applyCors(resp); + if (!enabled) { + writeError(resp, HttpServletResponse.SC_NOT_FOUND, "FILES_DISABLED", "Передача файлов отключена на сервере"); + return; + } + + String fileId = extractFileId(req); + Path target = fileId == null ? null : safeTarget(fileId); + if (target == null || !Files.isRegularFile(target)) { + writeError(resp, HttpServletResponse.SC_NOT_FOUND, "FILE_NOT_FOUND", "Файл не найден"); + return; + } + + long size = Files.size(target); + resp.setStatus(HttpServletResponse.SC_OK); + resp.setContentType("application/octet-stream"); + resp.setHeader("Content-Length", Long.toString(size)); + resp.setHeader("ETag", "\"" + fileId + "\""); + resp.setHeader("Cache-Control", "no-store"); + resp.setHeader("X-Content-Type-Options", "nosniff"); + resp.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + if (!headOnly) { + try (InputStream in = Files.newInputStream(target); OutputStream out = resp.getOutputStream()) { + in.transferTo(out); + } + } + } + + private UploadAuth readAndVerifyUploadAuth(HttpServletRequest req, String fileId) throws AuthFailure { + String sessionId = requiredHeader(req, HEADER_SESSION_ID); + String timeRaw = requiredHeader(req, HEADER_TIME_MS); + String lengthRaw = requiredHeader(req, HEADER_CONTENT_LENGTH); + String signatureRaw = requiredHeader(req, HEADER_SIGNATURE); + + long timeMs; + long declaredLength; + try { + timeMs = Long.parseLong(timeRaw); + declaredLength = Long.parseLong(lengthRaw); + } catch (NumberFormatException badNumber) { + throw new AuthFailure(HttpServletResponse.SC_BAD_REQUEST, "BAD_UPLOAD_HEADERS", "Некорректные числовые заголовки загрузки"); + } + if (declaredLength < 16L) { + throw new AuthFailure(HttpServletResponse.SC_BAD_REQUEST, "BAD_UPLOAD_LENGTH", "Зашифрованный файл слишком мал"); + } + long nowMs = System.currentTimeMillis(); + if (timeMs < nowMs - MAX_AUTH_SKEW_MS || timeMs > nowMs + MAX_AUTH_SKEW_MS) { + throw new AuthFailure(HttpServletResponse.SC_UNAUTHORIZED, "UPLOAD_AUTH_EXPIRED", "Подпись загрузки устарела"); + } + + ActiveSessionEntry session; + try { + session = ActiveSessionsDAO.getInstance().getBySessionId(sessionId); + } catch (SQLException dbError) { + throw new AuthFailure(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "SESSION_LOOKUP_FAILED", "Не удалось проверить пользовательскую сессию"); + } + if (session == null || session.getSessionKey() == null || session.getSessionKey().isBlank()) { + throw new AuthFailure(HttpServletResponse.SC_UNAUTHORIZED, "SESSION_NOT_FOUND", "Активная сессия не найдена"); + } + + byte[] signature; + byte[] publicKey; + try { + signature = Base64Ws.decodeLen(signatureRaw, Ed25519Util.SIGNATURE_LEN, "signature"); + publicKey = AuthKeyUtils.parseEd25519PublicKey(session.getSessionKey(), "sessionKey"); + } catch (RuntimeException badKey) { + throw new AuthFailure(HttpServletResponse.SC_UNAUTHORIZED, "BAD_UPLOAD_SIGNATURE", "Некорректная подпись загрузки"); + } + + String preimage = uploadPreimage(sessionId, fileId, declaredLength, timeMs); + if (!Ed25519Util.verify(preimage.getBytes(StandardCharsets.UTF_8), signature, publicKey)) { + throw new AuthFailure(HttpServletResponse.SC_UNAUTHORIZED, "BAD_UPLOAD_SIGNATURE", "Подпись загрузки не прошла проверку"); + } + return new UploadAuth(declaredLength); + } + + private static String uploadPreimage(String sessionId, String fileId, long encryptedSize, long timeMs) { + return "DM_FILE_UPLOAD_V1:" + sessionId + ':' + fileId + ':' + encryptedSize + ':' + timeMs; + } + + private String extractFileId(HttpServletRequest req) { + String pathInfo = String.valueOf(req.getPathInfo() == null ? "" : req.getPathInfo()).trim(); + if (!pathInfo.startsWith("/") || pathInfo.indexOf('/', 1) >= 0) return null; + String fileId = pathInfo.substring(1); + return FILE_ID_PATTERN.matcher(fileId).matches() ? fileId : null; + } + + private Path safeTarget(String fileId) { + if (fileId == null || !FILE_ID_PATTERN.matcher(fileId).matches()) return null; + Path target = storageDir.resolve(fileId).normalize(); + return target.getParent() != null && target.getParent().equals(storageDir) ? target : null; + } + + private static String requiredHeader(HttpServletRequest req, String name) throws AuthFailure { + String value = req.getHeader(name); + if (value == null || value.isBlank()) { + throw new AuthFailure(HttpServletResponse.SC_UNAUTHORIZED, "MISSING_UPLOAD_AUTH", "Не хватает заголовка " + name); + } + return value.trim(); + } + + private static MessageDigest sha256Digest() throws IOException { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException impossible) { + throw new IOException("SHA-256 недоступен", impossible); + } + } + + static String toBase58(byte[] bytes) { + if (bytes == null || bytes.length == 0) return ""; + BigInteger value = new BigInteger(1, bytes); + StringBuilder result = new StringBuilder(); + BigInteger base = BigInteger.valueOf(58L); + while (value.signum() > 0) { + BigInteger[] divRem = value.divideAndRemainder(base); + result.append(BASE58_ALPHABET.charAt(divRem[1].intValue())); + value = divRem[0]; + } + for (byte b : bytes) { + if (b != 0) break; + result.append('1'); + } + return result.reverse().toString(); + } + + private static boolean readBoolean(AppConfig config, String key, boolean fallback) { + String value = config.getParam(key); + if (value == null || value.isBlank()) return fallback; + return Boolean.parseBoolean(value.trim()); + } + + private static long readLong(AppConfig config, String key, long fallback) { + String value = config.getParam(key); + if (value == null || value.isBlank()) return fallback; + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException ignored) { + return fallback; + } + } + + private static String readString(AppConfig config, String key, String fallback) { + String value = config.getParam(key); + return value == null || value.isBlank() ? fallback : value.trim(); + } + + private static void applyCors(HttpServletResponse resp) { + resp.setHeader("Access-Control-Allow-Origin", "*"); + resp.setHeader("Access-Control-Allow-Methods", "GET, HEAD, PUT, OPTIONS"); + resp.setHeader("Access-Control-Allow-Headers", + "Content-Type, X-Shine-Session-Id, X-Shine-Time-Ms, X-Shine-Content-Length, X-Shine-Signature"); + resp.setHeader("Access-Control-Expose-Headers", "Content-Length, ETag"); + resp.setHeader("Vary", "Origin"); + } + + private static void writeUploadOk(HttpServletResponse resp, String fileId, long size, boolean alreadyExists) throws IOException { + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("ok", true); + payload.put("fileId", fileId); + payload.put("size", size); + payload.put("alreadyExists", alreadyExists); + writeJson(resp, HttpServletResponse.SC_OK, payload); + } + + private static void writeError(HttpServletResponse resp, int status, String code, String message) throws IOException { + ObjectNode payload = MAPPER.createObjectNode(); + payload.put("ok", false); + payload.put("error", code); + payload.put("message", message); + writeJson(resp, status, payload); + } + + private static void writeJson(HttpServletResponse resp, int status, ObjectNode payload) throws IOException { + resp.setStatus(status); + resp.setCharacterEncoding(StandardCharsets.UTF_8.name()); + resp.setContentType("application/json;charset=UTF-8"); + MAPPER.writeValue(resp.getOutputStream(), payload); + } + + private record UploadAuth(long declaredLength) {} + + private static final class AuthFailure extends Exception { + private final int httpStatus; + private final String code; + + private AuthFailure(int httpStatus, String code, String message) { + super(message); + this.httpStatus = httpStatus; + this.code = code; + } + } +} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index b6186c8f..87f30e7d 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -6,6 +6,7 @@ import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerI import org.slf4j.Logger; import org.slf4j.LoggerFactory; import server.debug.DebugApiConfigurator; +import server.files.DmFileApiConfigurator; import server.sync.BlockchainResyncRecoveryOnStartup; import server.sync.PeriodicBlockchainSyncService; import server.sync.PeriodicDmDeliveryService; @@ -93,6 +94,9 @@ public final class WsServer { // HTTP debug API DebugApiConfigurator.register(context); + // Зашифрованные вложения личных сообщений: PUT/GET /dm-files/{sha256-base58} + DmFileApiConfigurator.register(context); + // Инициализация контейнера WebSocket JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> { // Таймаут простоя соединения (Jetty 11 синтаксис) diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 6a0b2c8b..6e5fc2ba 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -53,6 +53,16 @@ server.ui.indexPath=/home/player/SHiNE/shine-UI/index.html server.ui.buildHash= # Web Push (VAPID) + +# ================================ +# DM encrypted file storage +# ================================ +# На диске лежит только AES-GCM ciphertext. Имя файла = Base58(SHA-256(ciphertext)). +dm.files.enabled=true +dm.files.storageDir=data/dm-files +# 50 MiB исходного файла + 16 bytes AES-GCM authentication tag. +dm.files.maxBytes=52428816 + webpush.vapid.public=BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI webpush.vapid.private=3hCt7XxTvLzuoxinjT5QcKRQEBnGZHXn8ZilU31RPNE webpush.vapid.subject=mailto:admin@shine.local diff --git a/VERSION.properties b/VERSION.properties index 39756fc9..1f7dd8ea 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ client.version=1.12.14 -server.version=1.10.4 +server.version=1.10.5 diff --git a/deploy/SETUP_SERVER_FROM_ZERO.md b/deploy/SETUP_SERVER_FROM_ZERO.md index 22dd7c13..6dfc1fe2 100644 --- a/deploy/SETUP_SERVER_FROM_ZERO.md +++ b/deploy/SETUP_SERVER_FROM_ZERO.md @@ -92,6 +92,12 @@ t2.shineup.me { reverse_proxy 127.0.0.1:7102 } + # Зашифрованные вложения DM. Этот route должен идти в Java, а не в SPA. + @dmfiles path /dm-files /dm-files/* + handle @dmfiles { + reverse_proxy 127.0.0.1:7102 + } + handle { root * /home/player/t2/UI try_files {path} /index.html diff --git a/deploy/backup/scheme/shineup.me/configs/Caddyfile b/deploy/backup/scheme/shineup.me/configs/Caddyfile index 055b2c6e..9a1c1c2f 100644 --- a/deploy/backup/scheme/shineup.me/configs/Caddyfile +++ b/deploy/backup/scheme/shineup.me/configs/Caddyfile @@ -10,6 +10,11 @@ shineup.me { reverse_proxy 127.0.0.1:7070 } + @dmfiles path /dm-files /dm-files/* + handle @dmfiles { + reverse_proxy 127.0.0.1:7070 + } + handle { root * /home/player/SHiNE/shine-ui try_files {path} /index.html @@ -31,6 +36,11 @@ shineup.me { reverse_proxy 127.0.0.1:7070 } + @dmfiles path /dm-files /dm-files/* + handle @dmfiles { + reverse_proxy 127.0.0.1:7070 + } + handle { root * /home/player/SHiNE/shine-ui try_files {path} /index.html diff --git a/deploy/scripts/deploy_server.sh b/deploy/scripts/deploy_server.sh index f34dba41..d9be6e23 100755 --- a/deploy/scripts/deploy_server.sh +++ b/deploy/scripts/deploy_server.sh @@ -9,6 +9,7 @@ TARGET_DOMAIN="${TARGET_DOMAIN:?TARGET_DOMAIN is required, example: shineup.me}" REMOTE_SERVER_DIR="${REMOTE_SERVER_DIR:?REMOTE_SERVER_DIR is required}" REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}" REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}" +REMOTE_DM_FILES_DIR="${REMOTE_DM_FILES_DIR:-}" REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:?REMOTE_SERVICE_NAME is required}" SERVER_PORT="${SERVER_PORT:?SERVER_PORT is required}" LOCAL_JAR="${LOCAL_JAR:-$ROOT_DIR/SHiNE-server/build/libs/shine-server.jar}" @@ -54,7 +55,7 @@ Type=simple User=player Group=player WorkingDirectory=$REMOTE_SERVER_DIR -ExecStart=/usr/bin/java -Dserver.port=$SERVER_PORT -jar $REMOTE_SERVER_DIR/shine-server.jar +ExecStart=/usr/bin/java -Dserver.port=$SERVER_PORT${REMOTE_DM_FILES_DIR:+ -Ddm.files.storageDir=$REMOTE_DM_FILES_DIR} -jar $REMOTE_SERVER_DIR/shine-server.jar Restart=always RestartSec=3 @@ -62,7 +63,11 @@ RestartSec=3 WantedBy=multi-user.target EOF -ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR'" +if [[ -n "$REMOTE_DM_FILES_DIR" ]]; then + ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR' '$REMOTE_DM_FILES_DIR'" +else + ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_SERVER_DIR' '$REMOTE_DATA_DIR' '$REMOTE_LOGS_DIR'" +fi rsync -az --timeout=120 "$LOCAL_JAR" "$REMOTE_HOST:$REMOTE_SERVER_DIR/shine-server.jar" rsync -az "$TMP_DIR/$REMOTE_SERVICE_NAME.service" "$REMOTE_HOST:/tmp/$REMOTE_SERVICE_NAME.service" diff --git a/deploy/scripts/test_t1_server.sh b/deploy/scripts/test_t1_server.sh index f38fbdf8..f61c5d91 100755 --- a/deploy/scripts/test_t1_server.sh +++ b/deploy/scripts/test_t1_server.sh @@ -4,6 +4,7 @@ set -euo pipefail REMOTE_HOST="player@t1.shineup.me" \ TARGET_DOMAIN="t1.shineup.me" \ REMOTE_SERVER_DIR="/home/player/t1/server" \ +REMOTE_DM_FILES_DIR="/home/player/test-dm-files" \ REMOTE_SERVICE_NAME="shine-t1" \ SERVER_PORT="7101" \ bash "$(dirname "$0")/deploy_server.sh" diff --git a/deploy/scripts/test_t2_server.sh b/deploy/scripts/test_t2_server.sh index 1a34c459..04c1a151 100755 --- a/deploy/scripts/test_t2_server.sh +++ b/deploy/scripts/test_t2_server.sh @@ -4,6 +4,7 @@ set -euo pipefail REMOTE_HOST="player@t2.shineup.me" \ TARGET_DOMAIN="t2.shineup.me" \ REMOTE_SERVER_DIR="/home/player/t2/server" \ +REMOTE_DM_FILES_DIR="/home/player/test-dm-files" \ REMOTE_SERVICE_NAME="shine-t2" \ SERVER_PORT="7102" \ bash "$(dirname "$0")/deploy_server.sh" diff --git a/deploy/scripts/test_t3_server.sh b/deploy/scripts/test_t3_server.sh index 68a609df..430bea9c 100755 --- a/deploy/scripts/test_t3_server.sh +++ b/deploy/scripts/test_t3_server.sh @@ -4,6 +4,7 @@ set -euo pipefail REMOTE_HOST="player@t3.shineup.me" \ TARGET_DOMAIN="t3.shineup.me" \ REMOTE_SERVER_DIR="/home/player/t3/server" \ +REMOTE_DM_FILES_DIR="/home/player/test-dm-files" \ REMOTE_SERVICE_NAME="shine-t3" \ SERVER_PORT="7103" \ bash "$(dirname "$0")/deploy_server.sh" diff --git a/deploy/scripts/test_t4_server.sh b/deploy/scripts/test_t4_server.sh index 5a4c6ba9..0e603082 100755 --- a/deploy/scripts/test_t4_server.sh +++ b/deploy/scripts/test_t4_server.sh @@ -4,6 +4,7 @@ set -euo pipefail REMOTE_HOST="player@t4.shineup.me" \ TARGET_DOMAIN="t4.shineup.me" \ REMOTE_SERVER_DIR="/home/player/t4/server" \ +REMOTE_DM_FILES_DIR="/home/player/test-dm-files" \ REMOTE_SERVICE_NAME="shine-t4" \ SERVER_PORT="7104" \ bash "$(dirname "$0")/deploy_server.sh" diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 02ff9351..124eb013 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -82,6 +82,6 @@ входящей копии на единственный access-сервер получателя. - Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее. - `ServerHello` пока принимает заявленный `serverLogin` на доверии и не является криптографической аутентификацией. -- Отдельных HTTP endpoints для DM-файлов сейчас нет. +- HTTP endpoints зашифрованных DM-файлов (`PUT/GET/HEAD/OPTIONS /dm-files/{fileId}`) не являются WebSocket `op` и описаны отдельно в `18_DM_File_Storage_API.md`. - Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит. - HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`. diff --git a/docs/API/18_DM_File_Storage_API.md b/docs/API/18_DM_File_Storage_API.md new file mode 100644 index 00000000..ee722303 --- /dev/null +++ b/docs/API/18_DM_File_Storage_API.md @@ -0,0 +1,99 @@ +# HTTP API зашифрованных файлов личных сообщений + +## 1. Назначение + +Файлы личных сообщений хранятся только на access-сервере отправителя и только в зашифрованном виде. +HTTP API не получает исходное имя файла, MIME-тип или ключ расшифрования. + +Клиент: + +1. генерирует случайный AES-256-GCM key и 96-bit IV; +2. шифрует файл в браузере; +3. считает `SHA-256(ciphertext)`; +4. переводит 32-byte hash в Base58 — это `fileId` и имя файла на диске сервера; +5. загружает ciphertext на свой access-сервер; +6. помещает URL, key, IV, исходное имя/MIME/размер в `` внутри plaintext DM; +7. обычный механизм E2EE DM шифрует эту техническую вставку отдельно для отправителя и получателя. + +Следствие: сервер файлов не знает AES-key и не может расшифровать вложение. + +## 2. Конфигурация сервера + +```properties +dm.files.enabled=true +dm.files.storageDir=data/dm-files +dm.files.maxBytes=52428816 +``` + +`dm.files.maxBytes` ограничивает размер ciphertext. Текущий официальный UI ограничивает исходный файл 50 MiB; AES-GCM добавляет 16-byte authentication tag. + +## 3. PUT `/dm-files/{fileId}` + +Загружает immutable ciphertext. + +`fileId` обязан быть равен: + +```text +Base58(SHA-256(ciphertext)) +``` + +### Заголовки авторизации + +```text +Content-Type: application/octet-stream +X-Shine-Session-Id: +X-Shine-Time-Ms: +X-Shine-Content-Length: +X-Shine-Signature: +``` + +Подписываемая UTF-8 строка: + +```text +DM_FILE_UPLOAD_V1:{sessionId}:{fileId}:{encryptedSize}:{timeMs} +``` + +Подпись проверяется публичным `session_key` из активной сессии. Допустимое отклонение времени — 60 секунд. + +Сервер потоково пишет временный файл, одновременно считает SHA-256, проверяет фактический размер и только после успешной проверки атомарно перемещает объект под именем `{fileId}`. + +Повторная корректно подписанная загрузка уже существующего immutable объекта идемпотентна. + +Успешный ответ: + +```json +{ + "ok": true, + "fileId": "...", + "size": 12345, + "alreadyExists": false +} +``` + +Основные ошибки: `BAD_FILE_ID`, `MISSING_UPLOAD_AUTH`, `UPLOAD_AUTH_EXPIRED`, `BAD_UPLOAD_SIGNATURE`, `FILE_TOO_LARGE`, `LENGTH_MISMATCH`, `HASH_MISMATCH`. + +## 4. GET `/dm-files/{fileId}` + +Возвращает только сохранённый ciphertext как `application/octet-stream`. + +GET не требует пользовательской сессии: `fileId` является непредсказуемым 256-bit content address, а без AES-key содержимое остаётся зашифрованным. AES-key передаётся только внутри E2EE DM. + +Ответ разрешает cross-origin чтение (`Access-Control-Allow-Origin: *`), потому что получатель может быть подключён к другому access-серверу и должен скачать ciphertext непосредственно с сервера отправителя. + +Перед расшифрованием официальный UI повторно проверяет `Base58(SHA-256(downloadedCiphertext)) == fileId`. + +## 5. HEAD и OPTIONS + +- `HEAD /dm-files/{fileId}` возвращает метаданные ciphertext без тела; +- `OPTIONS /dm-files/*` обслуживает CORS preflight для браузерного PUT. + +## 6. Reverse proxy + +Caddy/Nginx должен проксировать `/dm-files/*` в тот же Jetty, что обслуживает `/ws`. Route должен находиться до SPA fallback. + +## 7. Ограничения v1 + +- файл перед загрузкой целиком читается в память браузера, поэтому UI ограничен 50 MiB; +- удаления/TTL/garbage collection пока нет; +- если ciphertext уже загружен, а отправка E2EE DM затем не удалась, объект может остаться orphan-файлом; +- resumable/chunked upload не входит в v1. diff --git a/docs/Personal_Messages/README.md b/docs/Personal_Messages/README.md index 7c92140b..7814b5a8 100644 --- a/docs/Personal_Messages/README.md +++ b/docs/Personal_Messages/README.md @@ -9,6 +9,7 @@ - `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — доставка на единственный access-сервер, retry-воркер и UI-статусы - `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `` вставок внутри plaintext DM после расшифровки +- `docs/API/18_DM_File_Storage_API.md` — HTTP-хранилище зашифрованных файлов DM на access-сервере отправителя Исторический устаревший документ сохранён отдельно: @@ -19,3 +20,15 @@ - код DM и оба документа `Протокол_DM_v1.md` + `Формат_DM_v1.md` всегда должны обновляться синхронно; - если меняется поведение DM в коде, в том же наборе изменений обновляется и эта документация. - локальная таблица пользователей для DM считается кэшем, а источником истины остаётся Solana PDA; если серверная логика DM меняет правила lazy-import пользователей из PDA, это тоже обязательно фиксируется в документации. + +## Зашифрованные файлы DM + +Официальный UI v1 умеет отправлять файл как внешний зашифрованный объект: + +- файл шифруется в браузере `AES-256-GCM`; +- ciphertext хранится только на access-сервере отправителя под именем `Base58(SHA-256(ciphertext))`; +- AES-key, IV, исходное имя, MIME, размер и URL находятся в `` внутри plaintext DM, а значит сами защищены существующим E2EE DM; +- получатель скачивает ciphertext с сервера отправителя, проверяет SHA-256, расшифровывает локально и только затем отдаёт Blob браузеру для сохранения под исходным именем; +- сервер файлов ключ расшифрования не получает. + +Формат бинарного контейнера `SHiNE_DM` при этом не меняется. diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 75c64e4d..2cebdf2a 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -228,3 +228,22 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает - при удалении чата с `friend`/`close_friend` UI должен отдельно предупредить, что одна очистка истории не уберёт строку чата, и при подтверждении снять социальную связь и очистить историю. Это правило не меняет wire/API-формат DM и не меняет байтовый формат tombstone. + + +## 15. Файлы в личных сообщениях + +Файл не встраивается байтами в `SHiNE_DM`. До отправки DM официальный браузерный клиент: + +1. генерирует отдельные случайные `AES-256-GCM` key и IV; +2. шифрует исходный файл локально; +3. вычисляет `fileId = Base58(SHA-256(ciphertext))`; +4. подписанным HTTP `PUT` загружает ciphertext на свой текущий access-сервер; +5. отправляет обычный контентный DM `type=1/2`, plaintext которого начинается с ``. + +Внутри `` находятся `fileId`, абсолютный URL сервера отправителя, AES-key/IV и исходная метаинформация файла. Так как весь plaintext контентного DM уже шифруется на ключ получателя/отправителя, сервер не получает ключ файла. + +Получатель после E2EE-расшифровки DM скачивает ciphertext непосредственно с указанного access-сервера отправителя, сверяет `SHA-256`, расшифровывает файл в браузере и сохраняет исходный файл. + +Отключение функции «Передача файлов» в локальных дополнительных настройках запрещает только отправку новых файлов; ранее полученные `` остаются скачиваемыми. + +Хранение ciphertext и HTTP-контракт описаны в `docs/API/18_DM_File_Storage_API.md`. diff --git a/docs/Personal_Messages/Технические_вставки_DM_v1.md b/docs/Personal_Messages/Технические_вставки_DM_v1.md index 436ca0b1..a75585cf 100644 --- a/docs/Personal_Messages/Технические_вставки_DM_v1.md +++ b/docs/Personal_Messages/Технические_вставки_DM_v1.md @@ -68,7 +68,7 @@ - `kind` — ASCII-идентификатор типа вставки; - параметры отделяются `;`; - ключ и значение отделяются `=`; -- значения не экранируются в v1; +- значения обычных v1-вставок не экранируются; для `file` поля `url`, `name`, `mime` кодируются через percent-encoding (`encodeURIComponent`), чтобы `;`, `=` и `>` внутри метаданных не ломали блок; - канонический новый префикс: `📎 example.pdf +``` + +Поля: + +- `id` — `Base58(SHA-256(ciphertext))`; +- `url` — абсолютный URL ciphertext на access-сервере отправителя, percent-encoded; +- `key` — случайный 32-byte AES-256 key в Base64URL; +- `iv` — случайный 12-byte AES-GCM IV в Base64URL; +- `name` — исходное безопасно нормализованное имя файла, percent-encoded; +- `mime` — исходный MIME, percent-encoded; +- `size` — размер plaintext в байтах; +- `encsize` — размер ciphertext в байтах. + +`key`, `iv` и метаданные безопасно находятся здесь только потому, что весь plaintext DM затем шифруется существующим E2EE-механизмом. Они не передаются файловому HTTP endpoint отдельно. + +Текст `📎 example.pdf` после блока служит fallback для старого клиента. Новый официальный UI вместо него рисует карточку файла и кнопку «Скачать». + +Перед локальной AES-GCM-расшифровкой клиент обязан повторно вычислить SHA-256 скачанного ciphertext и сравнить Base58 с `id`. + +## 8. Поведение официального UI Официальный UI SHiNE в v1: @@ -137,9 +162,10 @@ fromLogin|toLogin|timeMs|nonce - `Звонок: H:MM:SS` - `Звонил, но недозвонился: ...` - для `reply` скрывает сам блок и показывает только текст ответа; +- для `file` скрывает fallback-текст и показывает карточку вложения с локальным download/decrypt; - если исходное reply-сообщение не найдено, reply-preview не показывается. -## 8. Совместимость +## 9. Совместимость Так как это часть plaintext, а не часть серверного envelope: diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index 38978942..d67306f7 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -371,3 +371,12 @@ UI-примечание (байтовый формат не меняет): ра Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог. Следствие для UI/агрегата диалогов: `hasDialog` определяется наличием пользовательского содержимого (или непрочитанных пользовательских сообщений), а не наличием служебной записи состояния/tombstone. Формат контейнера при этом не изменяется. + + +## 15. Вложения `` и бинарный формат + +Поддержка файлов не добавляет полей в `SHiNE_DM` и не меняет порядок существующих полей. + +Для контентных `type=1/2` технический блок `` является частью обычного plaintext, который затем попадает в уже существующий зашифрованный `body`. Сам ciphertext внешнего файла в `SHiNE_DM` не включается. + +Поэтому подписи контейнера, `baseKey`, `revisionTimeMs`, `reencryptedAtMs`, алгоритм E2EE DM и правила парности входящей/исходящей копий остаются прежними. diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 1bd23ced..dcaf7fbf 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -75,6 +75,7 @@ import * as walletView from './pages/wallet-view.js?v=202606281930'; import * as settingsView from './pages/settings-view.js'; import * as accessServersView from './pages/access-servers-view.js'; import * as developerSettingsView from './pages/developer-settings-view.js'; +import * as advancedSettingsView from './pages/advanced-settings-view.js'; import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240'; import * as arweaveUploadsView from './pages/arweave-uploads-view.js'; import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300'; @@ -141,6 +142,7 @@ const routes = { 'settings-view': settingsView, 'access-servers-view': accessServersView, 'developer-settings-view': developerSettingsView, + 'advanced-settings-view': advancedSettingsView, 'server-settings-view': serverSettingsView, 'arweave-uploads-view': arweaveUploadsView, 'remote-addblock-session-view': remoteAddBlockSessionView, @@ -226,6 +228,7 @@ const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([ 'server-settings-view', 'arweave-uploads-view', 'developer-settings-view', + 'advanced-settings-view', 'trusted-device-login-settings-view', 'device-view', 'device-session-view', diff --git a/shine-UI/js/pages/advanced-settings-view.js b/shine-UI/js/pages/advanced-settings-view.js new file mode 100644 index 00000000..814b0fda --- /dev/null +++ b/shine-UI/js/pages/advanced-settings-view.js @@ -0,0 +1,62 @@ +import { createTopBar } from '../components/topbar.js'; +import { + isDeveloperToolsEnabled, + isDmFileTransferEnabled, + setDeveloperToolsEnabled, + setDmFileTransferEnabled, +} from '../services/feature-settings.js'; + +export const pageMeta = { id: 'advanced-settings-view', title: 'Дополнительные настройки' }; + +function createToggleRow({ id, title, hint, checked }) { + const label = document.createElement('label'); + label.className = 'card settings-feature-toggle-row'; + label.htmlFor = id; + label.innerHTML = ` + + ${title} + ${hint} + + + `; + return label; +} + +export function render({ navigate, chrome }) { + const screen = document.createElement('section'); + screen.className = 'stack advanced-settings-screen'; + + chrome?.setTopbar(createTopBar({ + title: 'Дополнительные настройки', + back: { label: '←', onClick: () => navigate('settings-view') }, + })); + + const intro = document.createElement('div'); + intro.className = 'card stack advanced-settings-intro'; + intro.innerHTML = ` +

Скрытые возможности SHiNE

+

Этот экран открывается пятью быстрыми нажатиями по логотипу в обычных настройках и имеет постоянный адрес /settings/advanced.

+ `; + + const developerRow = createToggleRow({ + id: 'advanced-developer-tools', + title: 'Настройки разработчика', + hint: 'Показывать блок «Версии» и кнопку настроек разработчика в обычных настройках.', + checked: isDeveloperToolsEnabled(), + }); + + const filesRow = createToggleRow({ + id: 'advanced-dm-files', + title: 'Передача файлов в личных сообщениях', + hint: 'Разрешить отправку новых зашифрованных файлов через удержание кнопки эмодзи. Полученные ранее файлы останутся доступными.', + checked: isDmFileTransferEnabled(), + }); + + const developerInput = developerRow.querySelector('#advanced-developer-tools'); + const filesInput = filesRow.querySelector('#advanced-dm-files'); + developerInput?.addEventListener('change', () => setDeveloperToolsEnabled(developerInput.checked)); + filesInput?.addEventListener('change', () => setDmFileTransferEnabled(filesInput.checked)); + + screen.append(intro, developerRow, filesRow); + return screen; +} diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index e76e3bfd..67297f0d 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -29,7 +29,13 @@ import { } from '../components/emoji-picker.js?v=202607152130'; import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; import { showToast } from '../services/channels-ux.js'; -import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; +import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; +import { isDmFileTransferEnabled } from '../services/feature-settings.js'; +import { + downloadAndDecryptDmFile, + encryptAndUploadDmFile, + formatDmFileSize, +} from '../services/dm-file-service.js'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js'; @@ -686,10 +692,10 @@ function renderLog( const bubble = document.createElement('div'); const parsedText = parseDmTechBlocks(String(msg?.text || '')); - const autoKind = parsedText.callSummary ? 'call-tech' : ''; + const autoKind = parsedText.callSummary ? 'call-tech' : (parsedText.fileAttachment ? 'file-tech' : ''); const bubbleKind = String(msg?.kind || autoKind || '').trim(); bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`; - const emojiOnly = !parsedText.callSummary && !parsedText.replyRef && isTwemojiOnlyMessage(parsedText.displayText || ''); + const emojiOnly = !parsedText.callSummary && !parsedText.fileAttachment && !parsedText.replyRef && isTwemojiOnlyMessage(parsedText.displayText || ''); if (emojiOnly) { bubble.classList.add('bubble--emoji-only'); } @@ -734,6 +740,50 @@ function renderLog( bubble.append(callCard); } + if (parsedText.fileAttachment) { + const attachment = parsedText.fileAttachment; + const fileCard = document.createElement('div'); + fileCard.className = 'dm-file-card'; + + const fileIcon = document.createElement('span'); + fileIcon.className = 'dm-file-card__icon'; + fileIcon.textContent = '📎'; + + const fileCopy = document.createElement('span'); + fileCopy.className = 'dm-file-card__copy'; + const fileName = document.createElement('strong'); + fileName.className = 'dm-file-card__name'; + fileName.textContent = attachment.name || 'file'; + const fileMeta = document.createElement('span'); + fileMeta.className = 'dm-file-card__meta'; + fileMeta.textContent = formatDmFileSize(attachment.size); + fileCopy.append(fileName, fileMeta); + + const downloadBtn = document.createElement('button'); + downloadBtn.type = 'button'; + downloadBtn.className = 'ui-button dm-file-card__download'; + downloadBtn.textContent = 'Скачать'; + downloadBtn.addEventListener('click', async (event) => { + event.stopPropagation(); + if (downloadBtn.disabled) return; + downloadBtn.disabled = true; + const previousLabel = downloadBtn.textContent; + downloadBtn.textContent = 'Скачивание…'; + try { + await downloadAndDecryptDmFile(attachment); + showToast('Файл расшифрован и передан браузеру для сохранения', { timeoutMs: 1600 }); + } catch (error) { + showToast(error?.message || 'Не удалось скачать файл', { kind: 'error', timeoutMs: 2400 }); + } finally { + downloadBtn.disabled = false; + downloadBtn.textContent = previousLabel; + } + }); + + fileCard.append(fileIcon, fileCopy, downloadBtn); + bubble.append(fileCard); + } + const textNode = document.createElement('div'); textNode.className = 'bubble-text'; renderMessageText(textNode, parsedText.displayText || ''); @@ -764,7 +814,7 @@ function renderLog( bubble.append(replyBox); } } - if (!parsedText.callSummary) { + if (!parsedText.callSummary && !parsedText.fileAttachment) { bubble.append(textNode); } @@ -1118,7 +1168,9 @@ export function render({ navigate, route, chrome }) { +
+
@@ -1127,6 +1179,9 @@ export function render({ navigate, route, chrome }) { const input = form.elements.message; const emojiSlot = form.querySelector('#chat-emoji-picker-slot'); const emojiToggle = form.querySelector('#chat-emoji-toggle'); + const fileToggle = form.querySelector('#chat-file-toggle'); + const fileInput = form.querySelector('#chat-file-input'); + const fileTransferEnabled = isDmFileTransferEnabled(); const editBanner = form.querySelector('#chat-edit-banner'); const editBannerText = form.querySelector('#chat-edit-banner-text'); const editCancelBtn = form.querySelector('#chat-edit-cancel'); @@ -1135,6 +1190,9 @@ export function render({ navigate, route, chrome }) { let inputFocused = false; let emojiPickerOpen = false; let emojiSelection = null; + let emojiLongPressTimer = null; + let emojiLongPressTriggered = false; + let fileActionVisible = false; const setHistoryLoadingState = (isLoading) => { historyLoader.hidden = !isLoading; @@ -1162,6 +1220,19 @@ export function render({ navigate, route, chrome }) { emojiToggle?.setAttribute('aria-expanded', 'false'); }; + const clearEmojiLongPress = () => { + if (emojiLongPressTimer != null) { + window.clearTimeout(emojiLongPressTimer); + emojiLongPressTimer = null; + } + }; + + const setFileActionVisible = (visible) => { + fileActionVisible = Boolean(visible && fileTransferEnabled); + if (fileToggle) fileToggle.hidden = !fileActionVisible; + syncComposerLayout(); + }; + // На сенсорных устройствах фокус в textarea поднимает клавиатуру поверх пикера — // поэтому при вставке эмодзи фокусируем поле только там, где есть «точный» указатель. const isFinePointer = window.matchMedia?.('(hover: hover) and (pointer: fine)')?.matches ?? true; @@ -1453,6 +1524,117 @@ export function render({ navigate, route, chrome }) { } }; + const sendFileAttachmentMessage = async (attachment) => { + const replying = activeEdit ? null : activeReply; + const fileBlock = buildDmFileTechBlock(attachment); + const replyBlock = replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''; + const fallbackText = `📎 ${String(attachment?.name || 'file')}`; + const finalText = `${replyBlock}${fileBlock}${fallbackText}`; + + // Как и обычное исходящее сообщение, отправка файла сразу закрывает текущий + // визуальный раздел «Новые сообщения», не дожидаясь ответа сервера. + hideUnreadSeparator({ rerender: false }); + + const tempId = addOutgoingPendingMessage(chatId, finalText); + renderChatLog({ scrollMode: 'latest', markAsRead: false }); + scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); + + try { + const result = await authService.sendDirectMessage({ + login: state.session.login, + toLogin: chatId, + text: finalText, + storagePwd: state.session.storagePwdInMemory, + }); + markOutgoingSent(tempId, { + messageKey: result?.outgoingKey || '', + baseKey: result?.baseKey || result?.localBaseKey || '', + deliveryState: result?.deliveryState || 'accepted', + }); + + const localRevisionApplied = await applyLocalRevision({ + localOutgoingBlobB64: result?.localOutgoingBlobB64 || '', + fallbackMessageKey: result?.outgoingKey || '', + fallbackBaseKey: result?.baseKey || result?.localBaseKey || '', + deliveryState: result?.deliveryState || 'accepted', + }); + + if (replying) cancelReplyMode({ restoreDraft: false }); + renderChatLog({ scrollMode: 'latest', markAsRead: false }); + if (localRevisionApplied) notifyUnreadStateUpdated(); + scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); + window.requestAnimationFrame(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true })); + + addAppLogEntry({ + level: 'info', + source: 'outgoing-dm-file', + message: `Файл отправлен для ${chatId}`, + details: { + toLogin: chatId, + fileId: attachment?.id || '', + fileSize: Number(attachment?.size || 0), + messageId: result?.outgoingKey || '', + replyToBaseKey: replying?.baseKey || '', + }, + }); + } catch (error) { + addChatMessage(chatId, `Ошибка отправки файла: ${error?.message || 'unknown'}`); + addAppLogEntry({ + level: 'warn', + source: 'outgoing-dm-file', + message: 'Файл загружен, но DM с ключом не отправлен', + details: { + toLogin: chatId, + fileId: attachment?.id || '', + error: error?.message || 'unknown', + }, + }); + renderChatLog({ scrollMode: 'latest', markAsRead: false }); + throw error; + } + }; + + const handleSelectedFile = async (file) => { + if (!fileTransferEnabled) { + showToast('Передача файлов отключена в дополнительных настройках.', { kind: 'error', timeoutMs: 1800 }); + return; + } + if (activeEdit) { + showToast('Сначала завершите редактирование сообщения.', { kind: 'error', timeoutMs: 1600 }); + return; + } + if (!file) return; + + const originalLabel = fileToggle?.textContent || '📎'; + if (fileToggle) { + fileToggle.disabled = true; + fileToggle.textContent = '…'; + fileToggle.setAttribute('aria-label', 'Шифрование и загрузка файла'); + } + showToast(`Шифрую и загружаю «${String(file.name || 'file')}»…`, { timeoutMs: 1600 }); + + try { + const attachment = await encryptAndUploadDmFile({ + file, + login: state.session.login, + sessionId: state.session.sessionId, + wsUrl: authService.serverUrl, + }); + await sendFileAttachmentMessage(attachment); + setFileActionVisible(false); + showToast('Файл отправлен.', { timeoutMs: 1200 }); + } catch (error) { + showToast(error?.message || 'Не удалось отправить файл', { kind: 'error', timeoutMs: 2600 }); + } finally { + if (fileToggle) { + fileToggle.disabled = false; + fileToggle.textContent = originalLabel; + fileToggle.setAttribute('aria-label', 'Прикрепить файл'); + } + if (fileInput) fileInput.value = ''; + } + }; + const handleOpenActions = (msg, event) => { const parsed = parseDmTechBlocks(String(msg?.text || '')); const readState = resolveEffectiveReadState(getChatMessages(chatId), msg); @@ -1465,7 +1647,7 @@ export function render({ navigate, route, chrome }) { : '', canReply: true, showReadAloud: isTextToSpeechReady, - canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary, + canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary && !parsed?.fileAttachment, canDelete: ( (msg?.from === 'out' && Number(msg?.messageType || 0) === 2) || (msg?.from === 'in' && Number(msg?.messageType || 0) === 1) @@ -1577,9 +1759,38 @@ export function render({ navigate, route, chrome }) { emojiToggle?.setAttribute('aria-expanded', 'false'); emojiToggle?.addEventListener('pointerdown', (event) => { rememberEmojiSelection(); + emojiLongPressTriggered = false; + clearEmojiLongPress(); + if (fileTransferEnabled) { + emojiLongPressTimer = window.setTimeout(() => { + emojiLongPressTimer = null; + emojiLongPressTriggered = true; + closeEmojiPicker(); + setFileActionVisible(true); + }, 520); + } + try { + emojiToggle.setPointerCapture?.(event.pointerId); + } catch { + // Pointer capture is optional; long press still works without it. + } event.preventDefault(); }); - emojiToggle?.addEventListener('click', () => { + emojiToggle?.addEventListener('pointerup', clearEmojiLongPress); + emojiToggle?.addEventListener('pointercancel', clearEmojiLongPress); + emojiToggle?.addEventListener('pointerleave', (event) => { + if (event.buttons) clearEmojiLongPress(); + }); + emojiToggle?.addEventListener('contextmenu', (event) => { + if (fileTransferEnabled) event.preventDefault(); + }); + emojiToggle?.addEventListener('click', (event) => { + if (emojiLongPressTriggered) { + emojiLongPressTriggered = false; + event.preventDefault(); + return; + } + setFileActionVisible(false); emojiPickerOpen = !emojiPickerOpen; emojiSlot.hidden = !emojiPickerOpen; emojiToggle.setAttribute('aria-expanded', String(emojiPickerOpen)); @@ -1587,9 +1798,18 @@ export function render({ navigate, route, chrome }) { // открытии пикера прячем клавиатуру, чтобы поле ввода оставалось видно. if (emojiPickerOpen && !isFinePointer) input?.blur(); }); + fileToggle?.addEventListener('click', () => { + if (!fileTransferEnabled || fileToggle.disabled) return; + fileInput?.click(); + }); + fileInput?.addEventListener('change', () => { + const file = fileInput.files?.[0] || null; + if (file) void handleSelectedFile(file); + }); screen.addEventListener('pointerdown', (event) => { - if (!emojiPickerOpen || form.contains(event.target)) return; - closeEmojiPicker(); + if (form.contains(event.target)) return; + if (emojiPickerOpen) closeEmojiPicker(); + if (fileActionVisible) setFileActionVisible(false); }); form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => { event.preventDefault(); @@ -1658,6 +1878,7 @@ export function render({ navigate, route, chrome }) { screen.cleanup = () => { hideUnreadSeparator({ rerender: false }); stopAllTwemojiAnimations(); + clearEmojiLongPress(); window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh); boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll); diff --git a/shine-UI/js/pages/settings-view.js b/shine-UI/js/pages/settings-view.js index 8955cfff..20ad280e 100644 --- a/shine-UI/js/pages/settings-view.js +++ b/shine-UI/js/pages/settings-view.js @@ -1,5 +1,6 @@ import { createTopBar } from '../components/topbar.js'; import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js'; +import { isDeveloperToolsEnabled } from '../services/feature-settings.js'; export const pageMeta = { id: 'settings-view', title: 'Настройки' }; @@ -35,6 +36,24 @@ export function render({navigate, chrome}) { back: { label: '←', onClick: () => navigate('profile-view') }, })); + const logoButton = document.createElement('button'); + logoButton.type = 'button'; + logoButton.className = 'settings-shine-logo-button'; + logoButton.setAttribute('aria-label', 'Логотип SHiNE'); + logoButton.title = 'SHiNE'; + logoButton.innerHTML = ''; + + let logoTapCount = 0; + let lastLogoTapAt = 0; + logoButton.addEventListener('click', () => { + const now = Date.now(); + logoTapCount = now - lastLogoTapAt <= 900 ? logoTapCount + 1 : 1; + lastLogoTapAt = now; + if (logoTapCount < 5) return; + logoTapCount = 0; + navigate('advanced-settings-view'); + }); + const card = document.createElement('div'); card.className = 'card stack'; card.innerHTML = ` @@ -93,61 +112,64 @@ export function render({navigate, chrome}) { } }); - const versionCard = document.createElement('div'); - versionCard.className = 'card stack'; + screen.append(logoButton, card); - const title = document.createElement('p'); - title.className = 'field-label'; - title.textContent = 'Версии'; + if (isDeveloperToolsEnabled()) { + const versionCard = document.createElement('div'); + versionCard.className = 'card stack'; - const clientVersion = document.createElement('p'); - clientVersion.className = 'meta-muted'; - clientVersion.textContent = `Клиент: ${formatVersionForUi(window.__SHINE_CLIENT_VERSION__)}`; + const title = document.createElement('p'); + title.className = 'field-label'; + title.textContent = 'Версии'; - const uiBuild = document.createElement('p'); - uiBuild.className = 'meta-muted'; - uiBuild.textContent = `Сборка UI: ${formatVersionForUi(window.__SHINE_BUILD_HASH__)}`; + const clientVersion = document.createElement('p'); + clientVersion.className = 'meta-muted'; + clientVersion.textContent = `Клиент: ${formatVersionForUi(window.__SHINE_CLIENT_VERSION__)}`; - const serverVersion = document.createElement('p'); - serverVersion.className = 'meta-muted'; - serverVersion.textContent = 'Сервер: загружается...'; + const uiBuild = document.createElement('p'); + uiBuild.className = 'meta-muted'; + uiBuild.textContent = `Сборка UI: ${formatVersionForUi(window.__SHINE_BUILD_HASH__)}`; - versionCard.append(title, clientVersion, uiBuild, serverVersion); + const serverVersion = document.createElement('p'); + serverVersion.className = 'meta-muted'; + serverVersion.textContent = 'Сервер: загружается...'; - const developerCard = document.createElement('div'); - developerCard.className = 'card stack'; - developerCard.innerHTML = ` - - `; - developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view')); + versionCard.append(title, clientVersion, uiBuild, serverVersion); - void (async () => { - try { - let value = ''; + const developerCard = document.createElement('div'); + developerCard.className = 'card stack'; + developerCard.innerHTML = ` + + `; + developerCard.querySelector('#settings-developer').addEventListener('click', () => navigate('developer-settings-view')); + + void (async () => { try { - const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000); - value = String(pingResp?.payload?.serverVersion || pingResp?.serverVersion || '').trim(); - } catch { - // fallback below - } + let value = ''; + try { + const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000); + value = String(pingResp?.payload?.serverVersion || pingResp?.serverVersion || '').trim(); + } catch { + // fallback below + } - if (!value) { - const infoResp = await authService.ws.request('GetServerInfo', {}); - value = String(infoResp?.payload?.version || '').trim(); + if (!value) { + const infoResp = await authService.ws.request('GetServerInfo', {}); + value = String(infoResp?.payload?.version || '').trim(); + } + if (!isDisposed) serverVersion.textContent = `Сервер: ${formatVersionForUi(value)}`; + } catch { + if (!isDisposed) { + serverVersion.textContent = 'Сервер: недоступно'; + } } - if (!isDisposed) serverVersion.textContent = `Сервер: ${formatVersionForUi(value)}`; - } catch { - if (!isDisposed) { - serverVersion.textContent = 'Сервер: недоступно'; - } - } - })(); + })(); + + screen.append(versionCard, developerCard); + } screen.cleanup = () => { isDisposed = true; }; - screen.append(card); - screen.append(versionCard); - screen.append(developerCard); return screen; } diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js index d4b87b0d..35c7d02f 100644 --- a/shine-UI/js/router.js +++ b/shine-UI/js/router.js @@ -36,6 +36,7 @@ const PRETTY_PATHS = new Map([ ['server-settings-view', 'settings/servers'], ['arweave-uploads-view', 'settings/arweave-uploads'], ['developer-settings-view', 'settings/developer'], + ['advanced-settings-view', 'settings/advanced'], ['trusted-device-login-settings-view', 'settings/device-login'], ['language-view', 'settings/language'], ['app-log-view', 'settings/app-log'], @@ -322,6 +323,7 @@ export function parseRouteFromPath(pathname = '') { if (sub === 'servers') return { pageId: 'server-settings-view', params: {} }; if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} }; if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} }; + if (sub === 'advanced') return { pageId: 'advanced-settings-view', params: {} }; if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} }; if (sub === 'language') return { pageId: 'language-view', params: {} }; if (sub === 'app-log') return { pageId: 'app-log-view', params: {} }; @@ -447,6 +449,7 @@ export function resolveToolbarActive(pageId) { pageId === 'settings-view' || pageId === 'access-servers-view' || pageId === 'developer-settings-view' || + pageId === 'advanced-settings-view' || pageId === 'server-settings-view' || pageId === 'arweave-uploads-view' || pageId === 'remote-addblock-session-view' || diff --git a/shine-UI/js/services/dm-file-service.js b/shine-UI/js/services/dm-file-service.js new file mode 100644 index 00000000..39202df2 --- /dev/null +++ b/shine-UI/js/services/dm-file-service.js @@ -0,0 +1,204 @@ +import { + base64UrlToBytes, + bytesToBase58, + bytesToBase64Url, + decryptBytesAesGcm, + encryptBytesAesGcm, + importPkcs8Ed25519, + randomBytes, + sha256Bytes, + signBase64, +} from './crypto-utils.js'; +import { loadSessionMaterial } from './key-vault.js'; + +export const DM_FILE_MAX_SOURCE_BYTES = 50 * 1024 * 1024; + +function normalizeFileName(value = '') { + const cleaned = String(value || 'file') + .replace(/[\\/\u0000-\u001f\u007f]/g, '_') + .trim(); + return (cleaned || 'file').slice(0, 180); +} + +function wsUrlToHttpBase(wsUrl = '') { + const parsed = new URL(String(wsUrl || ''), window.location.href); + if (parsed.protocol === 'wss:') parsed.protocol = 'https:'; + else if (parsed.protocol === 'ws:') parsed.protocol = 'http:'; + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error('Не удалось определить HTTP-адрес сервера SHiNE'); + } + parsed.pathname = '/'; + parsed.search = ''; + parsed.hash = ''; + return parsed.origin; +} + +function uploadPreimage({ sessionId, fileId, encryptedSize, timeMs }) { + return `DM_FILE_UPLOAD_V1:${sessionId}:${fileId}:${Number(encryptedSize)}:${Number(timeMs)}`; +} + +async function readErrorMessage(response) { + try { + const raw = String(await response.text()).trim(); + if (!raw) return ''; + try { + const payload = JSON.parse(raw); + return String(payload?.message || payload?.error || raw).trim(); + } catch { + return raw; + } + } catch { + return ''; + } +} + +export function formatDmFileSize(bytes = 0) { + const value = Math.max(0, Number(bytes || 0)); + if (value < 1024) return `${value} Б`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} КБ`; + return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`; +} + +export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } = {}) { + if (!file || typeof file.arrayBuffer !== 'function') throw new Error('Файл не выбран'); + const cleanLogin = String(login || '').trim(); + const cleanSessionId = String(sessionId || '').trim(); + if (!cleanLogin || !cleanSessionId) throw new Error('Нет активной пользовательской сессии'); + if (Number(file.size || 0) > DM_FILE_MAX_SOURCE_BYTES) { + throw new Error(`Файл слишком большой. Текущий лимит — ${formatDmFileSize(DM_FILE_MAX_SOURCE_BYTES)}.`); + } + + const sessionMaterial = await loadSessionMaterial(cleanLogin); + if (!sessionMaterial?.sessionPrivPkcs8) { + throw new Error('На устройстве нет сохранённого session key для загрузки файла'); + } + if (sessionMaterial.sessionId && String(sessionMaterial.sessionId) !== cleanSessionId) { + throw new Error('Сохранённый session key относится к другой сессии'); + } + + const sourceBytes = new Uint8Array(await file.arrayBuffer()); + const fileKey = randomBytes(32); + const iv = randomBytes(12); + let encryptedBytes; + try { + encryptedBytes = await encryptBytesAesGcm(sourceBytes, fileKey, iv); + } finally { + sourceBytes.fill(0); + } + + try { + const fileHash = await sha256Bytes(encryptedBytes); + const fileId = bytesToBase58(fileHash); + const serverBase = wsUrlToHttpBase(wsUrl); + const fileUrl = `${serverBase}/dm-files/${fileId}`; + const timeMs = Date.now(); + const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8); + const signatureB64 = await signBase64(privateKey, uploadPreimage({ + sessionId: cleanSessionId, + fileId, + encryptedSize: encryptedBytes.byteLength, + timeMs, + })); + + let response; + try { + response = await fetch(fileUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Shine-Session-Id': cleanSessionId, + 'X-Shine-Time-Ms': String(timeMs), + 'X-Shine-Content-Length': String(encryptedBytes.byteLength), + 'X-Shine-Signature': signatureB64, + }, + body: encryptedBytes, + cache: 'no-store', + }); + } catch (error) { + throw new Error(`Не удалось загрузить файл на сервер отправителя: ${error?.message || 'network error'}`); + } + + if (!response.ok) { + const detail = await readErrorMessage(response); + throw new Error(detail || `Сервер отклонил файл (HTTP ${response.status})`); + } + + return { + version: 1, + id: fileId, + url: fileUrl, + keyB64Url: bytesToBase64Url(fileKey), + ivB64Url: bytesToBase64Url(iv), + name: normalizeFileName(file.name), + mime: String(file.type || 'application/octet-stream').trim().slice(0, 160) || 'application/octet-stream', + size: Number(file.size || 0), + encryptedSize: encryptedBytes.byteLength, + }; + } finally { + fileKey.fill(0); + iv.fill(0); + encryptedBytes?.fill(0); + } +} + +export async function downloadAndDecryptDmFile(attachment = {}) { + const fileId = String(attachment?.id || '').trim(); + const fileUrl = String(attachment?.url || '').trim(); + const keyB64Url = String(attachment?.keyB64Url || '').trim(); + const ivB64Url = String(attachment?.ivB64Url || '').trim(); + if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) { + throw new Error('В сообщении не хватает данных для расшифровки файла'); + } + + let response; + try { + response = await fetch(fileUrl, { method: 'GET', cache: 'no-store' }); + } catch (error) { + throw new Error(`Не удалось скачать зашифрованный файл: ${error?.message || 'network error'}`); + } + if (!response.ok) { + throw new Error(response.status === 404 ? 'Файл больше не найден на сервере отправителя' : `Ошибка скачивания файла (HTTP ${response.status})`); + } + + const encryptedBytes = new Uint8Array(await response.arrayBuffer()); + const actualId = bytesToBase58(await sha256Bytes(encryptedBytes)); + if (actualId !== fileId) { + encryptedBytes.fill(0); + throw new Error('SHA-256 файла не совпал: зашифрованный файл повреждён или подменён'); + } + + const keyBytes = base64UrlToBytes(keyB64Url); + const ivBytes = base64UrlToBytes(ivB64Url); + let plainBytes; + try { + plainBytes = await decryptBytesAesGcm(encryptedBytes, keyBytes, ivBytes); + } catch { + throw new Error('Не удалось расшифровать файл: ключ или содержимое повреждены'); + } finally { + encryptedBytes.fill(0); + keyBytes.fill(0); + ivBytes.fill(0); + } + + const expectedSize = Number(attachment?.size || 0); + if (expectedSize >= 0 && plainBytes.byteLength !== expectedSize) { + plainBytes.fill(0); + throw new Error('Размер расшифрованного файла не совпал с сообщением'); + } + + const blob = new Blob([plainBytes], { + type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream', + }); + plainBytes.fill(0); + + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = normalizeFileName(attachment?.name || 'file'); + anchor.rel = 'noopener'; + anchor.style.display = 'none'; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); +} diff --git a/shine-UI/js/services/dm-tech-blocks.js b/shine-UI/js/services/dm-tech-blocks.js index 10b2008e..9c906aef 100644 --- a/shine-UI/js/services/dm-tech-blocks.js +++ b/shine-UI/js/services/dm-tech-blocks.js @@ -8,6 +8,7 @@ function defaultParsed(rawText = '') { blocks: [], replyRef: null, callSummary: null, + fileAttachment: null, }; } @@ -87,6 +88,51 @@ export function buildDmReplyTechBlock({ baseKey = '' } = {}) { return ``; } +function decodeFileField(value = '') { + try { + return decodeURIComponent(String(value || '')); + } catch { + return String(value || ''); + } +} + +function normalizeFileAttachment(fields = {}) { + const id = String(fields.id || '').trim(); + const url = decodeFileField(fields.url || '').trim(); + const keyB64Url = String(fields.key || '').trim(); + const ivB64Url = String(fields.iv || '').trim(); + const name = decodeFileField(fields.name || '').trim() || 'file'; + const mime = decodeFileField(fields.mime || '').trim() || 'application/octet-stream'; + const size = Number(fields.size || 0); + const encryptedSize = Number(fields.encsize || 0); + if (!id || !url || !keyB64Url || !ivB64Url || !Number.isFinite(size) || size < 0) return null; + return { + version: Number(fields.v || 0), + id, + url, + keyB64Url, + ivB64Url, + name, + mime, + size, + encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0, + }; +} + +export function buildDmFileTechBlock(attachment = {}) { + const id = String(attachment?.id || '').trim(); + const url = String(attachment?.url || '').trim(); + const keyB64Url = String(attachment?.keyB64Url || '').trim(); + const ivB64Url = String(attachment?.ivB64Url || '').trim(); + const size = Math.max(0, Math.floor(Number(attachment?.size || 0))); + const encryptedSize = Math.max(0, Math.floor(Number(attachment?.encryptedSize || 0))); + if (!id || !url || !keyB64Url || !ivB64Url) throw new Error('Не хватает данных для технического блока файла'); + const name = encodeURIComponent(String(attachment?.name || 'file')); + const mime = encodeURIComponent(String(attachment?.mime || 'application/octet-stream')); + const encodedUrl = encodeURIComponent(url); + return ``; +} + export function parseDmTechBlocks(rawText = '') { const text = String(rawText || ''); if (!hasTechPrefix(text)) return defaultParsed(text); @@ -95,6 +141,7 @@ export function parseDmTechBlocks(rawText = '') { let cursor = 0; let replyRef = null; let callSummary = null; + let fileAttachment = null; while (hasTechPrefix(text, cursor)) { const end = text.indexOf('>', cursor); @@ -142,6 +189,8 @@ export function parseDmTechBlocks(rawText = '') { reason: String(fields.reason || '').trim().toLowerCase(), }; } + } else if (kind === 'file' && !fileAttachment) { + fileAttachment = normalizeFileAttachment(fields); } cursor = end + 1; @@ -157,5 +206,6 @@ export function parseDmTechBlocks(rawText = '') { blocks, replyRef, callSummary, + fileAttachment, }; } diff --git a/shine-UI/js/services/feature-settings.js b/shine-UI/js/services/feature-settings.js new file mode 100644 index 00000000..0b2598f8 --- /dev/null +++ b/shine-UI/js/services/feature-settings.js @@ -0,0 +1,43 @@ +const STORAGE_KEYS = Object.freeze({ + developerTools: 'shine-feature-developer-tools-v1', + dmFileTransfer: 'shine-feature-dm-file-transfer-v1', +}); + +function readBoolean(key, defaultValue = true) { + try { + const raw = localStorage.getItem(key); + if (raw == null) return Boolean(defaultValue); + return raw !== '0' && raw !== 'false'; + } catch { + return Boolean(defaultValue); + } +} + +function writeBoolean(key, enabled) { + const value = Boolean(enabled); + try { + localStorage.setItem(key, value ? '1' : '0'); + } catch { + // В приватном режиме/при запрете storage оставляем значение только на уровне default. + } + window.dispatchEvent(new CustomEvent('shine-feature-settings-updated', { + detail: { key, enabled: value }, + })); + return value; +} + +export function isDeveloperToolsEnabled() { + return readBoolean(STORAGE_KEYS.developerTools, true); +} + +export function setDeveloperToolsEnabled(enabled) { + return writeBoolean(STORAGE_KEYS.developerTools, enabled); +} + +export function isDmFileTransferEnabled() { + return readBoolean(STORAGE_KEYS.dmFileTransfer, true); +} + +export function setDmFileTransferEnabled(enabled) { + return writeBoolean(STORAGE_KEYS.dmFileTransfer, enabled); +} diff --git a/shine-UI/styles/features/chat.css b/shine-UI/styles/features/chat.css index ff70d858..0c9ad973 100644 --- a/shine-UI/styles/features/chat.css +++ b/shine-UI/styles/features/chat.css @@ -1275,3 +1275,101 @@ text-shadow: 0 0 5px rgba(92, 190, 255, 0.72), 0 0 12px rgba(72, 145, 255, 0.34); filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46)); } + +/* ===== Зашифрованные вложения в личных сообщениях ===== */ +.dm-file-btn { + position: absolute; + right: 45px; + bottom: 52px; + z-index: 6; + min-width: 43px; + width: 43px; + height: 43px; + padding: 0; + border-radius: 14px; + font-size: 21px; + line-height: 1; + background: rgba(13, 28, 54, 0.94); + border: 1px solid rgba(183, 207, 242, 0.28); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255,255,255,.08); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} + +.dm-file-btn[hidden] { + display: none; +} + +.dm-file-btn:disabled { + opacity: 0.7; + cursor: progress; +} + +.dm-file-card { + min-width: min(285px, 72vw); + max-width: min(330px, 76vw); + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: 9px; +} + +.dm-file-card__icon { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 12px; + background: rgba(255, 255, 255, 0.08); + font-size: 19px; +} + +.dm-file-card__copy { + min-width: 0; + display: grid; + gap: 3px; +} + +.dm-file-card__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 680; +} + +.dm-file-card__meta { + font-size: 11px; + color: rgba(205, 219, 242, 0.67); +} + +.dm-file-card__download { + min-height: 34px; + padding: 0 10px; + border-radius: 11px; + font-size: 12px; + font-weight: 650; + white-space: nowrap; +} + +.dm-file-card__download:disabled { + opacity: 0.65; + cursor: progress; +} + +@media (max-width: 390px) { + .dm-screen .dm-file-card { + min-width: min(264px, 76vw); + grid-template-columns: 32px minmax(0, 1fr); + } + + .dm-screen .dm-file-card__download { + grid-column: 2; + justify-self: start; + } +} + +.dm-chat-input--multiline .dm-file-btn { + right: 0; + bottom: 104px; +} diff --git a/shine-UI/styles/features/messages.css b/shine-UI/styles/features/messages.css index 775f01fb..cf74f0e7 100644 --- a/shine-UI/styles/features/messages.css +++ b/shine-UI/styles/features/messages.css @@ -404,6 +404,7 @@ button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); } .dm-actions-col { + position: relative; display: grid; grid-template-columns: repeat(2, 46px); grid-template-rows: 46px; diff --git a/shine-UI/styles/features/settings.css b/shine-UI/styles/features/settings.css index 62af738f..114283df 100644 --- a/shine-UI/styles/features/settings.css +++ b/shine-UI/styles/features/settings.css @@ -270,3 +270,113 @@ } .language-choice-actions .shine-btn { height: 50px; } + +/* ===== Скрытые расширенные настройки ===== */ +.settings-shine-logo-button { + width: min(72vw, 280px); + min-height: 118px; + margin: 2px auto 10px; + padding: 6px 12px; + display: grid; + place-items: center; + border: 0; + background: transparent; + box-shadow: none; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +.settings-shine-logo-button:hover, +.settings-shine-logo-button:focus, +.settings-shine-logo-button:active { + border: 0; + background: transparent; + box-shadow: none; + transform: none; +} + +.settings-shine-logo-button:focus-visible { + outline: 2px solid rgba(167, 205, 255, 0.7); + outline-offset: 4px; + border-radius: 22px; +} + +.settings-shine-logo { + display: block; + width: 100%; + max-height: 126px; + object-fit: contain; + pointer-events: none; + filter: drop-shadow(0 12px 30px rgba(73, 126, 216, 0.16)); +} + +.settings-feature-toggle-row { + min-height: 76px; + padding: 15px 16px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + cursor: pointer; +} + +.settings-feature-toggle-copy { + min-width: 0; + display: grid; + gap: 5px; +} + +.settings-feature-toggle-title { + font-size: 15px; + font-weight: 700; + color: rgba(246, 250, 255, 0.96); +} + +.settings-feature-toggle-hint { + font-size: 12px; + line-height: 1.4; + color: rgba(194, 210, 236, 0.68); +} + +.settings-feature-switch { + appearance: none; + -webkit-appearance: none; + width: 48px; + height: 28px; + margin: 0; + padding: 2px; + border: 1px solid rgba(189, 207, 238, 0.26); + border-radius: 999px; + background: rgba(8, 17, 34, 0.72); + box-shadow: inset 0 2px 7px rgba(0, 0, 0, 0.28); + cursor: pointer; + transition: background-color 150ms ease, border-color 150ms ease, box-shadow 150ms ease; +} + +.settings-feature-switch::after { + content: ''; + display: block; + width: 22px; + height: 22px; + border-radius: 50%; + background: rgba(235, 243, 255, 0.9); + box-shadow: 0 2px 7px rgba(0, 0, 0, 0.28); + transition: transform 150ms ease; +} + +.settings-feature-switch:checked { + border-color: rgba(86, 174, 255, 0.6); + background: rgba(38, 132, 237, 0.48); + box-shadow: inset 0 1px 0 rgba(255,255,255,.1), 0 0 16px rgba(58, 148, 255, 0.12); +} + +.settings-feature-switch:checked::after { + transform: translateX(20px); + background: #ffffff; +} + +.settings-feature-switch:focus-visible { + outline: 2px solid rgba(190, 220, 255, 0.82); + outline-offset: 3px; +}