Первая версия вложенных файлов работает

This commit is contained in:
AidarKC
2026-09-11 02:38:02 +03:00
parent ea0098d705
commit f9ae811702
29 changed files with 1463 additions and 57 deletions
@@ -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/*");
}
}
@@ -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;
}
}
}
@@ -6,6 +6,7 @@ import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerI
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import server.debug.DebugApiConfigurator; import server.debug.DebugApiConfigurator;
import server.files.DmFileApiConfigurator;
import server.sync.BlockchainResyncRecoveryOnStartup; import server.sync.BlockchainResyncRecoveryOnStartup;
import server.sync.PeriodicBlockchainSyncService; import server.sync.PeriodicBlockchainSyncService;
import server.sync.PeriodicDmDeliveryService; import server.sync.PeriodicDmDeliveryService;
@@ -93,6 +94,9 @@ public final class WsServer {
// HTTP debug API // HTTP debug API
DebugApiConfigurator.register(context); DebugApiConfigurator.register(context);
// Зашифрованные вложения личных сообщений: PUT/GET /dm-files/{sha256-base58}
DmFileApiConfigurator.register(context);
// Инициализация контейнера WebSocket // Инициализация контейнера WebSocket
JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> { JettyWebSocketServletContainerInitializer.configure(context, (servletContext, wsContainer) -> {
// Таймаут простоя соединения (Jetty 11 синтаксис) // Таймаут простоя соединения (Jetty 11 синтаксис)
@@ -53,6 +53,16 @@ server.ui.indexPath=/home/player/SHiNE/shine-UI/index.html
server.ui.buildHash= server.ui.buildHash=
# Web Push (VAPID) # 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.public=BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI
webpush.vapid.private=3hCt7XxTvLzuoxinjT5QcKRQEBnGZHXn8ZilU31RPNE webpush.vapid.private=3hCt7XxTvLzuoxinjT5QcKRQEBnGZHXn8ZilU31RPNE
webpush.vapid.subject=mailto:admin@shine.local webpush.vapid.subject=mailto:admin@shine.local
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.12.14 client.version=1.12.14
server.version=1.10.4 server.version=1.10.5
+6
View File
@@ -92,6 +92,12 @@ t2.shineup.me {
reverse_proxy 127.0.0.1:7102 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 { handle {
root * /home/player/t2/UI root * /home/player/t2/UI
try_files {path} /index.html try_files {path} /index.html
@@ -10,6 +10,11 @@ shineup.me {
reverse_proxy 127.0.0.1:7070 reverse_proxy 127.0.0.1:7070
} }
@dmfiles path /dm-files /dm-files/*
handle @dmfiles {
reverse_proxy 127.0.0.1:7070
}
handle { handle {
root * /home/player/SHiNE/shine-ui root * /home/player/SHiNE/shine-ui
try_files {path} /index.html try_files {path} /index.html
@@ -31,6 +36,11 @@ shineup.me {
reverse_proxy 127.0.0.1:7070 reverse_proxy 127.0.0.1:7070
} }
@dmfiles path /dm-files /dm-files/*
handle @dmfiles {
reverse_proxy 127.0.0.1:7070
}
handle { handle {
root * /home/player/SHiNE/shine-ui root * /home/player/SHiNE/shine-ui
try_files {path} /index.html try_files {path} /index.html
+7 -2
View File
@@ -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_SERVER_DIR="${REMOTE_SERVER_DIR:?REMOTE_SERVER_DIR is required}"
REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}" REMOTE_LOGS_DIR="${REMOTE_LOGS_DIR:-$REMOTE_SERVER_DIR/logs}"
REMOTE_DATA_DIR="${REMOTE_DATA_DIR:-$REMOTE_SERVER_DIR/data}" 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}" REMOTE_SERVICE_NAME="${REMOTE_SERVICE_NAME:?REMOTE_SERVICE_NAME is required}"
SERVER_PORT="${SERVER_PORT:?SERVER_PORT is required}" SERVER_PORT="${SERVER_PORT:?SERVER_PORT is required}"
LOCAL_JAR="${LOCAL_JAR:-$ROOT_DIR/SHiNE-server/build/libs/shine-server.jar}" LOCAL_JAR="${LOCAL_JAR:-$ROOT_DIR/SHiNE-server/build/libs/shine-server.jar}"
@@ -54,7 +55,7 @@ Type=simple
User=player User=player
Group=player Group=player
WorkingDirectory=$REMOTE_SERVER_DIR 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 Restart=always
RestartSec=3 RestartSec=3
@@ -62,7 +63,11 @@ RestartSec=3
WantedBy=multi-user.target WantedBy=multi-user.target
EOF 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 --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" rsync -az "$TMP_DIR/$REMOTE_SERVICE_NAME.service" "$REMOTE_HOST:/tmp/$REMOTE_SERVICE_NAME.service"
+1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
REMOTE_HOST="player@t1.shineup.me" \ REMOTE_HOST="player@t1.shineup.me" \
TARGET_DOMAIN="t1.shineup.me" \ TARGET_DOMAIN="t1.shineup.me" \
REMOTE_SERVER_DIR="/home/player/t1/server" \ REMOTE_SERVER_DIR="/home/player/t1/server" \
REMOTE_DM_FILES_DIR="/home/player/test-dm-files" \
REMOTE_SERVICE_NAME="shine-t1" \ REMOTE_SERVICE_NAME="shine-t1" \
SERVER_PORT="7101" \ SERVER_PORT="7101" \
bash "$(dirname "$0")/deploy_server.sh" bash "$(dirname "$0")/deploy_server.sh"
+1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
REMOTE_HOST="player@t2.shineup.me" \ REMOTE_HOST="player@t2.shineup.me" \
TARGET_DOMAIN="t2.shineup.me" \ TARGET_DOMAIN="t2.shineup.me" \
REMOTE_SERVER_DIR="/home/player/t2/server" \ REMOTE_SERVER_DIR="/home/player/t2/server" \
REMOTE_DM_FILES_DIR="/home/player/test-dm-files" \
REMOTE_SERVICE_NAME="shine-t2" \ REMOTE_SERVICE_NAME="shine-t2" \
SERVER_PORT="7102" \ SERVER_PORT="7102" \
bash "$(dirname "$0")/deploy_server.sh" bash "$(dirname "$0")/deploy_server.sh"
+1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
REMOTE_HOST="player@t3.shineup.me" \ REMOTE_HOST="player@t3.shineup.me" \
TARGET_DOMAIN="t3.shineup.me" \ TARGET_DOMAIN="t3.shineup.me" \
REMOTE_SERVER_DIR="/home/player/t3/server" \ REMOTE_SERVER_DIR="/home/player/t3/server" \
REMOTE_DM_FILES_DIR="/home/player/test-dm-files" \
REMOTE_SERVICE_NAME="shine-t3" \ REMOTE_SERVICE_NAME="shine-t3" \
SERVER_PORT="7103" \ SERVER_PORT="7103" \
bash "$(dirname "$0")/deploy_server.sh" bash "$(dirname "$0")/deploy_server.sh"
+1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
REMOTE_HOST="player@t4.shineup.me" \ REMOTE_HOST="player@t4.shineup.me" \
TARGET_DOMAIN="t4.shineup.me" \ TARGET_DOMAIN="t4.shineup.me" \
REMOTE_SERVER_DIR="/home/player/t4/server" \ REMOTE_SERVER_DIR="/home/player/t4/server" \
REMOTE_DM_FILES_DIR="/home/player/test-dm-files" \
REMOTE_SERVICE_NAME="shine-t4" \ REMOTE_SERVICE_NAME="shine-t4" \
SERVER_PORT="7104" \ SERVER_PORT="7104" \
bash "$(dirname "$0")/deploy_server.sh" bash "$(dirname "$0")/deploy_server.sh"
+1 -1
View File
@@ -82,6 +82,6 @@
входящей копии на единственный access-сервер получателя. входящей копии на единственный access-сервер получателя.
- Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее. - Межсерверные DM-операции пока доверяют `sourceServerLogin`; отдельная межсерверная авторизация запланирована позднее.
- `ServerHello` пока принимает заявленный `serverLogin` на доверии и не является криптографической аутентификацией. - `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 не входит. - Классы `Net_MarkChannelMessagesSeen_*` существуют в коде, но операция `MarkChannelMessagesSeen` не зарегистрирована в `JsonHandlerRegistry`, поэтому в публичный список API не входит.
- HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`. - HTTP debug endpoints из `src/main/java/server/debug/` не входят в этот индекс WebSocket `op`; они описаны отдельно в `13_HTTP_Debug_API.md`.
+99
View File
@@ -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/размер в `<S:file...>` внутри 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: <active session id>
X-Shine-Time-Ms: <unix time ms>
X-Shine-Content-Length: <ciphertext bytes>
X-Shine-Signature: <Ed25519 signature, Base64>
```
Подписываемая 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.
+13
View File
@@ -9,6 +9,7 @@
- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — доставка на - `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — доставка на
единственный access-сервер, retry-воркер и UI-статусы единственный access-сервер, retry-воркер и UI-статусы
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки - `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри 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_v1.md` + `Формат_DM_v1.md` всегда должны обновляться синхронно;
- если меняется поведение DM в коде, в том же наборе изменений обновляется и эта документация. - если меняется поведение DM в коде, в том же наборе изменений обновляется и эта документация.
- локальная таблица пользователей для DM считается кэшем, а источником истины остаётся Solana PDA; если серверная логика DM меняет правила lazy-import пользователей из PDA, это тоже обязательно фиксируется в документации. - локальная таблица пользователей для DM считается кэшем, а источником истины остаётся Solana PDA; если серверная логика DM меняет правила lazy-import пользователей из PDA, это тоже обязательно фиксируется в документации.
## Зашифрованные файлы DM
Официальный UI v1 умеет отправлять файл как внешний зашифрованный объект:
- файл шифруется в браузере `AES-256-GCM`;
- ciphertext хранится только на access-сервере отправителя под именем `Base58(SHA-256(ciphertext))`;
- AES-key, IV, исходное имя, MIME, размер и URL находятся в `<S:file...>` внутри plaintext DM, а значит сами защищены существующим E2EE DM;
- получатель скачивает ciphertext с сервера отправителя, проверяет SHA-256, расшифровывает локально и только затем отдаёт Blob браузеру для сохранения под исходным именем;
- сервер файлов ключ расшифрования не получает.
Формат бинарного контейнера `SHiNE_DM` при этом не меняется.
@@ -228,3 +228,22 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
- при удалении чата с `friend`/`close_friend` UI должен отдельно предупредить, что одна очистка истории не уберёт строку чата, и при подтверждении снять социальную связь и очистить историю. - при удалении чата с `friend`/`close_friend` UI должен отдельно предупредить, что одна очистка истории не уберёт строку чата, и при подтверждении снять социальную связь и очистить историю.
Это правило не меняет wire/API-формат DM и не меняет байтовый формат tombstone. Это правило не меняет 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 которого начинается с `<S:file...>`.
Внутри `<S:file...>` находятся `fileId`, абсолютный URL сервера отправителя, AES-key/IV и исходная метаинформация файла. Так как весь plaintext контентного DM уже шифруется на ключ получателя/отправителя, сервер не получает ключ файла.
Получатель после E2EE-расшифровки DM скачивает ciphertext непосредственно с указанного access-сервера отправителя, сверяет `SHA-256`, расшифровывает файл в браузере и сохраняет исходный файл.
Отключение функции «Передача файлов» в локальных дополнительных настройках запрещает только отправку новых файлов; ранее полученные `<S:file...>` остаются скачиваемыми.
Хранение ciphertext и HTTP-контракт описаны в `docs/API/18_DM_File_Storage_API.md`.
@@ -68,7 +68,7 @@
- `kind` — ASCII-идентификатор типа вставки; - `kind` — ASCII-идентификатор типа вставки;
- параметры отделяются `;`; - параметры отделяются `;`;
- ключ и значение отделяются `=`; - ключ и значение отделяются `=`;
- значения не экранируются в v1; - значения обычных v1-вставок не экранируются; для `file` поля `url`, `name`, `mime` кодируются через percent-encoding (`encodeURIComponent`), чтобы `;`, `=` и `>` внутри метаданных не ломали блок;
- канонический новый префикс: `<S:`; - канонический новый префикс: `<S:`;
- legacy-префикс `<SHiNE:` продолжает поддерживаться при чтении. - legacy-префикс `<SHiNE:` продолжает поддерживаться при чтении.
@@ -127,7 +127,32 @@ fromLogin|toLogin|timeMs|nonce
- UI может рисовать такие сообщения отдельным специальным стилем. - UI может рисовать такие сообщения отдельным специальным стилем.
- официальный UI не отправляет call-summary, если от старта исходящего звонка до его завершения прошло меньше `5` секунд. - официальный UI не отправляет call-summary, если от старта исходящего звонка до его завершения прошло меньше `5` секунд.
## 7. Поведение официального UI ## 7. Тип `file`
Формат v1:
```text
<S:file;v=1;id=BASE58_SHA256;url=ENCODED_URL;key=BASE64URL_AES_KEY;iv=BASE64URL_IV;name=ENCODED_NAME;mime=ENCODED_MIME;size=123;encsize=139>📎 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: Официальный UI SHiNE в v1:
@@ -137,9 +162,10 @@ fromLogin|toLogin|timeMs|nonce
- `Звонок: H:MM:SS` - `Звонок: H:MM:SS`
- `Звонил, но недозвонился: ...` - `Звонил, но недозвонился: ...`
- для `reply` скрывает сам блок и показывает только текст ответа; - для `reply` скрывает сам блок и показывает только текст ответа;
- для `file` скрывает fallback-текст и показывает карточку вложения с локальным download/decrypt;
- если исходное reply-сообщение не найдено, reply-preview не показывается. - если исходное reply-сообщение не найдено, reply-preview не показывается.
## 8. Совместимость ## 9. Совместимость
Так как это часть plaintext, а не часть серверного envelope: Так как это часть plaintext, а не часть серверного envelope:
@@ -371,3 +371,12 @@ UI-примечание (байтовый формат не меняет): ра
Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог. Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог.
Следствие для UI/агрегата диалогов: `hasDialog` определяется наличием пользовательского содержимого (или непрочитанных пользовательских сообщений), а не наличием служебной записи состояния/tombstone. Формат контейнера при этом не изменяется. Следствие для UI/агрегата диалогов: `hasDialog` определяется наличием пользовательского содержимого (или непрочитанных пользовательских сообщений), а не наличием служебной записи состояния/tombstone. Формат контейнера при этом не изменяется.
## 15. Вложения `<S:file>` и бинарный формат
Поддержка файлов не добавляет полей в `SHiNE_DM` и не меняет порядок существующих полей.
Для контентных `type=1/2` технический блок `<S:file...>` является частью обычного plaintext, который затем попадает в уже существующий зашифрованный `body`. Сам ciphertext внешнего файла в `SHiNE_DM` не включается.
Поэтому подписи контейнера, `baseKey`, `revisionTimeMs`, `reencryptedAtMs`, алгоритм E2EE DM и правила парности входящей/исходящей копий остаются прежними.
+3
View File
@@ -75,6 +75,7 @@ import * as walletView from './pages/wallet-view.js?v=202606281930';
import * as settingsView from './pages/settings-view.js'; import * as settingsView from './pages/settings-view.js';
import * as accessServersView from './pages/access-servers-view.js'; import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-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 serverSettingsView from './pages/server-settings-view.js?v=202606161240';
import * as arweaveUploadsView from './pages/arweave-uploads-view.js'; import * as arweaveUploadsView from './pages/arweave-uploads-view.js';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300'; import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
@@ -141,6 +142,7 @@ const routes = {
'settings-view': settingsView, 'settings-view': settingsView,
'access-servers-view': accessServersView, 'access-servers-view': accessServersView,
'developer-settings-view': developerSettingsView, 'developer-settings-view': developerSettingsView,
'advanced-settings-view': advancedSettingsView,
'server-settings-view': serverSettingsView, 'server-settings-view': serverSettingsView,
'arweave-uploads-view': arweaveUploadsView, 'arweave-uploads-view': arweaveUploadsView,
'remote-addblock-session-view': remoteAddBlockSessionView, 'remote-addblock-session-view': remoteAddBlockSessionView,
@@ -226,6 +228,7 @@ const SETTINGS_BORDERED_ACTION_PAGE_IDS = new Set([
'server-settings-view', 'server-settings-view',
'arweave-uploads-view', 'arweave-uploads-view',
'developer-settings-view', 'developer-settings-view',
'advanced-settings-view',
'trusted-device-login-settings-view', 'trusted-device-login-settings-view',
'device-view', 'device-view',
'device-session-view', 'device-session-view',
@@ -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 = `
<span class="settings-feature-toggle-copy">
<strong class="settings-feature-toggle-title">${title}</strong>
<span class="settings-feature-toggle-hint">${hint}</span>
</span>
<input class="settings-feature-switch" id="${id}" type="checkbox" ${checked ? 'checked' : ''} />
`;
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 = `
<p class="field-label">Скрытые возможности SHiNE</p>
<p class="meta-muted">Этот экран открывается пятью быстрыми нажатиями по логотипу в обычных настройках и имеет постоянный адрес <code>/settings/advanced</code>.</p>
`;
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;
}
+229 -8
View File
@@ -29,7 +29,13 @@ import {
} from '../components/emoji-picker.js?v=202607152130'; } from '../components/emoji-picker.js?v=202607152130';
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.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 { loadProfileSnapshot } from '../services/user-profile-params.js';
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js'; import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
@@ -686,10 +692,10 @@ function renderLog(
const bubble = document.createElement('div'); const bubble = document.createElement('div');
const parsedText = parseDmTechBlocks(String(msg?.text || '')); 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(); const bubbleKind = String(msg?.kind || autoKind || '').trim();
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`; 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) { if (emojiOnly) {
bubble.classList.add('bubble--emoji-only'); bubble.classList.add('bubble--emoji-only');
} }
@@ -734,6 +740,50 @@ function renderLog(
bubble.append(callCard); 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'); const textNode = document.createElement('div');
textNode.className = 'bubble-text'; textNode.className = 'bubble-text';
renderMessageText(textNode, parsedText.displayText || ''); renderMessageText(textNode, parsedText.displayText || '');
@@ -764,7 +814,7 @@ function renderLog(
bubble.append(replyBox); bubble.append(replyBox);
} }
} }
if (!parsedText.callSummary) { if (!parsedText.callSummary && !parsedText.fileAttachment) {
bubble.append(textNode); bubble.append(textNode);
} }
@@ -1118,7 +1168,9 @@ export function render({ navigate, route, chrome }) {
</div> </div>
<div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div> <div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000" enterkeyhint="enter"></textarea> <textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000" enterkeyhint="enter"></textarea>
<input id="chat-file-input" type="file" hidden />
<div class="dm-actions-col"> <div class="dm-actions-col">
<button class="ghost-btn dm-file-btn" type="button" id="chat-file-toggle" aria-label="Прикрепить файл" title="Прикрепить файл" hidden>📎</button>
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"><span class="dm-emoji-btn__glyph"></span></button> <button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"><span class="dm-emoji-btn__glyph"></span></button>
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить"></button> <button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить"></button>
</div> </div>
@@ -1127,6 +1179,9 @@ export function render({ navigate, route, chrome }) {
const input = form.elements.message; const input = form.elements.message;
const emojiSlot = form.querySelector('#chat-emoji-picker-slot'); const emojiSlot = form.querySelector('#chat-emoji-picker-slot');
const emojiToggle = form.querySelector('#chat-emoji-toggle'); 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 editBanner = form.querySelector('#chat-edit-banner');
const editBannerText = form.querySelector('#chat-edit-banner-text'); const editBannerText = form.querySelector('#chat-edit-banner-text');
const editCancelBtn = form.querySelector('#chat-edit-cancel'); const editCancelBtn = form.querySelector('#chat-edit-cancel');
@@ -1135,6 +1190,9 @@ export function render({ navigate, route, chrome }) {
let inputFocused = false; let inputFocused = false;
let emojiPickerOpen = false; let emojiPickerOpen = false;
let emojiSelection = null; let emojiSelection = null;
let emojiLongPressTimer = null;
let emojiLongPressTriggered = false;
let fileActionVisible = false;
const setHistoryLoadingState = (isLoading) => { const setHistoryLoadingState = (isLoading) => {
historyLoader.hidden = !isLoading; historyLoader.hidden = !isLoading;
@@ -1162,6 +1220,19 @@ export function render({ navigate, route, chrome }) {
emojiToggle?.setAttribute('aria-expanded', 'false'); 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 поднимает клавиатуру поверх пикера — // На сенсорных устройствах фокус в textarea поднимает клавиатуру поверх пикера —
// поэтому при вставке эмодзи фокусируем поле только там, где есть «точный» указатель. // поэтому при вставке эмодзи фокусируем поле только там, где есть «точный» указатель.
const isFinePointer = window.matchMedia?.('(hover: hover) and (pointer: fine)')?.matches ?? true; 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 handleOpenActions = (msg, event) => {
const parsed = parseDmTechBlocks(String(msg?.text || '')); const parsed = parseDmTechBlocks(String(msg?.text || ''));
const readState = resolveEffectiveReadState(getChatMessages(chatId), msg); const readState = resolveEffectiveReadState(getChatMessages(chatId), msg);
@@ -1465,7 +1647,7 @@ export function render({ navigate, route, chrome }) {
: '', : '',
canReply: true, canReply: true,
showReadAloud: isTextToSpeechReady, 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: ( canDelete: (
(msg?.from === 'out' && Number(msg?.messageType || 0) === 2) (msg?.from === 'out' && Number(msg?.messageType || 0) === 2)
|| (msg?.from === 'in' && Number(msg?.messageType || 0) === 1) || (msg?.from === 'in' && Number(msg?.messageType || 0) === 1)
@@ -1577,9 +1759,38 @@ export function render({ navigate, route, chrome }) {
emojiToggle?.setAttribute('aria-expanded', 'false'); emojiToggle?.setAttribute('aria-expanded', 'false');
emojiToggle?.addEventListener('pointerdown', (event) => { emojiToggle?.addEventListener('pointerdown', (event) => {
rememberEmojiSelection(); 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(); 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; emojiPickerOpen = !emojiPickerOpen;
emojiSlot.hidden = !emojiPickerOpen; emojiSlot.hidden = !emojiPickerOpen;
emojiToggle.setAttribute('aria-expanded', String(emojiPickerOpen)); emojiToggle.setAttribute('aria-expanded', String(emojiPickerOpen));
@@ -1587,9 +1798,18 @@ export function render({ navigate, route, chrome }) {
// открытии пикера прячем клавиатуру, чтобы поле ввода оставалось видно. // открытии пикера прячем клавиатуру, чтобы поле ввода оставалось видно.
if (emojiPickerOpen && !isFinePointer) input?.blur(); 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) => { screen.addEventListener('pointerdown', (event) => {
if (!emojiPickerOpen || form.contains(event.target)) return; if (form.contains(event.target)) return;
closeEmojiPicker(); if (emojiPickerOpen) closeEmojiPicker();
if (fileActionVisible) setFileActionVisible(false);
}); });
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => { form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
event.preventDefault(); event.preventDefault();
@@ -1658,6 +1878,7 @@ export function render({ navigate, route, chrome }) {
screen.cleanup = () => { screen.cleanup = () => {
hideUnreadSeparator({ rerender: false }); hideUnreadSeparator({ rerender: false });
stopAllTwemojiAnimations(); stopAllTwemojiAnimations();
clearEmojiLongPress();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh); window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll); boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
+25 -3
View File
@@ -1,5 +1,6 @@
import { createTopBar } from '../components/topbar.js'; import { createTopBar } from '../components/topbar.js';
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js'; import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
import { isDeveloperToolsEnabled } from '../services/feature-settings.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' }; export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -35,6 +36,24 @@ export function render({navigate, chrome}) {
back: { label: '←', onClick: () => navigate('profile-view') }, 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 = '<img class="settings-shine-logo" src="/img/shine-logo-transparent-final_big.png" alt="SHiNE" />';
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'); const card = document.createElement('div');
card.className = 'card stack'; card.className = 'card stack';
card.innerHTML = ` card.innerHTML = `
@@ -93,6 +112,9 @@ export function render({navigate, chrome}) {
} }
}); });
screen.append(logoButton, card);
if (isDeveloperToolsEnabled()) {
const versionCard = document.createElement('div'); const versionCard = document.createElement('div');
versionCard.className = 'card stack'; versionCard.className = 'card stack';
@@ -143,11 +165,11 @@ export function render({navigate, chrome}) {
} }
})(); })();
screen.append(versionCard, developerCard);
}
screen.cleanup = () => { screen.cleanup = () => {
isDisposed = true; isDisposed = true;
}; };
screen.append(card);
screen.append(versionCard);
screen.append(developerCard);
return screen; return screen;
} }
+3
View File
@@ -36,6 +36,7 @@ const PRETTY_PATHS = new Map([
['server-settings-view', 'settings/servers'], ['server-settings-view', 'settings/servers'],
['arweave-uploads-view', 'settings/arweave-uploads'], ['arweave-uploads-view', 'settings/arweave-uploads'],
['developer-settings-view', 'settings/developer'], ['developer-settings-view', 'settings/developer'],
['advanced-settings-view', 'settings/advanced'],
['trusted-device-login-settings-view', 'settings/device-login'], ['trusted-device-login-settings-view', 'settings/device-login'],
['language-view', 'settings/language'], ['language-view', 'settings/language'],
['app-log-view', 'settings/app-log'], ['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 === 'servers') return { pageId: 'server-settings-view', params: {} };
if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} }; if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} };
if (sub === 'developer') return { pageId: 'developer-settings-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 === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
if (sub === 'language') return { pageId: 'language-view', params: {} }; if (sub === 'language') return { pageId: 'language-view', params: {} };
if (sub === 'app-log') return { pageId: 'app-log-view', params: {} }; if (sub === 'app-log') return { pageId: 'app-log-view', params: {} };
@@ -447,6 +449,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'settings-view' || pageId === 'settings-view' ||
pageId === 'access-servers-view' || pageId === 'access-servers-view' ||
pageId === 'developer-settings-view' || pageId === 'developer-settings-view' ||
pageId === 'advanced-settings-view' ||
pageId === 'server-settings-view' || pageId === 'server-settings-view' ||
pageId === 'arweave-uploads-view' || pageId === 'arweave-uploads-view' ||
pageId === 'remote-addblock-session-view' || pageId === 'remote-addblock-session-view' ||
+204
View File
@@ -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);
}
+50
View File
@@ -8,6 +8,7 @@ function defaultParsed(rawText = '') {
blocks: [], blocks: [],
replyRef: null, replyRef: null,
callSummary: null, callSummary: null,
fileAttachment: null,
}; };
} }
@@ -87,6 +88,51 @@ export function buildDmReplyTechBlock({ baseKey = '' } = {}) {
return `<S:reply;v=1;id=${cleanBaseKey}>`; return `<S:reply;v=1;id=${cleanBaseKey}>`;
} }
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 `<S:file;v=1;id=${id};url=${encodedUrl};key=${keyB64Url};iv=${ivB64Url};name=${name};mime=${mime};size=${size};encsize=${encryptedSize}>`;
}
export function parseDmTechBlocks(rawText = '') { export function parseDmTechBlocks(rawText = '') {
const text = String(rawText || ''); const text = String(rawText || '');
if (!hasTechPrefix(text)) return defaultParsed(text); if (!hasTechPrefix(text)) return defaultParsed(text);
@@ -95,6 +141,7 @@ export function parseDmTechBlocks(rawText = '') {
let cursor = 0; let cursor = 0;
let replyRef = null; let replyRef = null;
let callSummary = null; let callSummary = null;
let fileAttachment = null;
while (hasTechPrefix(text, cursor)) { while (hasTechPrefix(text, cursor)) {
const end = text.indexOf('>', cursor); const end = text.indexOf('>', cursor);
@@ -142,6 +189,8 @@ export function parseDmTechBlocks(rawText = '') {
reason: String(fields.reason || '').trim().toLowerCase(), reason: String(fields.reason || '').trim().toLowerCase(),
}; };
} }
} else if (kind === 'file' && !fileAttachment) {
fileAttachment = normalizeFileAttachment(fields);
} }
cursor = end + 1; cursor = end + 1;
@@ -157,5 +206,6 @@ export function parseDmTechBlocks(rawText = '') {
blocks, blocks,
replyRef, replyRef,
callSummary, callSummary,
fileAttachment,
}; };
} }
+43
View File
@@ -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);
}
+98
View File
@@ -1275,3 +1275,101 @@
text-shadow: 0 0 5px rgba(92, 190, 255, 0.72), 0 0 12px rgba(72, 145, 255, 0.34); 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)); 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;
}
+1
View File
@@ -404,6 +404,7 @@ button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); }
.dm-actions-col { .dm-actions-col {
position: relative;
display: grid; display: grid;
grid-template-columns: repeat(2, 46px); grid-template-columns: repeat(2, 46px);
grid-template-rows: 46px; grid-template-rows: 46px;
+110
View File
@@ -270,3 +270,113 @@
} }
.language-choice-actions .shine-btn { height: 50px; } .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;
}