diff --git a/-- удалить javac.20260920_155256.args b/-- удалить javac.20260920_155256.args new file mode 100644 index 00000000..5d0cf8ff --- /dev/null +++ b/-- удалить javac.20260920_155256.args @@ -0,0 +1,30 @@ +-proc:none +-d +/tmp/javac-check +-sourcepath +SHiNE-server/shine-server-geo/src/main/java:SHiNE-server/shine-server-log/src/main/java:SHiNE-server/shine-server-solana-users-sync/src/main/java:SHiNE-server/shine-server-db/src/main/java:SHiNE-server/src/main/java:SHiNE-server/shine-server-archive/src/main/java:SHiNE-server/shine-server-blockchain/src/main/java:SHiNE-server/shine-server-crypto/src/main/java:SHiNE-server/shine-server-net-protocol/src/main/java:SHiNE-server/shine-server-config/src/main/java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java +/mnt/data/shine_patch_final_work/SHiNE-server/src/main/java/server/ws/WsServer.java +/mnt/data/shine_patch_final_work/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java +/mnt/data/shine_patch_final_work/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java +/mnt/data/shine_patch_final_work/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java diff --git a/.gitignore b/.gitignore index 29bcddec..34986288 100644 --- a/.gitignore +++ b/.gitignore @@ -105,6 +105,7 @@ ESP32/**/*.a # Полные серверные бэкапы (тяжёлые архивы, не коммитим) deploy/backup/archive/** !deploy/backup/archive/.gitkeep +SHiNE-bundle-*.zip # Локальная дев-обвязка AI-агентов (сессии, планы, настройки) — не коммитим .agents/ diff --git a/PATCH_MANIFEST.md b/PATCH_MANIFEST.md new file mode 100644 index 00000000..14d6eeb6 --- /dev/null +++ b/PATCH_MANIFEST.md @@ -0,0 +1,29 @@ +# SHiNE ANS-104 `test5590` patch + +Этот архив содержит **только новые/изменённые файлы** плюс `DELETE_FILES.txt`. + +## Что меняется + +- Frame v1 без поддержки старого формата. +- Один пользовательский block = готовый Ed25519 ANS-104 DataItem. +- Test tag: `App=test5590`; channel tag: `c=`. +- SHiNE `prevHash` остаётся `SHA-256(FrameV1Bytes)` и не зависит от Arweave ID. +- PostgreSQL — единственное локальное хранилище пользовательских blocks. +- Старый custom SHINE-ARCHIVE и filesystem blockchain storage удаляются. +- Каждые ~15 минут локальные pending DataItems собираются в standard ANS-104 bundle и идут через существующий native Arweave L1 transport. +- Другие серверы находят `App=test5590`, импортируют и не публикуют повторно. +- `ESP32/**` **не изменён**. + +## Состав + +- изменено/добавлено файлов: 46 +- удалить файлов: 41 + +Применение и проверки: `docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md`. + +## Проверки при сборке patch + +- `node --check` для изменённых production JS: OK. +- `diff -qr` каталога `ESP32` относительно исходного архива: без изменений. +- Active Java refs на удалённые SHINE-ARCHIVE/file-recovery classes: отсутствуют. +- `./gradlew testClasses --offline --no-daemon`: не запущен до компиляции, потому что wrapper требует скачать Gradle 8.14 с `services.gradle.org`, а рабочая среда не имеет внешнего сетевого доступа. Полный Gradle test обязателен после применения patch в dev-среде. diff --git a/SHiNE-server/shine-server-archive/build.gradle b/SHiNE-server/shine-server-archive/build.gradle new file mode 100644 index 00000000..dca78bdc --- /dev/null +++ b/SHiNE-server/shine-server-archive/build.gradle @@ -0,0 +1,31 @@ +plugins { + id 'java' +} + +group = 'shine' +version = '1.0.0' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +repositories { mavenCentral() } + +dependencies { + implementation project(':shine-server-config') + implementation project(':shine-server-db') + implementation project(':shine-server-crypto') + implementation project(':shine-server-blockchain') + implementation project(':shine-server-net-protocol') + implementation project(':shine-server-solana-users-sync') + + implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2' + implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' + implementation 'org.slf4j:slf4j-api:2.0.16' + + testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0' +} + +test { useJUnitPlatform() } diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java new file mode 100644 index 00000000..20977bb7 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/Ans104Bundle.java @@ -0,0 +1,103 @@ +package server.archive; + +import blockchain.Ans104DataItem; + +import java.io.ByteArrayOutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** Minimal ANS-104 binary bundle writer/reader. */ +public final class Ans104Bundle { + public record Entry(byte[] id32, byte[] rawDataItem) {} + + private Ans104Bundle() {} + + public static byte[] write(List rawItems) { + if (rawItems == null || rawItems.isEmpty()) throw new IllegalArgumentException("ANS-104 bundle is empty"); + if (rawItems.size() > 1_000_000) throw new IllegalArgumentException("Too many ANS-104 items"); + + List parsed = new ArrayList<>(rawItems.size()); + long total = 32L + 64L * rawItems.size(); + for (byte[] raw : rawItems) { + Ans104DataItem item = new Ans104DataItem(raw); + if (!item.verifySignature()) throw new IllegalArgumentException("Invalid ANS-104 DataItem signature"); + parsed.add(item); + total = Math.addExact(total, raw.length); + } + if (total > Integer.MAX_VALUE) throw new IllegalArgumentException("Bundle too large for in-memory publisher: " + total); + + ByteArrayOutputStream out = new ByteArrayOutputStream((int) total); + out.writeBytes(uint256LE(rawItems.size())); + for (int i = 0; i < rawItems.size(); i++) { + out.writeBytes(uint256LE(rawItems.get(i).length)); + out.writeBytes(parsed.get(i).id32()); + } + for (byte[] raw : rawItems) out.writeBytes(raw); + return out.toByteArray(); + } + + public static List read(byte[] bundle, int maxItems) { + if (bundle == null || bundle.length < 32) throw new IllegalArgumentException("ANS-104 bundle too short"); + long countLong = readUint256AsLong(bundle, 0); + if (countLong <= 0 || countLong > Math.max(1, maxItems)) { + throw new IllegalArgumentException("Bad ANS-104 bundle item count: " + countLong); + } + int count = (int) countLong; + long headerLong = 32L + 64L * count; + if (headerLong > bundle.length) throw new IllegalArgumentException("ANS-104 bundle header truncated"); + int header = (int) headerLong; + long[] sizes = new long[count]; + byte[][] ids = new byte[count][]; + int hp = 32; + long payloadBytes = 0; + for (int i = 0; i < count; i++) { + sizes[i] = readUint256AsLong(bundle, hp); hp += 32; + ids[i] = Arrays.copyOfRange(bundle, hp, hp + 32); hp += 32; + if (sizes[i] <= 0 || sizes[i] > Integer.MAX_VALUE) throw new IllegalArgumentException("Bad DataItem size: " + sizes[i]); + payloadBytes = Math.addExact(payloadBytes, sizes[i]); + } + if (headerLong + payloadBytes != bundle.length) { + throw new IllegalArgumentException("ANS-104 bundle size mismatch: header=" + headerLong + " payload=" + payloadBytes + " actual=" + bundle.length); + } + List out = new ArrayList<>(count); + int p = header; + for (int i = 0; i < count; i++) { + int len = (int) sizes[i]; + byte[] raw = Arrays.copyOfRange(bundle, p, p + len); + p += len; + Ans104DataItem item = new Ans104DataItem(raw); + if (!Arrays.equals(ids[i], item.id32())) throw new IllegalArgumentException("Bundle entry ID mismatch at index " + i); + out.add(new Entry(ids[i], raw)); + } + return out; + } + + public static byte[] find(byte[] bundle, byte[] wantedId32, int maxItems) { + if (wantedId32 == null || wantedId32.length != 32) throw new IllegalArgumentException("wanted ID must be 32 bytes"); + for (Entry e : read(bundle, maxItems)) { + if (Arrays.equals(e.id32(), wantedId32)) return e.rawDataItem(); + } + return null; + } + + /** ANS-104 bundle integers are 32-byte unsigned little-endian values. */ + static byte[] uint256LE(long value) { + if (value < 0) throw new IllegalArgumentException("negative uint256"); + byte[] out = new byte[32]; + long v = value; + for (int i = 0; i < 8; i++) { out[i] = (byte) (v & 0xff); v >>>= 8; } + return out; + } + + static long readUint256AsLong(byte[] data, int offset) { + if (offset < 0 || offset + 32 > data.length) throw new IllegalArgumentException("uint256 truncated"); + for (int i = offset + 8; i < offset + 32; i++) { + if (data[i] != 0) throw new IllegalArgumentException("uint256 exceeds signed long range"); + } + long v = 0; + for (int i = 7; i >= 0; i--) v = (v << 8) | (data[offset + i] & 0xffL); + if (v < 0) throw new IllegalArgumentException("uint256 exceeds signed long range"); + return v; + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java new file mode 100644 index 00000000..4025ff08 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherScheduler.java @@ -0,0 +1,37 @@ +package server.archive; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Fixed-delay publisher. Empty cycles are no-ops. */ +public final class ArweaveBlockPublisherScheduler { + private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherScheduler.class); + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static ScheduledExecutorService executor; + + private ArweaveBlockPublisherScheduler() {} + + public static void startOrLog() { + ArweaveBlocksConfig cfg; + try { cfg = ArweaveBlocksConfig.load(); cfg.validatePublisher(); } + catch (Exception e) { log.error("Cannot read/validate Arweave block publisher config", e); return; } + if (!cfg.publishEnabled()) { log.info("Arweave user-block publisher disabled (mode=none)"); return; } + if (!STARTED.compareAndSet(false,true)) return; + try { + ArweaveBlockPublisherService service = new ArweaveBlockPublisherService(cfg); + executor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "shine-arweave-block-publisher"); t.setDaemon(true); return t; + }); + Runnable task = () -> { try { service.runCycle(); } catch (Exception e) { log.error("Arweave block publish cycle failed", e); } }; + executor.scheduleWithFixedDelay(task, 0, cfg.publishIntervalMinutes(), TimeUnit.MINUTES); + log.info("Arweave user-block publisher enabled: mode={} interval={}m", cfg.publishMode(), cfg.publishIntervalMinutes()); + } catch (Exception e) { STARTED.set(false); log.error("Arweave user-block publisher failed to start", e); } + } + + public static void close() { ScheduledExecutorService e=executor; if(e!=null)e.shutdownNow(); } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java new file mode 100644 index 00000000..4b979459 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockPublisherService.java @@ -0,0 +1,93 @@ +package server.archive; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import shine.db.dao.BlocksDAO; +import shine.db.entities.BlockEntry; + +import java.util.ArrayList; +import java.util.List; + +/** Publishes locally-created signed user DataItems through the configured transport. */ +public final class ArweaveBlockPublisherService { + private static final Logger log = LoggerFactory.getLogger(ArweaveBlockPublisherService.class); + + private final ArweaveBlocksConfig cfg; + private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); + private final ArweaveL1Uploader arweaveUploader; + private final TurboDataItemUploader turboUploader; + + public ArweaveBlockPublisherService(ArweaveBlocksConfig cfg) { + this.cfg = cfg; + this.arweaveUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.ARWEAVE ? new ArweaveL1Uploader(cfg) : null; + this.turboUploader = cfg.publishMode() == ArweaveBlocksConfig.PublishMode.TURBO ? new TurboDataItemUploader(cfg) : null; + } + + public int runCycle() throws Exception { + if (cfg.publishMode() == ArweaveBlocksConfig.PublishMode.NONE) return 0; + List candidates = blocksDAO.listPendingArweave(cfg.publishMaxItems()); + if (candidates.isEmpty()) return 0; + return switch (cfg.publishMode()) { + case TURBO -> publishTurbo(candidates); + case ARWEAVE -> publishDirectArweave(candidates); + case NONE -> 0; + }; + } + + private int publishTurbo(List candidates) throws Exception { + int published = 0; + Exception firstFailure = null; + for (BlockEntry e : candidates) { + byte[] raw = e.getBlockBytes(); + byte[] id = e.getDataItemId(); + if (raw == null || raw.length == 0 || id == null || id.length != 32) continue; + try { + TurboDataItemUploader.UploadResult result = turboUploader.upload(raw, id); + blocksDAO.markArweavePublished(List.of(id), System.currentTimeMillis()); + published++; + log.debug("Turbo published SHiNE DataItem {} chain={} block={}", result.dataItemId(), e.getBchName(), e.getBlockNumber()); + } catch (Exception ex) { + if (firstFailure == null) firstFailure = ex; + log.warn("Turbo publish failed: chain={} block={} bytes={} error={}", + e.getBchName(), e.getBlockNumber(), raw.length, ex.getMessage()); + } + } + if (published > 0) log.info("Published {} SHiNE test DataItems through Turbo", published); + if (published == 0 && firstFailure != null) throw firstFailure; + return published; + } + + private int publishDirectArweave(List candidates) throws Exception { + List items = new ArrayList<>(); + List ids = new ArrayList<>(); + long estimated = 32; + for (BlockEntry e : candidates) { + byte[] raw = e.getBlockBytes(); + byte[] id = e.getDataItemId(); + if (raw == null || raw.length == 0 || id == null || id.length != 32) continue; + long next = estimated + 64L + raw.length; + if (!items.isEmpty() && next > cfg.publishMaxBundleBytes()) break; + if (next > cfg.publishMaxBundleBytes()) { + log.error("One DataItem exceeds arweave.blocks.publish.maxBundleBytes: chain={} block={} bytes={}", + e.getBchName(), e.getBlockNumber(), raw.length); + continue; + } + items.add(raw); + ids.add(id); + estimated = next; + } + if (items.isEmpty()) return 0; + + byte[] bundle = Ans104Bundle.write(items); + List rootTags = List.of( + new ArweaveL1Uploader.Tag("Content-Type", "application/octet-stream"), + new ArweaveL1Uploader.Tag("Bundle-Format", "binary"), + new ArweaveL1Uploader.Tag("Bundle-Version", "2.0.0") + ); + ArweaveL1Uploader.UploadResult result = arweaveUploader.upload(bundle, rootTags); + blocksDAO.markArweavePublished(ids, System.currentTimeMillis()); + log.info("Published {} SHiNE test DataItems in direct Arweave root tx {} (bundle={} bytes)", + items.size(), result.txId(), bundle.length); + return items.size(); + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java new file mode 100644 index 00000000..b5c1369b --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncScheduler.java @@ -0,0 +1,32 @@ +package server.archive; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class ArweaveBlockSyncScheduler { + private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncScheduler.class); + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static ScheduledExecutorService executor; + private ArweaveBlockSyncScheduler() {} + + public static void startOrLog() { + ArweaveBlocksConfig cfg; + try { cfg = ArweaveBlocksConfig.load(); } + catch (Exception e) { log.error("Cannot read Arweave block sync config", e); return; } + if (!cfg.syncEnabled()) { log.info("Arweave user-block sync disabled"); return; } + if (!STARTED.compareAndSet(false,true)) return; + try { + ArweaveBlockSyncService service = new ArweaveBlockSyncService(cfg); + executor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t=new Thread(r,"shine-arweave-block-sync");t.setDaemon(true);return t; }); + Runnable task=()->{try{service.runCycle();}catch(Exception e){log.error("Arweave block sync cycle failed",e);}}; + executor.scheduleWithFixedDelay(task,0,cfg.syncIntervalMinutes(),TimeUnit.MINUTES); + log.info("Arweave user-block sync enabled: interval={}m gateway={}",cfg.syncIntervalMinutes(),cfg.syncGateway()); + } catch (Exception e) { STARTED.set(false); log.error("Arweave user-block sync failed to start",e); } + } + public static void close(){ScheduledExecutorService e=executor;if(e!=null)e.shutdownNow();} +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java new file mode 100644 index 00000000..2c249d29 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlockSyncService.java @@ -0,0 +1,252 @@ +package server.archive; + +import blockchain.Ans104DataItem; +import blockchain.BchBlockEntry; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.handlers.blockchain.Net_AddBlock_Handler; +import shine.db.dao.ArweaveBlockImportDAO; +import shine.db.dao.BlockchainStateDAO; +import shine.db.dao.BlocksDAO; +import shine.db.dao.SolanaUserPdaCurrentDAO; +import shine.db.entities.BlockchainStateEntry; +import shine.db.entities.SolanaUserPdaCurrentEntry; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; + +/** + * Discovers individual App=test5590 DataItems and imports the exact signed ANS-104 bytes. + * The importer is transport-agnostic: a DataItem may have reached Arweave through Turbo + * or inside a direct server-created ANS-104 bundle. + */ +public final class ArweaveBlockSyncService { + private static final Logger log = LoggerFactory.getLogger(ArweaveBlockSyncService.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Base64.Decoder B64URL = Base64.getUrlDecoder(); + + private final ArweaveBlocksConfig cfg; + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(20)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + private final ArweaveBlockImportDAO importDAO = ArweaveBlockImportDAO.getInstance(); + private final BlocksDAO blocksDAO = BlocksDAO.getInstance(); + private final SolanaUserPdaCurrentDAO usersDAO = SolanaUserPdaCurrentDAO.getInstance(); + private final BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); + private final Net_AddBlock_Handler addBlock = new Net_AddBlock_Handler(); + + public ArweaveBlockSyncService(ArweaveBlocksConfig cfg) { this.cfg = cfg; } + + public void runCycle() throws Exception { + discover(); + drainQueue(); + } + + private void discover() throws Exception { + long stored = importDAO.getLastBlockHeight(); + long minHeight = Math.max(cfg.syncStartBlockHeight(), stored); + String cursor = null; + long maxHeight = minHeight; + long lowestRetryHeight = Long.MAX_VALUE; + int discovered = 0; + + do { + JsonNode response = graphQlPage(minHeight, cursor); + JsonNode txs = response.path("data").path("transactions"); + if (response.has("errors")) throw new IOException("Arweave GraphQL errors: " + response.path("errors")); + JsonNode edges = txs.path("edges"); + if (!edges.isArray()) throw new IOException("Arweave GraphQL returned no transactions.edges"); + + String nextCursor = null; + for (JsonNode edge : edges) { + nextCursor = edge.path("cursor").asText(null); + JsonNode node = edge.path("node"); + String dataItemId = node.path("id").asText("").trim(); + long height = node.path("block").path("height").asLong(-1L); + if (dataItemId.isBlank() || height < 0) continue; + maxHeight = Math.max(maxHeight, height); + + byte[] id32; + try { + id32 = B64URL.decode(dataItemId); + if (id32.length != 32) throw new IllegalArgumentException("id length=" + id32.length); + } catch (Exception e) { + log.warn("Ignoring malformed Arweave DataItem id {}: {}", dataItemId, e.getMessage()); + continue; + } + if (blocksDAO.existsByDataItemId(id32) || importDAO.exists(id32)) continue; + + try { + byte[] rawDataItem = downloadSignedDataItem(dataItemId); + if (importDAO.enqueueIfMissing(id32, height, rawDataItem, System.currentTimeMillis())) discovered++; + } catch (Exception e) { + lowestRetryHeight = Math.min(lowestRetryHeight, height); + log.warn("Cannot retrieve signed DataItem {} at height {} yet: {}", dataItemId, height, e.getMessage()); + } + } + boolean hasNext = txs.path("pageInfo").path("hasNextPage").asBoolean(false); + cursor = hasNext ? nextCursor : null; + if (hasNext && (cursor == null || cursor.isBlank())) throw new IOException("GraphQL hasNextPage without cursor"); + } while (cursor != null); + + // Keep inclusive overlap. If a gateway has indexed GraphQL before offsets, do not advance past that item. + long checkpoint = lowestRetryHeight == Long.MAX_VALUE ? maxHeight : Math.min(maxHeight, lowestRetryHeight); + importDAO.setLastBlockHeight(checkpoint, System.currentTimeMillis()); + if (discovered > 0) { + log.info("Arweave discovery queued {} new SHiNE test DataItems; checkpoint={}", discovered, checkpoint); + } + } + + private void drainQueue() throws Exception { + int passes = 0; + int importedTotal = 0; + boolean progress; + do { + progress = false; + List pending = importDAO.listPending(cfg.syncQueueBatchSize()); + if (pending.isEmpty()) break; + for (ArweaveBlockImportDAO.QueueItem q : pending) { + Ans104DataItem item; + try { + item = new Ans104DataItem(q.rawDataItem()); + if (!Arrays.equals(item.id32(), q.dataItemId())) throw new IllegalArgumentException("data_item_id mismatch"); + if (!item.hasTag(ArweaveBlocksConfig.TEST_TAG_NAME, ArweaveBlocksConfig.TEST_TAG_VALUE)) throw new IllegalArgumentException("bad App tag"); + if (!item.verifySignature()) throw new IllegalArgumentException("bad ANS-104 signature"); + new BchBlockEntry(q.rawDataItem()); + } catch (Exception e) { + importDAO.reject(q.dataItemId(), "invalid_data_item: " + e.getMessage(), System.currentTimeMillis()); + continue; + } + + SolanaUserPdaCurrentEntry user = usersDAO.getByBlockchainKey(item.owner32()); + if (user == null || user.getBlockchainName() == null || user.getBlockchainName().isBlank()) { + importDAO.setPendingError(q.dataItemId(), "unknown_owner", System.currentTimeMillis()); + continue; + } + ensureState(user); + Net_AddBlock_Handler.ArweaveImportResult result = addBlock.addBlockFromArweave(user.getBlockchainName(), q.rawDataItem()); + if (result.ok()) { + importDAO.delete(q.dataItemId()); + importedTotal++; progress = true; + continue; + } + String reason = result.reasonCode(); + if ("bad_block_number".equals(reason) || "bad_prev_hash".equals(reason) + || "prev_line_block_not_found".equals(reason) || "channel_not_found".equals(reason) + || "chain_resync_in_progress".equals(reason)) { + importDAO.setPendingError(q.dataItemId(), reason, System.currentTimeMillis()); + } else { + importDAO.reject(q.dataItemId(), reason, System.currentTimeMillis()); + } + } + passes++; + } while (progress && passes < 8); + if (importedTotal > 0) log.info("Imported {} SHiNE test DataItems from Arweave", importedTotal); + } + + private void ensureState(SolanaUserPdaCurrentEntry user) throws Exception { + BlockchainStateEntry existing = stateDAO.getByBlockchainName(user.getBlockchainName()); + if (existing != null) return; + BlockchainStateEntry s = new BlockchainStateEntry(); + s.setBlockchainName(user.getBlockchainName()); s.setLogin(user.getLogin()); s.setBlockchainKey(user.getBlockchainKey()); + s.setSizeLimit(user.getPaidLimitBytes() > 0 ? user.getPaidLimitBytes() : 100_000L); + s.setFileSizeBytes(0L); s.setLastBlockNumber(-1); s.setLastBlockHash(null); s.setUpdatedAtMs(System.currentTimeMillis()); + stateDAO.insertIfMissing(s); + } + + private JsonNode graphQlPage(long minHeight, String cursor) throws Exception { + String after = cursor == null ? "null" : "\"" + escapeGraphQl(cursor) + "\""; + String query = "query { transactions(tags:[{name:\"" + ArweaveBlocksConfig.TEST_TAG_NAME + "\",values:[\"" + + ArweaveBlocksConfig.TEST_TAG_VALUE + "\"]}], block:{min:" + minHeight + "}, first:" + + cfg.syncPageSize() + ", after:" + after + + ", sort:HEIGHT_ASC) { pageInfo { hasNextPage } edges { cursor node { id block { height } } } } }"; + String body = MAPPER.writeValueAsString(Map.of("query", query)); + HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/graphql")) + .timeout(Duration.ofSeconds(60)).header("Content-Type","application/json").header("Accept","application/json") + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) throw new IOException("GraphQL HTTP " + resp.statusCode()); + return MAPPER.readTree(resp.body()); + } + + /** + * Gateways normally expose only the payload at /{dataItemId}. SHiNE needs the complete signed + * DataItem, so obtain its exact offset/size inside the root L1 transaction and range-read it. + */ + private byte[] downloadSignedDataItem(String dataItemId) throws Exception { + JsonNode offsets = getOffsets(dataItemId); + String rootTxId = offsets.path("rootTxId").asText("").trim(); + long rootOffset = offsets.path("rootOffset").asLong(-1L); + long size = offsets.path("size").asLong(-1L); + if (rootTxId.isBlank() || rootOffset < 0 || size <= 0) { + throw new IOException("Bad AR.IO offsets for " + dataItemId + ": " + offsets); + } + if (size > cfg.syncMaxDataItemBytes()) { + throw new IOException("DataItem exceeds syncMaxDataItemBytes: " + size); + } + if (rootOffset > Long.MAX_VALUE - size) throw new IOException("DataItem offset overflow"); + long endInclusive = rootOffset + size - 1; + + HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/raw/" + rootTxId)) + .timeout(Duration.ofMinutes(2)) + .header("Range", "bytes=" + rootOffset + "-" + endInclusive) + .header("Accept", "application/octet-stream") + .GET().build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream()); + try (InputStream in = resp.body()) { + if (resp.statusCode() != 206) { + throw new IOException("Gateway ignored root range for DataItem " + dataItemId + ": HTTP " + resp.statusCode()); + } + byte[] bytes = readExactlyBounded(in, (int) size); + if (bytes.length != size) throw new IOException("Truncated DataItem range: expected=" + size + " got=" + bytes.length); + Ans104DataItem parsed = new Ans104DataItem(bytes); + byte[] expectedId = B64URL.decode(dataItemId); + if (!Arrays.equals(parsed.id32(), expectedId)) { + throw new IOException("Range returned another ANS-104 DataItem for " + dataItemId); + } + return bytes; + } + } + + private JsonNode getOffsets(String dataItemId) throws Exception { + HttpRequest req = HttpRequest.newBuilder(URI.create(trim(cfg.syncGateway()) + "/ar-io/offsets/" + dataItemId)) + .timeout(Duration.ofSeconds(30)).header("Accept","application/json").GET().build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (resp.statusCode() == 404) throw new IOException("AR.IO offsets not indexed yet"); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + throw new IOException("AR.IO offsets HTTP " + resp.statusCode() + ": " + safe(resp.body())); + } + return MAPPER.readTree(resp.body()); + } + + private static byte[] readExactlyBounded(InputStream in, int expected) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(expected); + byte[] buf = new byte[Math.min(64 * 1024, Math.max(1024, expected))]; + int remaining = expected; + while (remaining > 0) { + int n = in.read(buf, 0, Math.min(buf.length, remaining)); + if (n < 0) break; + out.write(buf, 0, n); + remaining -= n; + } + return out.toByteArray(); + } + + private static String safe(String value) { + String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim(); + return v.length() <= 500 ? v : v.substring(0, 500); + } + private static String trim(String s){return String.valueOf(s==null?"":s).trim().replaceAll("/+$","");} + private static String escapeGraphQl(String s){return s.replace("\\","\\\\").replace("\"","\\\"");} +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java new file mode 100644 index 00000000..f97790c6 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveBlocksConfig.java @@ -0,0 +1,99 @@ +package server.archive; + +import blockchain.Ans104DataItem; +import utils.config.AppConfig; + +import java.nio.file.Path; +import java.util.Locale; + +/** Configuration of per-user-block ANS-104 Arweave/Turbo transport. */ +public record ArweaveBlocksConfig( + PublishMode publishMode, + int publishIntervalMinutes, + int publishMaxItems, + long publishMaxBundleBytes, + String publishGateway, + Path walletJwkPath, + int minConfirmations, + int confirmPollSeconds, + int confirmTimeoutMinutes, + String turboUploadUrl, + String turboPaidByAddress, + Path turboWalletJwkPath, + boolean syncEnabled, + int syncIntervalMinutes, + int syncPageSize, + int syncQueueBatchSize, + long syncStartBlockHeight, + long syncMaxDataItemBytes, + String syncGateway +) { + public enum PublishMode { + TURBO, + ARWEAVE, + NONE; + + static PublishMode parse(String value) { + String normalized = value == null ? "none" : value.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "turbo" -> TURBO; + case "arweave" -> ARWEAVE; + case "none", "" -> NONE; + default -> throw new IllegalArgumentException( + "arweave.blocks.publish.mode must be turbo, arweave or none; got: " + value); + }; + } + } + + public static final String TEST_TAG_NAME = "App"; + public static final String TEST_TAG_VALUE = "test5590"; + public static final String CHANNEL_TAG_NAME = "c_test5590"; + + public static ArweaveBlocksConfig load() { + AppConfig c = AppConfig.getInstance(); + long maxDataItemBytes = parseLong(c.getParam("arweave.blocks.sync.maxDataItemBytes"), Ans104DataItem.MAX_DATA_ITEM_BYTES); + if (maxDataItemBytes > Ans104DataItem.MAX_DATA_ITEM_BYTES) maxDataItemBytes = Ans104DataItem.MAX_DATA_ITEM_BYTES; + return new ArweaveBlocksConfig( + PublishMode.parse(c.getParam("arweave.blocks.publish.mode")), + positive(c.getInt("arweave.blocks.publish.intervalMinutes", 15), "publish.intervalMinutes"), + positive(c.getInt("arweave.blocks.publish.maxItems", 10_000), "publish.maxItems"), + positiveLong(parseLong(c.getParam("arweave.blocks.publish.maxBundleBytes"), 128L * 1024 * 1024), "publish.maxBundleBytes"), + orDefault(c.getParam("arweave.blocks.publish.gateway"), "https://arweave.net"), + optionalPath(c.getParam("arweave.blocks.publish.walletJwkPath")), + nonNegative(c.getInt("arweave.blocks.publish.minConfirmations", 0), "publish.minConfirmations"), + positive(c.getInt("arweave.blocks.publish.confirmPollSeconds", 30), "publish.confirmPollSeconds"), + positive(c.getInt("arweave.blocks.publish.confirmTimeoutMinutes", 180), "publish.confirmTimeoutMinutes"), + orDefault(c.getParam("arweave.blocks.publish.turbo.uploadUrl"), "https://turbo.ardrive.io/tx"), + blankToNull(c.getParam("arweave.blocks.publish.turbo.paidByAddress")), + optionalPath(c.getParam("arweave.blocks.publish.turbo.walletJwkPath")), + c.getBoolean("arweave.blocks.sync.enabled", false), + positive(c.getInt("arweave.blocks.sync.intervalMinutes", 15), "sync.intervalMinutes"), + clamp(c.getInt("arweave.blocks.sync.pageSize", 100), 1, 100), + positive(c.getInt("arweave.blocks.sync.queueBatchSize", 10_000), "sync.queueBatchSize"), + nonNegativeLong(parseLong(c.getParam("arweave.blocks.sync.startBlockHeight"), 0L), "sync.startBlockHeight"), + positiveLong(maxDataItemBytes, "sync.maxDataItemBytes"), + orDefault(c.getParam("arweave.blocks.sync.gateway"), "https://turbo-gateway.com") + ); + } + + public boolean publishEnabled() { return publishMode != PublishMode.NONE; } + + public void validatePublisher() { + if (publishMode == PublishMode.ARWEAVE && walletJwkPath == null) { + throw new IllegalArgumentException("arweave.blocks.publish.walletJwkPath is required for mode=arweave"); + } + if (publishMode == PublishMode.TURBO && (turboUploadUrl == null || turboUploadUrl.isBlank())) { + throw new IllegalArgumentException("arweave.blocks.publish.turbo.uploadUrl is required for mode=turbo"); + } + } + + private static String orDefault(String v, String d) { return v == null || v.isBlank() ? d : v.trim(); } + private static String blankToNull(String v) { return v == null || v.isBlank() ? null : v.trim(); } + private static Path optionalPath(String v) { return v == null || v.isBlank() ? null : Path.of(v.trim()); } + private static long parseLong(String v, long d) { return v == null || v.isBlank() ? d : Long.parseLong(v.trim()); } + private static int positive(int v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;} + private static int nonNegative(int v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;} + private static long positiveLong(long v,String n){if(v<=0)throw new IllegalArgumentException(n+" must be >0");return v;} + private static long nonNegativeLong(long v,String n){if(v<0)throw new IllegalArgumentException(n+" must be >=0");return v;} + private static int clamp(int v,int min,int max){return Math.max(min,Math.min(max,v));} +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java new file mode 100644 index 00000000..db5ca59e --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveL1Uploader.java @@ -0,0 +1,141 @@ +package server.archive; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.math.BigInteger; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.*; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; +import java.security.spec.RSAPrivateCrtKeySpec; +import java.time.Duration; +import java.util.*; + +/** Existing native Arweave L1 transport adapted to upload in-memory ANS-104 bundles. */ +public final class ArweaveL1Uploader { + private static final Logger log = LoggerFactory.getLogger(ArweaveL1Uploader.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder B64D = Base64.getUrlDecoder(); + + public record Tag(String name, String value) {} + public record UploadResult(String txId, byte[] txIdBytes) {} + + private final ArweaveBlocksConfig cfg; + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)).build(); + private volatile Wallet wallet; + + public ArweaveL1Uploader(ArweaveBlocksConfig cfg) { this.cfg = Objects.requireNonNull(cfg); } + + public UploadResult upload(byte[] data, List tags) throws Exception { + if (data == null || data.length == 0) throw new IllegalArgumentException("Arweave upload data is empty"); + Wallet w = wallet(); + String gateway = trimGateway(cfg.publishGateway()); + ArweaveMerkle.Prepared merkle = ArweaveMerkle.prepare(data); + String anchor = getRequiredText(gateway, "/tx_anchor"); + String reward = getRequiredText(gateway, "/price/" + data.length); + if (!reward.matches("^\\d+$")) throw new IOException("Bad Arweave price: " + reward); + verifyBalance(gateway, w.address(), reward); + + List> tagList = new ArrayList<>(); + List> jsonTags = new ArrayList<>(); + for (Tag tag : tags == null ? List.of() : tags) addTag(jsonTags, tagList, tag.name(), tag.value()); + + byte[] signaturePayload = deepHash(List.of( + utf8("2"), b64Decode(w.owner()), new byte[0], utf8("0"), utf8(reward), + b64Decode(anchor), tagList, utf8(Integer.toString(data.length)), merkle.dataRoot() + )); + byte[] rawSignature = signPayload(w.privateKey(), signaturePayload); + byte[] txIdBytes = sha256(rawSignature); + String txId = b64(txIdBytes); + + Map tx = new LinkedHashMap<>(); + tx.put("format",2); tx.put("id",txId); tx.put("last_tx",anchor); tx.put("owner",w.owner()); + tx.put("tags",jsonTags); tx.put("target",""); tx.put("quantity","0"); + tx.put("data_root",b64(merkle.dataRoot())); tx.put("data_size",Integer.toString(data.length)); tx.put("data",""); + tx.put("reward",reward); tx.put("signature",b64(rawSignature)); + postJsonExpect2xx(gateway + "/tx", tx, Duration.ofSeconds(60)); + + for (int i=0;i body=new LinkedHashMap<>(); + body.put("data_root",b64(merkle.dataRoot())); body.put("data_size",Integer.toString(data.length)); + body.put("data_path",b64(proof.proof())); body.put("offset",Long.toString(proof.offset())); + body.put("chunk",b64(ArweaveMerkle.readChunk(data,meta))); + postChunkWithRetry(gateway,body,i,merkle.chunks().size()); + } + if (cfg.minConfirmations() > 0) waitForConfirmations(txId); + log.info("ANS-104 bundle uploaded to Arweave: tx={} bytes={} items-transported",txId,data.length); + return new UploadResult(txId,txIdBytes); + } + + private int waitForConfirmations(String txId) throws Exception { + String gateway=trimGateway(cfg.publishGateway()); + long deadline=System.currentTimeMillis()+cfg.confirmTimeoutMinutes()*60_000L; int last=0; + while(System.currentTimeMillis() resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if(resp.statusCode()==200){JsonNode j=MAPPER.readTree(resp.body());last=j.path("number_of_confirmations").asInt(0);if(last>=cfg.minConfirmations())return last;} + else if(resp.statusCode()!=202&&resp.statusCode()!=404)log.warn("Arweave status tx={} HTTP {}",txId,resp.statusCode()); + Thread.sleep(cfg.confirmPollSeconds()*1000L); + } + throw new IOException("Arweave confirmation timeout for "+txId+", last="+last); + } + + private void postChunkWithRetry(String gateway,Map body,int index,int total)throws Exception{ + Exception last=null; + for(int attempt=1;attempt<=12;attempt++)try{postJsonExpect2xx(gateway+"/chunk",body,Duration.ofSeconds(60));return;}catch(Exception e){last=e;if(attempt==12)break;Thread.sleep(5000L);} + throw new IOException("Failed Arweave chunk "+(index+1)+"/"+total,last); + } + private void postJsonExpect2xx(String url,Object body,Duration timeout)throws Exception{ + HttpRequest req=HttpRequest.newBuilder(URI.create(url)).timeout(timeout).header("Content-Type","application/json").header("Accept","application/json") + .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(body),StandardCharsets.UTF_8)).build(); + HttpResponse resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("HTTP "+resp.statusCode()+" "+safe(resp.body())); + } + private void verifyBalance(String gateway,String address,String reward)throws Exception{ + String balance=getRequiredText(gateway,"/wallet/"+address+"/balance"); + if(balance.matches("^\\d+$")&&new BigInteger(balance).compareTo(new BigInteger(reward))<0)throw new IllegalStateException("Not enough AR: balance="+balance+" reward="+reward); + } + private String getRequiredText(String gateway,String path)throws Exception{ + HttpRequest req=HttpRequest.newBuilder(URI.create(gateway+path)).timeout(Duration.ofSeconds(20)).GET().build(); + HttpResponse resp=http.send(req,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if(resp.statusCode()<200||resp.statusCode()>=300)throw new IOException("Arweave HTTP "+resp.statusCode()+" for "+path+": "+safe(resp.body())); + String s=resp.body()==null?"":resp.body().trim();if(s.isBlank())throw new IOException("Empty Arweave response for "+path);return s; + } + private Wallet wallet()throws Exception{ + Wallet local=wallet;if(local!=null)return local;synchronized(this){if(wallet!=null)return wallet; + JsonNode jwk=MAPPER.readTree(Files.readString(cfg.walletJwkPath(),StandardCharsets.UTF_8));String owner=required(jwk,"n"); + PrivateKey pk=buildPrivateKey(jwk);String address=b64(sha256(b64Decode(owner)));return wallet=new Wallet(owner,address,pk);} + } + private static PrivateKey buildPrivateKey(JsonNode jwk)throws GeneralSecurityException{ + RSAPrivateCrtKeySpec spec=new RSAPrivateCrtKeySpec(big(required(jwk,"n")),big(required(jwk,"e")),big(required(jwk,"d")),big(required(jwk,"p")),big(required(jwk,"q")),big(required(jwk,"dp")),big(required(jwk,"dq")),big(required(jwk,"qi"))); + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } + private static byte[] signPayload(PrivateKey key,byte[] payload)throws GeneralSecurityException{ + Signature s=Signature.getInstance("RSASSA-PSS");s.setParameter(new PSSParameterSpec("SHA-256","MGF1",MGF1ParameterSpec.SHA256,32,1));s.initSign(key);s.update(payload);return s.sign(); + } + private static byte[] deepHash(Object data)throws GeneralSecurityException{ + if(data instanceof List list){byte[] acc=sha384(utf8("list"+list.size()));for(Object item:list)acc=sha384(concat(acc,deepHash(item)));return acc;} + if(!(data instanceof byte[] bytes))throw new IllegalArgumentException("deepHash byte[]/List expected");return sha384(concat(sha384(utf8("blob"+bytes.length)),sha384(bytes))); + } + private static void addTag(List> json,List> raw,String name,String value){byte[]n=utf8(name),v=utf8(value);Mapm=new LinkedHashMap<>();m.put("name",b64(n));m.put("value",b64(v));json.add(m);raw.add(List.of(n,v));} + private static String required(JsonNode n,String f){String s=n==null?"":n.path(f).asText("").trim();if(s.isBlank())throw new IllegalStateException("Arweave JWK missing "+f);return s;} + private static BigInteger big(String s){return new BigInteger(1,b64Decode(s));} + private static String trimGateway(String s){return(s==null?"https://arweave.net":s.trim()).replaceAll("/+$","");} + private static byte[] utf8(String s){return String.valueOf(s==null?"":s).getBytes(StandardCharsets.UTF_8);} + private static byte[] b64Decode(String s){return B64D.decode(s);}private static String b64(byte[]b){return B64.encodeToString(b);} + private static byte[] sha256(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-256").digest(b);}private static byte[] sha384(byte[]b)throws GeneralSecurityException{return MessageDigest.getInstance("SHA-384").digest(b);} + private static byte[] concat(byte[]...a){int n=0;for(byte[]x:a)n=Math.addExact(n,x.length);byte[]o=new byte[n];int p=0;for(byte[]x:a){System.arraycopy(x,0,o,p,x.length);p+=x.length;}return o;} + private static String safe(String s){s=String.valueOf(s==null?"":s).replace('\n',' ').replace('\r',' ').trim();return s.length()<=250?s:s.substring(0,250)+"...";} + private record Wallet(String owner,String address,PrivateKey privateKey){} +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java new file mode 100644 index 00000000..98b688cf --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/ArweaveMerkle.java @@ -0,0 +1,206 @@ +package server.archive; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; + +/** + * Arweave v2 data Merkle tree/proofs. Алгоритм повторяет arweave-js merkle.ts. + */ +final class ArweaveMerkle { + static final int MAX_CHUNK_SIZE = 256 * 1024; + static final int MIN_CHUNK_SIZE = 32 * 1024; + + record Chunk(byte[] dataHash, long minByteRange, long maxByteRange) { + long size() { return maxByteRange - minByteRange; } + } + record Proof(long offset, byte[] proof) {} + record Prepared(byte[] dataRoot, List chunks, List proofs) {} + + private sealed interface Node permits Leaf, Branch { + byte[] id(); + long maxByteRange(); + } + private record Leaf(byte[] id, byte[] dataHash, long minByteRange, long maxByteRange) implements Node {} + private record Branch(byte[] id, long byteRange, long maxByteRange, Node left, Node right) implements Node {} + + private ArweaveMerkle() {} + + + static Prepared prepare(byte[] data) throws IOException { + if (data == null || data.length == 0) throw new IOException("Arweave data is empty"); + List chunks = chunkBytes(data); + List leaves = new ArrayList<>(chunks.size()); + for (Chunk c : chunks) { + byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange())))); + leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange())); + } + Node root = buildLayers(leaves); + List proofs = new ArrayList<>(); + resolveProofs(root, new byte[0], proofs); + if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) { + chunks = new ArrayList<>(chunks); + chunks.remove(chunks.size() - 1); + proofs.remove(proofs.size() - 1); + } + if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch"); + return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs)); + } + + private static List chunkBytes(byte[] data) { + List out = new ArrayList<>(); + long remaining = data.length; + long cursor = 0; + int p = 0; + while (remaining >= MAX_CHUNK_SIZE) { + long chunkSize = MAX_CHUNK_SIZE; + long next = remaining - MAX_CHUNK_SIZE; + if (next > 0 && next < MIN_CHUNK_SIZE) chunkSize = (remaining + 1) / 2; + byte[] chunk = Arrays.copyOfRange(data, p, p + (int) chunkSize); + out.add(new Chunk(sha256(chunk), cursor, cursor + chunkSize)); + p += (int) chunkSize; + cursor += chunkSize; + remaining -= chunkSize; + } + byte[] rest = Arrays.copyOfRange(data, p, p + (int) remaining); + out.add(new Chunk(sha256(rest), cursor, cursor + remaining)); + return out; + } + + static byte[] readChunk(byte[] data, Chunk c) throws IOException { + long len = c.size(); + if (data == null || len < 0 || len > Integer.MAX_VALUE || c.minByteRange() < 0 || c.maxByteRange() > data.length) { + throw new IOException("Invalid Arweave chunk range"); + } + return Arrays.copyOfRange(data, (int) c.minByteRange(), (int) c.maxByteRange()); + } + + static Prepared prepare(Path file) throws IOException { + long fileSize = Files.size(file); + List chunks = chunkFile(file, fileSize); + List leaves = new ArrayList<>(chunks.size()); + for (Chunk c : chunks) { + byte[] id = sha256(concat(sha256(c.dataHash()), sha256(note(c.maxByteRange())))); + leaves.add(new Leaf(id, c.dataHash(), c.minByteRange(), c.maxByteRange())); + } + if (leaves.isEmpty()) throw new IOException("Arweave Merkle: нет leaves"); + Node root = buildLayers(leaves); + List proofs = new ArrayList<>(); + resolveProofs(root, new byte[0], proofs); + + // arweave-js строит root вместе с нулевым последним chunk при точном кратном 256KiB, + // но сам нулевой chunk/proof не загружает. + if (!chunks.isEmpty() && chunks.get(chunks.size() - 1).size() == 0) { + chunks = new ArrayList<>(chunks); + chunks.remove(chunks.size() - 1); + proofs.remove(proofs.size() - 1); + } + if (chunks.size() != proofs.size()) throw new IOException("Arweave Merkle: chunks/proofs mismatch"); + return new Prepared(root.id(), List.copyOf(chunks), List.copyOf(proofs)); + } + + private static List chunkFile(Path file, long fileSize) throws IOException { + List out = new ArrayList<>(); + long remaining = fileSize; + long cursor = 0; + try (InputStream in = new BufferedInputStream(Files.newInputStream(file))) { + while (remaining >= MAX_CHUNK_SIZE) { + long chunkSize = MAX_CHUNK_SIZE; + long next = remaining - MAX_CHUNK_SIZE; + if (next > 0 && next < MIN_CHUNK_SIZE) { + chunkSize = (remaining + 1) / 2; + } + byte[] data = readExact(in, (int)chunkSize); + out.add(new Chunk(sha256(data), cursor, cursor + chunkSize)); + cursor += chunkSize; + remaining -= chunkSize; + } + byte[] rest = readExact(in, (int)remaining); + out.add(new Chunk(sha256(rest), cursor, cursor + remaining)); + } + return out; + } + + private static Node buildLayers(List nodes) { + if (nodes.size() < 2) return nodes.get(0); + List next = new ArrayList<>((nodes.size() + 1) / 2); + for (int i = 0; i < nodes.size(); i += 2) { + Node left = nodes.get(i); + Node right = i + 1 < nodes.size() ? nodes.get(i + 1) : null; + if (right == null) { + next.add(left); + continue; + } + long byteRange = left.maxByteRange(); + byte[] id = sha256(concat( + sha256(left.id()), + sha256(right.id()), + sha256(note(byteRange)) + )); + next.add(new Branch(id, byteRange, right.maxByteRange(), left, right)); + } + return buildLayers(next); + } + + private static void resolveProofs(Node node, byte[] prefix, List out) { + if (node instanceof Leaf leaf) { + out.add(new Proof(leaf.maxByteRange() - 1, + concat(prefix, leaf.dataHash(), note(leaf.maxByteRange())))); + return; + } + Branch b = (Branch) node; + byte[] partial = concat(prefix, b.left().id(), b.right().id(), note(b.byteRange())); + resolveProofs(b.left(), partial, out); + resolveProofs(b.right(), partial, out); + } + + static byte[] readChunk(Path file, Chunk c) throws IOException { + long len = c.size(); + if (len < 0 || len > Integer.MAX_VALUE) throw new IOException("Некорректный Arweave chunk size: " + len); + try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) { + raf.seek(c.minByteRange()); + byte[] data = new byte[(int)len]; + raf.readFully(data); + return data; + } + } + + private static byte[] readExact(InputStream in, int len) throws IOException { + byte[] out = new byte[len]; + int pos = 0; + while (pos < len) { + int n = in.read(out, pos, len - pos); + if (n < 0) throw new EOFException("Неожиданный EOF"); + pos += n; + } + return out; + } + + private static byte[] note(long value) { + if (value < 0) throw new IllegalArgumentException("negative note"); + byte[] out = new byte[32]; + long v = value; + for (int i = 31; i >= 0 && v != 0; i--) { + out[i] = (byte)(v & 0xff); + v >>>= 8; + } + return out; + } + + private static byte[] sha256(byte[] data) { + try { return MessageDigest.getInstance("SHA-256").digest(data); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + + private static byte[] concat(byte[]... arrays) { + int len = 0; + for (byte[] a : arrays) len = Math.addExact(len, a.length); + byte[] out = new byte[len]; + int p=0; + for (byte[] a : arrays) { System.arraycopy(a,0,out,p,a.length); p+=a.length; } + return out; + } +} diff --git a/SHiNE-server/shine-server-archive/src/main/java/server/archive/TurboDataItemUploader.java b/SHiNE-server/shine-server-archive/src/main/java/server/archive/TurboDataItemUploader.java new file mode 100644 index 00000000..7aea253b --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/main/java/server/archive/TurboDataItemUploader.java @@ -0,0 +1,106 @@ +package server.archive; + +import blockchain.Ans104DataItem; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.Objects; + +/** Uploads an already user-signed ANS-104 DataItem to Turbo without modifying it. */ +public final class TurboDataItemUploader { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder B64URL_DECODER = Base64.getUrlDecoder(); + + public record UploadResult(String dataItemId, String owner) {} + + private final ArweaveBlocksConfig cfg; + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(20)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + private volatile String resolvedPaidByAddress; + + public TurboDataItemUploader(ArweaveBlocksConfig cfg) { + this.cfg = Objects.requireNonNull(cfg); + } + + public UploadResult upload(byte[] rawDataItem, byte[] expectedId32) throws Exception { + if (rawDataItem == null || rawDataItem.length == 0) throw new IllegalArgumentException("Turbo DataItem is empty"); + Ans104DataItem item = new Ans104DataItem(rawDataItem); + if (!item.verifySignature()) throw new IllegalArgumentException("Turbo DataItem has bad ANS-104 signature"); + if (expectedId32 != null && !Arrays.equals(item.id32(), expectedId32)) { + throw new IllegalArgumentException("Turbo DataItem id does not match blocks.data_item_id"); + } + + String expectedId = B64URL.encodeToString(item.id32()); + HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(cfg.turboUploadUrl())) + .timeout(Duration.ofMinutes(2)) + .header("Content-Type", "application/octet-stream") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofByteArray(rawDataItem)); + String paidBy = paidByAddress(); + if (paidBy != null) request.header("x-paid-by", paidBy); + + HttpResponse response = http.send(request.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + String body = response.body() == null ? "" : response.body().trim(); + if (response.statusCode() == 409 && body.toLowerCase().contains("data item exists")) { + return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + String hint = response.statusCode() == 402 + ? " (Turbo payment required: check server credits / Credit Share Approval / x-paid-by)" + : ""; + throw new IOException("Turbo HTTP " + response.statusCode() + hint + ": " + safe(body)); + } + + if (body.isBlank()) return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); + JsonNode json; + try { json = MAPPER.readTree(body); } + catch (Exception ignored) { return new UploadResult(expectedId, B64URL.encodeToString(item.owner32())); } + String returnedId = json.path("id").asText("").trim(); + if (!returnedId.isBlank() && !returnedId.equals(expectedId)) { + throw new IOException("Turbo returned another DataItem id: expected=" + expectedId + " got=" + returnedId); + } + return new UploadResult(expectedId, json.path("owner").asText("")); + } + + /** + * x-paid-by contains the payer's public native address, never the private key. + * A configured Arweave JWK is used only to derive that public address. + */ + private String paidByAddress() throws Exception { + if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress; + synchronized (this) { + if (resolvedPaidByAddress != null) return resolvedPaidByAddress.isBlank() ? null : resolvedPaidByAddress; + String explicit = cfg.turboPaidByAddress(); + if (explicit != null && !explicit.isBlank()) return resolvedPaidByAddress = explicit.trim(); + if (cfg.turboWalletJwkPath() == null) { + resolvedPaidByAddress = ""; + return null; + } + JsonNode jwk = MAPPER.readTree(Files.readString(cfg.turboWalletJwkPath(), StandardCharsets.UTF_8)); + String modulus = jwk.path("n").asText("").trim(); + if (modulus.isBlank()) throw new IllegalStateException("Turbo payer JWK missing n"); + byte[] owner = B64URL_DECODER.decode(modulus); + resolvedPaidByAddress = B64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(owner)); + return resolvedPaidByAddress; + } + } + + private static String safe(String value) { + String v = value == null ? "" : value.replace('\n',' ').replace('\r',' ').trim(); + return v.length() <= 500 ? v : v.substring(0, 500); + } +} diff --git a/SHiNE-server/shine-server-archive/src/test/java/server/archive/Ans104BundleTest.java b/SHiNE-server/shine-server-archive/src/test/java/server/archive/Ans104BundleTest.java new file mode 100644 index 00000000..2b83e9e1 --- /dev/null +++ b/SHiNE-server/shine-server-archive/src/test/java/server/archive/Ans104BundleTest.java @@ -0,0 +1,36 @@ +package server.archive; + +import blockchain.Ans104DataItem; +import org.junit.jupiter.api.Test; +import utils.crypto.Ed25519Util; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +final class Ans104BundleTest { + @Test + void writeReadAndFindPreserveExactDataItems() { + byte[] a = item("a"); + byte[] b = item("b"); + byte[] bundle = Ans104Bundle.write(List.of(a, b)); + + List entries = Ans104Bundle.read(bundle, 10); + assertEquals(2, entries.size()); + assertArrayEquals(a, entries.get(0).rawDataItem()); + assertArrayEquals(b, entries.get(1).rawDataItem()); + + byte[] bId = new Ans104DataItem(b).id32(); + assertArrayEquals(b, Ans104Bundle.find(bundle, bId, 10)); + } + + private static byte[] item(String payload) { + byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("bundle-" + payload); + byte[] owner = Ed25519Util.derivePublicKey(privateKey); + byte[] data = payload.getBytes(StandardCharsets.UTF_8); + List tags = List.of(new Ans104DataItem.Tag("App", "test5590")); + byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey); + return Ans104DataItem.serializeEd25519(signature, owner, tags, data); + } +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java new file mode 100644 index 00000000..55ae570b --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/Ans104DataItem.java @@ -0,0 +1,363 @@ +package blockchain; + +import utils.crypto.Ed25519Util; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Minimal ANS-104 DataItem parser/verifier used by SHiNE. + * + * SHiNE accepts only generic Ed25519 signature type 2: + * uint16le signatureType + * signature[64] + * owner[32] + * target_present[1] + target[32] when present + * anchor_present[1] + anchor[32] when present + * tags_count uint64le + * tags_size uint64le + * avro_tags[tags_size] + * data[*] + * + * The signing message matches the deployed arbundles/Turbo DataItem implementation: + * deepHash(["dataitem", "1", signatureType, owner, target, anchor, rawAvroTags, data]). + */ +public final class Ans104DataItem { + public static final int SIGNATURE_TYPE_ED25519 = 2; + public static final int SIGNATURE_LEN = 64; + public static final int OWNER_LEN = 32; + public static final int OPTIONAL_FIELD_LEN = 32; + public static final int ID_LEN = 32; + public static final int MAX_DATA_ITEM_BYTES = 8 * 1024 * 1024; + public static final int MAX_TAGS_BYTES = 64 * 1024; + public static final long MAX_TAGS_COUNT = 128; + + public record Tag(String name, String value) {} + + private final int signatureType; + private final byte[] signature; + private final byte[] owner; + private final byte[] target; + private final byte[] anchor; + private final long tagsCount; + private final byte[] rawTags; + private final List tags; + private final byte[] data; + private final byte[] rawBytes; + private final byte[] id; + + public Ans104DataItem(byte[] bytes) { + Objects.requireNonNull(bytes, "bytes == null"); + if (bytes.length < 2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + 1) { + throw new IllegalArgumentException("ANS-104 DataItem too short: " + bytes.length); + } + if (bytes.length > MAX_DATA_ITEM_BYTES) { + throw new IllegalArgumentException("ANS-104 DataItem too large: " + bytes.length); + } + + ByteBuffer bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + this.signatureType = Short.toUnsignedInt(bb.getShort()); + if (signatureType != SIGNATURE_TYPE_ED25519) { + throw new IllegalArgumentException("Unsupported ANS-104 signature type: " + signatureType + " (expected 2)"); + } + + this.signature = readExact(bb, SIGNATURE_LEN, "signature"); + this.owner = readExact(bb, OWNER_LEN, "owner"); + this.target = readOptional32(bb, "target"); + this.anchor = readOptional32(bb, "anchor"); + + this.tagsCount = readUnsignedLongAsNonNegative(bb, "tags_count"); + long tagsSizeLong = readUnsignedLongAsNonNegative(bb, "tags_size"); + if (tagsCount > MAX_TAGS_COUNT) { + throw new IllegalArgumentException("Too many ANS-104 tags: " + tagsCount); + } + if (tagsSizeLong > MAX_TAGS_BYTES || tagsSizeLong > bb.remaining()) { + throw new IllegalArgumentException("Bad ANS-104 tags_size: " + tagsSizeLong + ", remaining=" + bb.remaining()); + } + this.rawTags = readExact(bb, (int) tagsSizeLong, "tags"); + this.tags = Collections.unmodifiableList(parseAvroTags(rawTags, tagsCount)); + this.data = new byte[bb.remaining()]; + bb.get(this.data); + this.rawBytes = Arrays.copyOf(bytes, bytes.length); + this.id = sha256(signature); + } + + public int signatureType() { return signatureType; } + public byte[] signature64() { return Arrays.copyOf(signature, signature.length); } + public byte[] owner32() { return Arrays.copyOf(owner, owner.length); } + public byte[] target() { return Arrays.copyOf(target, target.length); } + public byte[] anchor() { return Arrays.copyOf(anchor, anchor.length); } + public long tagsCount() { return tagsCount; } + public byte[] rawTags() { return Arrays.copyOf(rawTags, rawTags.length); } + public List tags() { return tags; } + public byte[] data() { return Arrays.copyOf(data, data.length); } + public byte[] rawBytes() { return Arrays.copyOf(rawBytes, rawBytes.length); } + public byte[] id32() { return Arrays.copyOf(id, id.length); } + + public String tagValue(String name) { + if (name == null) return null; + for (Tag tag : tags) { + if (name.equals(tag.name())) return tag.value(); + } + return null; + } + + public boolean hasTag(String name, String value) { + for (Tag tag : tags) { + if (Objects.equals(name, tag.name()) && Objects.equals(value, tag.value())) return true; + } + return false; + } + + public boolean verifySignature() { + return Ed25519Util.verify(signingMessage(), signature, owner); + } + + public boolean ownerEquals(byte[] expectedOwner32) { + return expectedOwner32 != null && expectedOwner32.length == OWNER_LEN && Arrays.equals(owner, expectedOwner32); + } + + public byte[] signingMessage() { + return buildSigningMessage(owner, target, anchor, tags, data); + } + + /** + * Builds the exact ANS-104 signing message for generic Ed25519 DataItems. + * Useful for SHiNE clients/tests that must sign a DataItem before serializing it. + */ + public static byte[] buildSigningMessage(byte[] owner32, List tags, byte[] data) { + return buildSigningMessage(owner32, new byte[0], new byte[0], tags, data); + } + + public static byte[] buildSigningMessage(byte[] owner32, byte[] target, byte[] anchor, List tags, byte[] data) { + requireLen(owner32, OWNER_LEN, "owner"); + byte[] safeTarget = target == null ? new byte[0] : Arrays.copyOf(target, target.length); + byte[] safeAnchor = anchor == null ? new byte[0] : Arrays.copyOf(anchor, anchor.length); + if (!(safeTarget.length == 0 || safeTarget.length == OPTIONAL_FIELD_LEN)) { + throw new IllegalArgumentException("target must be empty or 32 bytes"); + } + if (!(safeAnchor.length == 0 || safeAnchor.length == OPTIONAL_FIELD_LEN)) { + throw new IllegalArgumentException("anchor must be empty or 32 bytes"); + } + List safeTags = validateTags(tags); + byte[] safeData = data == null ? new byte[0] : Arrays.copyOf(data, data.length); + + byte[] rawTags = serializeAvroTags(safeTags); + return deepHash(List.of( + utf8("dataitem"), + utf8("1"), + utf8(Integer.toString(SIGNATURE_TYPE_ED25519)), + Arrays.copyOf(owner32, owner32.length), + safeTarget, + safeAnchor, + rawTags, + safeData + )); + } + + /** Serialize one SHiNE-ready ANS-104 DataItem with empty target/anchor. */ + public static byte[] serializeEd25519(byte[] signature64, byte[] owner32, List tags, byte[] data) { + requireLen(signature64, SIGNATURE_LEN, "signature"); + requireLen(owner32, OWNER_LEN, "owner"); + List safeTags = validateTags(tags); + byte[] rawTags = serializeAvroTags(safeTags); + byte[] safeData = data == null ? new byte[0] : data; + + ByteArrayOutputStream out = new ByteArrayOutputStream( + 2 + SIGNATURE_LEN + OWNER_LEN + 1 + 1 + 8 + 8 + rawTags.length + safeData.length); + writeU16LE(out, SIGNATURE_TYPE_ED25519); + out.writeBytes(signature64); + out.writeBytes(owner32); + out.write(0); // target absent + out.write(0); // anchor absent + writeU64LE(out, safeTags.size()); + writeU64LE(out, rawTags.length); + out.writeBytes(rawTags); + out.writeBytes(safeData); + return out.toByteArray(); + } + + private static byte[] readOptional32(ByteBuffer bb, String name) { + if (!bb.hasRemaining()) throw new IllegalArgumentException("Missing " + name + " presence byte"); + int present = Byte.toUnsignedInt(bb.get()); + if (present == 0) return new byte[0]; + if (present != 1) throw new IllegalArgumentException("Bad " + name + " presence byte: " + present); + return readExact(bb, OPTIONAL_FIELD_LEN, name); + } + + private static byte[] readExact(ByteBuffer bb, int len, String field) { + if (len < 0 || bb.remaining() < len) { + throw new IllegalArgumentException("ANS-104 " + field + " truncated: need=" + len + " remaining=" + bb.remaining()); + } + byte[] out = new byte[len]; + bb.get(out); + return out; + } + + private static long readUnsignedLongAsNonNegative(ByteBuffer bb, String field) { + if (bb.remaining() < 8) throw new IllegalArgumentException("Missing ANS-104 " + field); + long v = bb.getLong(); + if (v < 0) throw new IllegalArgumentException("ANS-104 " + field + " exceeds signed long range"); + return v; + } + + private static List validateTags(List tags) { + List safe = tags == null ? List.of() : List.copyOf(tags); + if (safe.size() > MAX_TAGS_COUNT) { + throw new IllegalArgumentException("Too many ANS-104 tags: " + safe.size()); + } + for (Tag tag : safe) { + if (tag == null || tag.name() == null || tag.value() == null || tag.name().isEmpty() || tag.value().isEmpty()) { + throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty"); + } + if (utf8(tag.name()).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes"); + if (utf8(tag.value()).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes"); + } + return safe; + } + + private static byte[] serializeAvroTags(List tags) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + if (!tags.isEmpty()) { + writeAvroLong(out, tags.size()); + for (Tag tag : tags) { + writeAvroString(out, tag.name()); + writeAvroString(out, tag.value()); + } + } + writeAvroLong(out, 0); // array terminator; also the complete encoding for an empty array + byte[] raw = out.toByteArray(); + if (raw.length > MAX_TAGS_BYTES) throw new IllegalArgumentException("ANS-104 tags exceed " + MAX_TAGS_BYTES + " bytes"); + return raw; + } + + private static void writeAvroString(ByteArrayOutputStream out, String value) { + byte[] bytes = utf8(value); + writeAvroLong(out, bytes.length); + out.writeBytes(bytes); + } + + private static void writeAvroLong(ByteArrayOutputStream out, long value) { + long n = (value << 1) ^ (value >> 63); + while ((n & ~0x7fL) != 0) { + out.write((int) ((n & 0x7f) | 0x80)); + n >>>= 7; + } + out.write((int) n); + } + + private static void writeU16LE(ByteArrayOutputStream out, int value) { + out.write(value & 0xff); + out.write((value >>> 8) & 0xff); + } + + private static void writeU64LE(ByteArrayOutputStream out, long value) { + for (int i = 0; i < 8; i++) out.write((int) ((value >>> (8 * i)) & 0xff)); + } + + private static void requireLen(byte[] bytes, int expected, String name) { + if (bytes == null || bytes.length != expected) { + throw new IllegalArgumentException(name + " must be " + expected + " bytes"); + } + } + + private static List parseAvroTags(byte[] raw, long declaredCount) { + AvroCursor c = new AvroCursor(raw); + List out = new ArrayList<>(); + while (!c.eof()) { + long count = c.readLong(); + if (count == 0) break; + if (count < 0) { + count = -count; + long blockBytes = c.readLong(); + if (blockBytes < 0) throw new IllegalArgumentException("Negative Avro tag block size"); + } + if (count > MAX_TAGS_COUNT || out.size() + count > MAX_TAGS_COUNT) { + throw new IllegalArgumentException("Too many Avro tags"); + } + for (long i = 0; i < count; i++) { + String name = c.readString(); + String value = c.readString(); + if (name.isEmpty() || value.isEmpty()) throw new IllegalArgumentException("ANS-104 tag name/value must be non-empty"); + if (name.getBytes(StandardCharsets.UTF_8).length > 1024) throw new IllegalArgumentException("ANS-104 tag name exceeds 1024 bytes"); + if (value.getBytes(StandardCharsets.UTF_8).length > 3072) throw new IllegalArgumentException("ANS-104 tag value exceeds 3072 bytes"); + out.add(new Tag(name, value)); + } + } + if (!c.eof()) { + throw new IllegalArgumentException("Trailing bytes in ANS-104 Avro tags"); + } + if (out.size() != declaredCount) { + throw new IllegalArgumentException("ANS-104 tags_count mismatch: declared=" + declaredCount + " parsed=" + out.size()); + } + return out; + } + + private static final class AvroCursor { + private final byte[] data; + private int p; + private AvroCursor(byte[] data) { this.data = data == null ? new byte[0] : data; } + boolean eof() { return p == data.length; } + long readLong() { + long raw = 0; + int shift = 0; + while (true) { + if (p >= data.length) throw new IllegalArgumentException("Truncated Avro long"); + int b = data[p++] & 0xff; + raw |= (long) (b & 0x7f) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + if (shift > 63) throw new IllegalArgumentException("Avro long too large"); + } + return (raw >>> 1) ^ -(raw & 1L); + } + String readString() { + long len = readLong(); + if (len < 0 || len > Integer.MAX_VALUE || p + len > data.length) { + throw new IllegalArgumentException("Bad Avro string length: " + len); + } + String s = new String(data, p, (int) len, StandardCharsets.UTF_8); + p += (int) len; + return s; + } + } + + private static byte[] deepHash(Object item) { + if (item instanceof byte[] bytes) { + return deepHashBlob(bytes); + } + if (item instanceof List items) { + byte[] acc = sha384(utf8("list" + items.size())); + for (Object child : items) { + acc = sha384(concat(acc, deepHash(child))); + } + return acc; + } + throw new IllegalArgumentException("Unsupported deepHash item: " + (item == null ? "null" : item.getClass())); + } + + private static byte[] deepHashBlob(byte[] bytes) { + byte[] tag = utf8("blob" + bytes.length); + return sha384(concat(sha384(tag), sha384(bytes))); + } + + private static byte[] utf8(String s) { return s.getBytes(StandardCharsets.UTF_8); } + private static byte[] sha256(byte[] b) { return digest("SHA-256", b); } + private static byte[] sha384(byte[] b) { return digest("SHA-384", b); } + private static byte[] digest(String alg, byte[] b) { + try { return MessageDigest.getInstance(alg).digest(b); } + catch (Exception e) { throw new IllegalStateException(alg + " unavailable", e); } + } + private static byte[] concat(byte[] a, byte[] b) { + ByteArrayOutputStream out = new ByteArrayOutputStream(a.length + b.length); + out.writeBytes(a); out.writeBytes(b); return out.toByteArray(); + } +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java index 255644d3..2e6d77be 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchBlockEntry.java @@ -9,364 +9,116 @@ import java.util.Arrays; import java.util.Objects; /** - * BchBlockEntry — универсальный блок формата SHiNE (Frame v0). + * SHiNE user block, Frame v1, carried as the data payload of an ANS-104 DataItem. * - * ========================================================================= - * FRAME v0 — ФИКСИРОВАННЫЙ ФОРМАТ БЛОКА (ДОКУМЕНТ ПРОТОКОЛА) - * ========================================================================= + * The constructor accepts the complete serialized ANS-104 DataItem because this is + * exactly what is stored in PostgreSQL and exchanged between servers. * - * Все числа BigEndian. + * SHiNE Frame v1 (all integer fields BigEndian): + * [2] frameCode = 0x0001 + * [32] prevHash32 = SHA-256(Frame v1 bytes of previous SHiNE block) + * [4] blockSize = exact Frame v1 byte length + * [4] blockNumber + * [8] timestamp unix seconds + * [2] type + * [2] subType + * [2] version + * [N] bodyBytes * - * PREIMAGE (входит в blockSize, подписывается): - * [2] frameCode (uint16) код/версия рамки: - * - 0x0000 = Frame v0 (текущий) - * [32] prevHash32 (bytes) SHA-256(preimage) предыдущего блока (цепочка) - * [4] blockSize (int32) размер preimage (в байтах), ВКЛЮЧАЯ frameCode, - * НО БЕЗ sigMarker и БЕЗ signature64 - * [4] blockNumber (int32) глобальный номер блока (>=0) - * [8] timestamp (int64) unix seconds - * [2] type (uint16) тип сообщения - * [2] subType (uint16) подтип сообщения - * [2] version (uint16) версия формата сообщения - * [N] bodyBytes (bytes) тело сообщения (БЕЗ type/subType/version) - * - * TAIL (НЕ входит в blockSize, НЕ подписывается в Frame v0): - * [2] sigMarker (uint16) маркер подписи: - * - 0x0100 (256) = далее подпись Ed25519 64 байта - * [64] signature64 (bytes) Ed25519 signature над hash32 - * - * hash32 НЕ хранится в блоке. - * hash32 вычисляется при парсинге: - * preimage = первые blockSize байт - * hash32 = SHA-256(preimage) - * - * Правила MVP-парсера (Frame v0): - * - frameCode должен быть строго 0x0000, иначе REJECT. - * - sigMarker должен быть строго 0x0100, иначе REJECT. - * - подпись обязана присутствовать всегда (sigMarker+signature64). - * - НИКАКИХ fallback-веток “если маркер другой, то подписи нет/другой хвост”. - * - * Важно по безопасности: - * - sigMarker в v0 не входит в подписываемые байты → его можно подменить, - * поэтому единственная безопасная логика: "если не 0x0100 — reject". - * ========================================================================= + * The user signature is NOT stored in this frame. It is the single Ed25519/Solana + * signature of the surrounding ANS-104 DataItem. */ public final class BchBlockEntry { - - public static final int SIGNATURE_LEN = 64; public static final int HASH_LEN = 32; - - public static final int FRAME_CODE_LEN = 2; - public static final int SIG_MARKER_LEN = 2; - - /** Frame v0 */ - public static final int FRAME_CODE_V0 = 0x0000; - - /** sigMarker: 256 = 0x0100 */ - public static final int SIG_MARKER_ED25519 = 0x0100; - - /** - * Максимальный допустимый размер блока (fullBytes = preimage + sigMarker + signature), - * чтобы не уложить сервер по памяти/диску. - */ - public static final int MAX_BLOCK_FULL_BYTES = 4 * 1024 * 1024; - - /** - * Насколько блок может “обгонять” текущее время (защита от кривых часов/вбросов). - * Если timestamp больше now + 60 сек — блок считаем неверным. - */ + public static final int FRAME_CODE_V1 = 0x0001; + public static final int MAX_BLOCK_FULL_BYTES = Ans104DataItem.MAX_DATA_ITEM_BYTES; public static final long MAX_FUTURE_SECONDS = 60; + public static final int FRAME_HEADER_SIZE = 2 + 32 + 4 + 4 + 8 + 2 + 2 + 2; - /** - * Размер фиксированной части PREIMAGE (без bodyBytes). - * - * PREIMAGE header: - * frameCode(2) + prevHash32(32) + blockSize(4) + blockNumber(4) + timestamp(8) - * + type(2) + subType(2) + version(2) - */ - public static final int PREIMAGE_HEADER_SIZE = - 2 // frameCode - + 32 // prevHash32 - + 4 // blockSize - + 4 // blockNumber - + 8 // timestamp - + 2 // type - + 2 // subType - + 2; // version - - /** Минимальный полный размер блока (без bodyBytes). */ - public static final int MIN_FULL_BYTES = - PREIMAGE_HEADER_SIZE + SIG_MARKER_LEN + SIGNATURE_LEN; - - // --- HEADER (PREIMAGE) --- - public final int frameCode; // uint16 (v0=0) - public final byte[] prevHash32; // 32 - public final int blockSize; // preimage size (включая frameCode) - public final int blockNumber; // >=0 + public final int frameCode; + public final byte[] prevHash32; + public final int blockSize; + public final int blockNumber; public final long timestamp; public final short type; public final short subType; public final short version; - - // --- BODY (PREIMAGE) --- public final byte[] bodyBytes; - - /** Распарсенное тело (создаётся сразу при парсинге блока). */ public final BodyRecord body; - // --- TAIL --- - public final int sigMarker; // uint16 (v0: 0x0100) - private final byte[] signature64; // 64 + private final Ans104DataItem dataItem; + private final byte[] frameBytes; + private final byte[] hash32; - // --- derived --- - private final byte[] hash32; // 32, computed - private final byte[] preimage; // blockSize bytes - private final byte[] fullBytes; // preimage + sigMarker + signature - - /* ===================================================================== */ - /* ====================== Конструктор из байт ========================== */ - /* ===================================================================== */ - - public BchBlockEntry(byte[] fullBytes) { - Objects.requireNonNull(fullBytes, "fullBytes == null"); - - if (fullBytes.length < MIN_FULL_BYTES) { - throw new IllegalArgumentException("Block too short: " + fullBytes.length + " < " + MIN_FULL_BYTES); - } - if (fullBytes.length > MAX_BLOCK_FULL_BYTES) { - throw new IllegalArgumentException("Block too large: " + fullBytes.length + " > " + MAX_BLOCK_FULL_BYTES); + /** Parse a complete serialized ANS-104 DataItem containing one SHiNE Frame v1. */ + public BchBlockEntry(byte[] fullDataItemBytes) { + Objects.requireNonNull(fullDataItemBytes, "fullDataItemBytes == null"); + this.dataItem = new Ans104DataItem(fullDataItemBytes); + this.frameBytes = dataItem.data(); + if (frameBytes.length < FRAME_HEADER_SIZE) { + throw new IllegalArgumentException("SHiNE Frame v1 too short: " + frameBytes.length); } - ByteBuffer bb = ByteBuffer.wrap(fullBytes).order(ByteOrder.BIG_ENDIAN); - - // [2] frameCode + ByteBuffer bb = ByteBuffer.wrap(frameBytes).order(ByteOrder.BIG_ENDIAN); this.frameCode = Short.toUnsignedInt(bb.getShort()); - if (this.frameCode != FRAME_CODE_V0) { - throw new IllegalArgumentException(String.format( - "Bad frameCode: 0x%04X (expected 0x%04X)", this.frameCode, FRAME_CODE_V0 - )); + if (frameCode != FRAME_CODE_V1) { + throw new IllegalArgumentException(String.format("Bad SHiNE frameCode: 0x%04X (expected 0x%04X)", frameCode, FRAME_CODE_V1)); } - - // [32] prevHash32 this.prevHash32 = new byte[32]; - bb.get(this.prevHash32); - - // [4] blockSize + bb.get(prevHash32); this.blockSize = bb.getInt(); - if (blockSize < PREIMAGE_HEADER_SIZE) { - throw new IllegalArgumentException("blockSize too small: " + blockSize + " < " + PREIMAGE_HEADER_SIZE); + if (blockSize != frameBytes.length || blockSize < FRAME_HEADER_SIZE) { + throw new IllegalArgumentException("SHiNE blockSize mismatch: declared=" + blockSize + " actual=" + frameBytes.length); } - - // fullLen must match exactly: blockSize + sigMarker(2) + signature(64) - int expectedFullLen = blockSize + SIG_MARKER_LEN + SIGNATURE_LEN; - if (expectedFullLen != fullBytes.length) { - throw new IllegalArgumentException("blockSize mismatch: blockSize=" + blockSize - + " expectedFullLen=" + expectedFullLen - + " fullLen=" + fullBytes.length); - } - if (expectedFullLen > MAX_BLOCK_FULL_BYTES) { - throw new IllegalArgumentException("Block too large by blockSize: " + expectedFullLen + " > " + MAX_BLOCK_FULL_BYTES); - } - - // [4] blockNumber this.blockNumber = bb.getInt(); - if (this.blockNumber < 0) { - throw new IllegalArgumentException("blockNumber < 0: " + this.blockNumber); - } - - // [8] timestamp + if (blockNumber < 0) throw new IllegalArgumentException("blockNumber < 0: " + blockNumber); this.timestamp = bb.getLong(); - - // запрет “в будущее” больше чем на 1 минуту long now = Instant.now().getEpochSecond(); - if (this.timestamp > now + MAX_FUTURE_SECONDS) { - throw new IllegalArgumentException("timestamp is too far in future: ts=" + this.timestamp - + " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS); + if (timestamp > now + MAX_FUTURE_SECONDS) { + throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp + " now=" + now); } - - // [2][2][2] type/subType/version this.type = bb.getShort(); this.subType = bb.getShort(); this.version = bb.getShort(); - - // [N] bodyBytes - int bodyLen = blockSize - PREIMAGE_HEADER_SIZE; - if (bodyLen < 0) { - throw new IllegalArgumentException("Invalid body length: " + bodyLen); - } - this.bodyBytes = new byte[bodyLen]; - bb.get(this.bodyBytes); - - // TAIL: [2] sigMarker - this.sigMarker = Short.toUnsignedInt(bb.getShort()); - if (this.sigMarker != SIG_MARKER_ED25519) { - throw new IllegalArgumentException(String.format( - "Bad sigMarker: 0x%04X (expected 0x%04X)", this.sigMarker, SIG_MARKER_ED25519 - )); - } - - // TAIL: [64] signature64 - this.signature64 = new byte[SIGNATURE_LEN]; - bb.get(this.signature64); - - // preimage = первые blockSize байт (включая frameCode) - this.preimage = Arrays.copyOfRange(fullBytes, 0, blockSize); - - // hash32 = sha256(preimage) - this.hash32 = BchCryptoVerifier.sha256(preimage); - - // parse body по header.type/subType/version + ОБЯЗАТЕЛЬНЫЙ check() - this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes); - - this.fullBytes = Arrays.copyOf(fullBytes, fullBytes.length); - - if (bb.remaining() != 0) { - throw new IllegalArgumentException("Unexpected tail bytes, remaining=" + bb.remaining()); - } + this.bodyBytes = new byte[bb.remaining()]; + bb.get(bodyBytes); + this.body = BodyRecordParser.parse(type, subType, version, bodyBytes); + this.hash32 = BchCryptoVerifier.sha256(frameBytes); } - /* ===================================================================== */ - /* ====================== Конструктор сборки ============================ */ - /* ===================================================================== */ - - public BchBlockEntry(byte[] prevHash32, - int blockNumber, - long timestamp, - short type, - short subType, - short version, - byte[] bodyBytes, - byte[] signature64) { - - Objects.requireNonNull(prevHash32, "prevHash32 == null"); - Objects.requireNonNull(bodyBytes, "bodyBytes == null"); - Objects.requireNonNull(signature64, "signature64 == null"); - - if (prevHash32.length != 32) throw new IllegalArgumentException("prevHash32 != 32"); - if (signature64.length != SIGNATURE_LEN) throw new IllegalArgumentException("signature64 != 64"); - - if (blockNumber < 0) { - throw new IllegalArgumentException("blockNumber < 0: " + blockNumber); - } - - // запрет “в будущее” больше чем на 1 минуту - long now = Instant.now().getEpochSecond(); - if (timestamp > now + MAX_FUTURE_SECONDS) { - throw new IllegalArgumentException("timestamp is too far in future: ts=" + timestamp - + " now=" + now + " maxFutureSec=" + MAX_FUTURE_SECONDS); - } - - this.frameCode = FRAME_CODE_V0; - this.prevHash32 = Arrays.copyOf(prevHash32, 32); - this.blockNumber = blockNumber; - this.timestamp = timestamp; - this.type = type; - this.subType = subType; - this.version = version; - this.bodyBytes = Arrays.copyOf(bodyBytes, bodyBytes.length); - - // blockSize = размер preimage (включая frameCode) - this.blockSize = PREIMAGE_HEADER_SIZE + this.bodyBytes.length; - - int fullLen = this.blockSize + SIG_MARKER_LEN + SIGNATURE_LEN; - if (fullLen > MAX_BLOCK_FULL_BYTES) { - throw new IllegalArgumentException("Block too large: " + fullLen + " > " + MAX_BLOCK_FULL_BYTES); - } - - // parse body по header + ОБЯЗАТЕЛЬНЫЙ check() - this.body = BodyRecordParser.parse(this.type, this.subType, this.version, this.bodyBytes); - - // tail marker фиксирован - this.sigMarker = SIG_MARKER_ED25519; - this.signature64 = Arrays.copyOf(signature64, SIGNATURE_LEN); - - // build preimage - ByteBuffer pre = ByteBuffer.allocate(blockSize).order(ByteOrder.BIG_ENDIAN); - pre.putShort((short) (FRAME_CODE_V0 & 0xFFFF)); - pre.put(this.prevHash32); - pre.putInt(this.blockSize); - pre.putInt(this.blockNumber); - pre.putLong(this.timestamp); - pre.putShort(this.type); - pre.putShort(this.subType); - pre.putShort(this.version); - pre.put(this.bodyBytes); - - this.preimage = pre.array(); - this.hash32 = BchCryptoVerifier.sha256(preimage); - - // build fullBytes: preimage + sigMarker + signature64 - ByteBuffer full = ByteBuffer.allocate(fullLen).order(ByteOrder.BIG_ENDIAN); - full.put(this.preimage); - full.putShort((short) (SIG_MARKER_ED25519 & 0xFFFF)); - full.put(this.signature64); - this.fullBytes = full.array(); - } - - /* ===================================================================== */ - /* ============================ Getters ================================= */ - /* ===================================================================== */ - - public byte[] getPreimageBytes() { - return Arrays.copyOf(preimage, preimage.length); - } - - /** Возвращает подпись Ed25519 (64 байта). */ - public byte[] getSignature64() { - return Arrays.copyOf(signature64, SIGNATURE_LEN); - } - - /** Возвращает hash32 = SHA-256(preimage). */ - public byte[] getHash32() { - return Arrays.copyOf(hash32, HASH_LEN); - } - - /** Возвращает полный блок: preimage + sigMarker + signature. */ - public byte[] toBytes() { - return Arrays.copyOf(fullBytes, fullBytes.length); - } + public Ans104DataItem getDataItem() { return dataItem; } + public byte[] getFrameBytes() { return Arrays.copyOf(frameBytes, frameBytes.length); } + /** Kept as an alias for code/docs that called the old field preimage. */ + public byte[] getPreimageBytes() { return getFrameBytes(); } + public byte[] getSignature64() { return dataItem.signature64(); } + public byte[] getOwner32() { return dataItem.owner32(); } + public byte[] getDataItemId32() { return dataItem.id32(); } + public byte[] getHash32() { return Arrays.copyOf(hash32, hash32.length); } + /** Full serialized ANS-104 DataItem. */ + public byte[] toBytes() { return dataItem.rawBytes(); } @Override public String toString() { - String timeIso; - try { - timeIso = Instant.ofEpochSecond(timestamp).toString(); - } catch (Exception e) { - timeIso = "некорректныйTimestamp"; - } - - return "BchBlockEntry{" - + "FRAME{frameCode=0x" + hex4(frameCode) - + "}, HDR{" - + "blockSize=" + blockSize - + ", blockNumber=" + blockNumber - + ", timestamp=" + timestamp + " (" + timeIso + ")" - + ", type=" + (type & 0xFFFF) - + ", subType=" + (subType & 0xFFFF) - + ", version=" + (version & 0xFFFF) - + ", prevHash32(hex)=" + toHex(prevHash32) - + "}" - + ", BODY{len=" + (bodyBytes == null ? -1 : bodyBytes.length) + "}" - + ", TAIL{sigMarker=0x" + hex4(sigMarker) + ", signature64(hex)=" + toHex(signature64) + "}" - + ", DERIVED{hash32(hex)=" + toHex(hash32) + "}" - + "}"; - } - - private static String hex4(int v) { - String s = Integer.toHexString(v & 0xFFFF); - while (s.length() < 4) s = "0" + s; - return s; + return "BchBlockEntry{" + + "frameCode=0x" + Integer.toHexString(frameCode) + + ", blockNumber=" + blockNumber + + ", timestamp=" + timestamp + + ", type=" + (type & 0xffff) + + ", subType=" + (subType & 0xffff) + + ", version=" + (version & 0xffff) + + ", bodyLen=" + bodyBytes.length + + ", dataItemId=" + toHex(getDataItemId32()) + + ", blockHash=" + toHex(hash32) + + '}'; } private static String toHex(byte[] bytes) { - if (bytes == null) return "null"; - char[] HEX = "0123456789abcdef".toCharArray(); + char[] h = "0123456789abcdef".toCharArray(); char[] out = new char[bytes.length * 2]; for (int i = 0; i < bytes.length; i++) { - int vv = bytes[i] & 0xFF; - out[i * 2] = HEX[vv >>> 4]; - out[i * 2 + 1] = HEX[vv & 0x0F]; + int v = bytes[i] & 0xff; + out[i * 2] = h[v >>> 4]; out[i * 2 + 1] = h[v & 15]; } return new String(out); } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java index ca657e80..8a8ddb49 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/BchCryptoVerifier.java @@ -1,42 +1,27 @@ package blockchain; -import utils.crypto.Ed25519Util; - import java.security.MessageDigest; import java.util.Objects; -/** - * Верификатор SHiNE (Frame v0): - * - * preimage = первые blockSize байт блока (ВКЛЮЧАЯ frameCode=0x0000), - * = всё до TAIL (sigMarker+signature). - * - * hash32 = SHA-256(preimage) - * verify = Ed25519.verify(hash32, signature64, pubKey32) - */ +/** Cryptographic checks for SHiNE Frame v1 + surrounding ANS-104 DataItem. */ public final class BchCryptoVerifier { - private BchCryptoVerifier() {} public static byte[] sha256(byte[] data) { Objects.requireNonNull(data, "data == null"); - try { - MessageDigest d = MessageDigest.getInstance("SHA-256"); - return d.digest(data); - } catch (Exception e) { - throw new IllegalStateException("SHA-256 unavailable", e); - } + try { return MessageDigest.getInstance("SHA-256").digest(data); } + catch (Exception e) { throw new IllegalStateException("SHA-256 unavailable", e); } } + /** + * Verifies that the ANS-104 owner is the current blockchain key and that the + * ANS-104 Ed25519 signature is valid. The SHiNE block hash itself remains + * SHA-256(Frame v1 bytes) and is intentionally independent of Arweave. + */ public static boolean verifyBlock(BchBlockEntry block, byte[] publicKey32) { Objects.requireNonNull(block, "block == null"); Objects.requireNonNull(publicKey32, "publicKey32 == null"); - if (publicKey32.length != 32) throw new IllegalArgumentException("publicKey32 != 32"); - - byte[] hash32 = block.getHash32(); - byte[] sig64 = block.getSignature64(); - - return Ed25519Util.verify(hash32, sig64, publicKey32); + return block.getDataItem().ownerEquals(publicKey32) && block.getDataItem().verifySignature(); } -} \ No newline at end of file +} diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java b/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java deleted file mode 100644 index a7550fb9..00000000 --- a/SHiNE-server/shine-server-blockchain/src/main/java/utils/files/FileStoreUtil.java +++ /dev/null @@ -1,297 +0,0 @@ -package utils.files; - -import java.io.IOException; -import java.nio.file.*; -import java.util.Objects; - -/** - * =============================================================== - * FileStoreUtil — утилита работы с файлами в папке data/. - * - * Теперь поддерживает: - * - основной файл блокчейна: .bch - * - временный файл блокчейна: .tmp_bch - * - sidecar-файл проверки записи: .write_check - * - marker-файл записи: .write_pending - * - * Важное: - * - validateSimpleFileName() запрещает path traversal. - * - atomicReplaceBlockchainFile(): пытается сделать ATOMIC_MOVE (если ФС поддерживает), - * иначе делает обычный REPLACE_EXISTING move. - * =============================================================== - */ -public final class FileStoreUtil { - - /** Базовая папка для хранения всех файлов (создаётся автоматически). */ - public static final String DATA_DIR_NAME = "data"; - - /** Расширение основного файла блокчейна. */ - public static final String BLOCKCHAIN_FILE_EXTENSION = ".bch"; - - /** Расширение временного файла (старое+новое). */ - public static final String BLOCKCHAIN_TMP_EXTENSION = ".tmp_bch"; - - /** Маркер того, что chain сейчас в процессе полного resync. */ - public static final String BLOCKCHAIN_RESYNC_MARKER_EXTENSION = ".resync_pending"; - - /** Marker того, что обычный AddBlock находится в опасной фазе записи. */ - public static final String BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION = ".write_pending"; - - /** Sidecar-файл с blockNumber/blockHash для обычного AddBlock. */ - public static final String BLOCKCHAIN_WRITE_CHECK_EXTENSION = ".write_check"; - - private static final FileStoreUtil INSTANCE = new FileStoreUtil(); - - private final Path dataDirPath; - - private FileStoreUtil() { - this.dataDirPath = Paths.get(DATA_DIR_NAME); - ensureDataDirExists(); - } - - public static FileStoreUtil getInstance() { - return INSTANCE; - } - - /* ===================================================================== */ - /* ======================== Базовые операции =========================== */ - /* ===================================================================== */ - - public void newFile(String fileName, byte[] data) { - Objects.requireNonNull(data, "data == null"); - Path target = resolveSafe(fileName); - try { - Files.write(target, data, - StandardOpenOption.CREATE, - StandardOpenOption.TRUNCATE_EXISTING, - StandardOpenOption.WRITE); - } catch (IOException e) { - throw new IllegalStateException("Не удалось записать файл: " + target, e); - } - } - - public void addDataToFile(String fileName, byte[] data) { - Objects.requireNonNull(data, "data == null"); - Path target = resolveSafe(fileName); - try { - Files.write(target, data, - StandardOpenOption.CREATE, - StandardOpenOption.WRITE, - StandardOpenOption.APPEND); - } catch (IOException e) { - throw new IllegalStateException("Не удалось дописать файл: " + target, e); - } - } - - public byte[] readAllDataFromFile(String fileName) { - Path target = resolveSafe(fileName); - if (!Files.exists(target)) { - throw new IllegalStateException("Файл не найден: " + target); - } - try { - return Files.readAllBytes(target); - } catch (IOException e) { - throw new IllegalStateException("Не удалось прочитать файл: " + target, e); - } - } - - public boolean exists(String fileName) { - Path target = resolveSafe(fileName); - return Files.exists(target); - } - - public long size(String fileName) { - Path target = resolveSafe(fileName); - try { - return Files.size(target); - } catch (IOException e) { - throw new IllegalStateException("Не удалось получить размер файла: " + target, e); - } - } - - /* ===================================================================== */ - /* ===================== Блокчейн-файлы по имени ======================= */ - /* ===================================================================== */ - - /** .bch */ - public String buildBlockchainFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_FILE_EXTENSION; - } - - /** .tmp_bch */ - public String buildBlockchainTmpFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_TMP_EXTENSION; - } - - public Path resolveBlockchainPath(String blockchainName) { - return resolveSafe(buildBlockchainFileName(blockchainName)); - } - - public Path resolveBlockchainTmpPath(String blockchainName) { - return resolveSafe(buildBlockchainTmpFileName(blockchainName)); - } - - public byte[] readBlockchain(String blockchainName) { - return readAllDataFromFile(buildBlockchainFileName(blockchainName)); - } - - public void writeBlockchainTmp(String blockchainName, byte[] data) { - newFile(buildBlockchainTmpFileName(blockchainName), data); - } - - /** .write_check */ - public String buildBlockchainWriteCheckFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_WRITE_CHECK_EXTENSION; - } - - public Path resolveBlockchainWriteCheckPath(String blockchainName) { - return resolveSafe(buildBlockchainWriteCheckFileName(blockchainName)); - } - - public void writeBlockchainWriteCheck(String blockchainName, int blockNumber, String blockHashHex) { - StringBuilder sb = new StringBuilder(128); - sb.append("blockNumber=").append(blockNumber).append('\n'); - sb.append("blockHash=").append(blockHashHex == null ? "" : blockHashHex).append('\n'); - newFile(buildBlockchainWriteCheckFileName(blockchainName), sb.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); - } - - /** .write_pending */ - public String buildBlockchainWritePendingMarkerFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION; - } - - public Path resolveBlockchainWritePendingMarkerPath(String blockchainName) { - return resolveSafe(buildBlockchainWritePendingMarkerFileName(blockchainName)); - } - - public void writeBlockchainWritePendingMarker(String blockchainName) { - newFile(buildBlockchainWritePendingMarkerFileName(blockchainName), new byte[0]); - } - - /** .resync_pending */ - public String buildBlockchainResyncMarkerFileName(String blockchainName) { - validateSimpleFileName(blockchainName); - return blockchainName + BLOCKCHAIN_RESYNC_MARKER_EXTENSION; - } - - public Path resolveBlockchainResyncMarkerPath(String blockchainName) { - return resolveSafe(buildBlockchainResyncMarkerFileName(blockchainName)); - } - - public void writeBlockchainResyncMarker(String blockchainName, String markerContent) { - byte[] data = markerContent == null ? new byte[0] : markerContent.getBytes(java.nio.charset.StandardCharsets.UTF_8); - newFile(buildBlockchainResyncMarkerFileName(blockchainName), data); - } - - public void deleteIfExists(Path path) { - if (path == null) { - return; - } - try { - Files.deleteIfExists(path); - } catch (IOException e) { - throw new IllegalStateException("Не удалось удалить файл: " + path, e); - } - } - - public void deleteBlockchainFileIfExists(String blockchainName) { - deleteIfExists(resolveBlockchainPath(blockchainName)); - } - - public void deleteBlockchainTmpFileIfExists(String blockchainName) { - deleteIfExists(resolveBlockchainTmpPath(blockchainName)); - } - - public void deleteBlockchainWriteCheckIfExists(String blockchainName) { - deleteIfExists(resolveBlockchainWriteCheckPath(blockchainName)); - } - - public void deleteBlockchainWritePendingMarkerIfExists(String blockchainName) { - deleteIfExists(resolveBlockchainWritePendingMarkerPath(blockchainName)); - } - - public void deleteBlockchainResyncMarkerIfExists(String blockchainName) { - deleteIfExists(resolveBlockchainResyncMarkerPath(blockchainName)); - } - - public boolean existsBlockchainResyncMarker(String blockchainName) { - return exists(buildBlockchainResyncMarkerFileName(blockchainName)); - } - - /** - * Атомарно заменить основной файл блокчейна временным: - * .tmp_bch -> .bch - * - * Стратегия: - * 1) Пытаемся Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING) - * 2) Если ATOMIC_MOVE не поддерживается — делаем move с REPLACE_EXISTING без атомарности - * - * Важный нюанс: - * - атомарность гарантируется только в пределах одной файловой системы. - */ - public void atomicReplaceBlockchainFile(String blockchainName) { - Path tmp = resolveBlockchainTmpPath(blockchainName); - Path main = resolveBlockchainPath(blockchainName); - - if (!Files.exists(tmp)) { - throw new IllegalStateException("TMP-файл не найден: " + tmp); - } - - try { - // 1) Пытаемся атомарный move - Files.move(tmp, main, - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.ATOMIC_MOVE); - } catch (AtomicMoveNotSupportedException e) { - // 2) Если ФС не поддерживает атомарный move — делаем обычный replace - try { - Files.move(tmp, main, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException ex) { - throw new IllegalStateException("Не удалось заменить файл блокчейна (non-atomic): " + main, ex); - } - } catch (IOException e) { - throw new IllegalStateException("Не удалось заменить файл блокчейна (atomic): " + main, e); - } - } - - /* ===================================================================== */ - /* ============================ Helpers ================================= */ - /* ===================================================================== */ - - private void ensureDataDirExists() { - try { - if (!Files.exists(dataDirPath)) { - Files.createDirectories(dataDirPath); - } - } catch (IOException e) { - throw new IllegalStateException("Не удалось создать директорию хранения: " + dataDirPath, e); - } - } - - private Path resolveSafe(String fileName) { - validateSimpleFileName(fileName); - return dataDirPath.resolve(fileName); - } - - /** - * Валидация "простого имени": - * - запрещаем слэши, обратные слэши, ".." - * - запрещаем пустоту - * - * Важно: сюда у нас попадает и blockchainName (как часть имени файла), - * поэтому blockchainName должен быть "простым": без путей. - */ - private void validateSimpleFileName(String fileName) { - Objects.requireNonNull(fileName, "fileName == null"); - if (fileName.isBlank()) { - throw new IllegalArgumentException("Имя файла не должно быть пустым"); - } - if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) { - throw new IllegalArgumentException("Недопустимое имя файла: " + fileName); - } - } -} diff --git a/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java new file mode 100644 index 00000000..2350b93b --- /dev/null +++ b/SHiNE-server/shine-server-blockchain/src/test/java/blockchain/Ans104DataItemTest.java @@ -0,0 +1,74 @@ +package blockchain; + +import org.junit.jupiter.api.Test; +import utils.crypto.Ed25519Util; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +final class Ans104DataItemTest { + @Test + void ed25519RoundTripWithTestAndChannelTags() { + byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-test-key"); + byte[] owner = Ed25519Util.derivePublicKey(privateKey); + byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8); + List tags = List.of( + new Ans104DataItem.Tag("App", "test5590"), + new Ans104DataItem.Tag("c_test5590", "books") + ); + + byte[] message = Ans104DataItem.buildSigningMessage(owner, tags, data); + byte[] signature = Ed25519Util.sign(message, privateKey); + byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data); + + Ans104DataItem parsed = new Ans104DataItem(raw); + assertEquals(2, parsed.signatureType()); + assertArrayEquals(owner, parsed.owner32()); + assertArrayEquals(data, parsed.data()); + assertTrue(parsed.hasTag("App", "test5590")); + assertEquals("books", parsed.tagValue("c_test5590")); + assertTrue(parsed.verifySignature()); + assertEquals(32, parsed.id32().length); + } + + @Test + void mutationBreaksSignature() { + byte[] privateKey = Ed25519Util.generatePrivateKeyFromString("shine-ans104-mutation-key"); + byte[] owner = Ed25519Util.derivePublicKey(privateKey); + byte[] data = "original".getBytes(StandardCharsets.UTF_8); + List tags = List.of(new Ans104DataItem.Tag("App", "test5590")); + byte[] signature = Ed25519Util.sign(Ans104DataItem.buildSigningMessage(owner, tags, data), privateKey); + byte[] raw = Ans104DataItem.serializeEd25519(signature, owner, tags, data); + raw[raw.length - 1] ^= 1; + assertFalse(new Ans104DataItem(raw).verifySignature()); + } + @Test + void signingMessageMatchesArbundlesTurboCompatibilityVector() { + byte[] owner = new byte[32]; + for (int i = 0; i < owner.length; i++) owner[i] = (byte) i; + byte[] data = "frame-v1-test".getBytes(StandardCharsets.UTF_8); + List tags = List.of( + new Ans104DataItem.Tag("App", "test5590"), + new Ans104DataItem.Tag("c_test5590", "books") + ); + + byte[] actual = Ans104DataItem.buildSigningMessage(owner, tags, data); + byte[] expected = hex("1abe0371d12268b34be32ca5ebf3d3d2189f9d311d9004c142538ca8a4312faa329d1a4873df72dd01b632a2ecce6b87"); + assertArrayEquals(expected, actual); + } + + private static byte[] hex(String value) { + if ((value.length() & 1) != 0) throw new IllegalArgumentException("odd hex length"); + byte[] out = new byte[value.length() / 2]; + for (int i = 0; i < out.length; i++) { + int hi = Character.digit(value.charAt(i * 2), 16); + int lo = Character.digit(value.charAt(i * 2 + 1), 16); + if (hi < 0 || lo < 0) throw new IllegalArgumentException("bad hex"); + out[i] = (byte) ((hi << 4) | lo); + } + return out; + } + +} diff --git a/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java b/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java index 027779b0..14a71683 100644 --- a/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java +++ b/SHiNE-server/shine-server-config/src/main/java/utils/config/AppConfig.java @@ -66,19 +66,20 @@ public final class AppConfig { /** Вернёт строку или пустую строку, если параметр не найден. */ public String getStringOrEmpty(String name) { - String value = properties.getProperty(name); + String value = getParam(name); return value == null ? "" : value.trim(); } - /** Можно добавить методы для удобства */ + /** Целочисленный параметр с тем же приоритетом: system property -> env -> properties. */ public int getInt(String name, int defaultValue) { - String v = properties.getProperty(name); - return v == null ? defaultValue : Integer.parseInt(v); + String v = getParam(name); + return v == null || v.isBlank() ? defaultValue : Integer.parseInt(v.trim()); } + /** Boolean-параметр с тем же приоритетом: system property -> env -> properties. */ public boolean getBoolean(String name, boolean defaultValue) { - String v = properties.getProperty(name); - return v == null ? defaultValue : Boolean.parseBoolean(v); + String v = getParam(name); + return v == null || v.isBlank() ? defaultValue : Boolean.parseBoolean(v.trim()); } private static String toEnvName(String name) { diff --git a/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java b/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java index a8cb5d08..fd730bba 100644 --- a/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java +++ b/SHiNE-server/shine-server-config/src/main/java/utils/config/ShineSignatureConstants.java @@ -17,6 +17,9 @@ public final class ShineSignatureConstants { /** Подписываемые данные пользовательских настроек: prefix + login + type + key + time_ms + value_text + value_num */ public static final String USER_SETTINGS_PREFIX = "SHiNe/UserSettings:"; + /** Подписанный watermark чтения канала: prefix + login + owner_bch_name + channel_name + time_ms + read_count */ + public static final String CHANNEL_READ_STATE_PREFIX = "SHiNe/ChannelReadState:"; + /** TAG в HeaderBody (genesis). ASCII "SHiNe". */ public static final String BLOCKCHAIN_HEADER_TAG = "SHiNe"; diff --git a/SHiNE-server/shine-server-crypto/src/main/java/utils/crypto/README.md b/SHiNE-server/shine-server-crypto/src/main/java/utils/crypto/README.md index 3e132bed..20b4dcc5 100644 --- a/SHiNE-server/shine-server-crypto/src/main/java/utils/crypto/README.md +++ b/SHiNE-server/shine-server-crypto/src/main/java/utils/crypto/README.md @@ -1,7 +1,7 @@ # utils.crypto Пакет отвечает за криптографию — подписи и хэши блоков. -Используется при создании и проверке целостности `.bch`-блоков. +Используется при создании и проверке криптографических сущностей SHiNE; пользовательские blockchain-файлы `.bch` больше не используются. --- diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index eb9bb3f6..9c19edf7 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -37,6 +37,11 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_18 = 18; public static final int SCHEMA_VERSION_19 = 19; public static final int SCHEMA_VERSION_20 = 20; + public static final int SCHEMA_VERSION_21 = 21; + public static final int SCHEMA_VERSION_22 = 22; + public static final int SCHEMA_VERSION_23 = 23; + public static final int SCHEMA_VERSION_24 = 24; + public static final int SCHEMA_VERSION_25 = 25; public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql"; public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql"; public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql"; @@ -57,6 +62,11 @@ public final class DatabaseInitializer { public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql"; public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql"; public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql"; + public static final String POSTGRES_MIGRATION_V21_RESOURCE = "postgres/migration_v21.sql"; + public static final String POSTGRES_MIGRATION_V22_RESOURCE = "postgres/migration_v22.sql"; + public static final String POSTGRES_MIGRATION_V23_RESOURCE = "postgres/migration_v23.sql"; + public static final String POSTGRES_MIGRATION_V24_RESOURCE = "postgres/migration_v24.sql"; + public static final String POSTGRES_MIGRATION_V25_RESOURCE = "postgres/migration_v25.sql"; private DatabaseInitializer() {} @@ -206,6 +216,26 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE); currentVersion = SCHEMA_VERSION_20; } + if (currentVersion < SCHEMA_VERSION_21) { + runSqlScript(conn, POSTGRES_MIGRATION_V21_RESOURCE); + currentVersion = SCHEMA_VERSION_21; + } + if (currentVersion < SCHEMA_VERSION_22) { + runSqlScript(conn, POSTGRES_MIGRATION_V22_RESOURCE); + currentVersion = SCHEMA_VERSION_22; + } + if (currentVersion < SCHEMA_VERSION_23) { + runSqlScript(conn, POSTGRES_MIGRATION_V23_RESOURCE); + currentVersion = SCHEMA_VERSION_23; + } + if (currentVersion < SCHEMA_VERSION_24) { + runSqlScript(conn, POSTGRES_MIGRATION_V24_RESOURCE); + currentVersion = SCHEMA_VERSION_24; + } + if (currentVersion < SCHEMA_VERSION_25) { + runSqlScript(conn, POSTGRES_MIGRATION_V25_RESOURCE); + currentVersion = SCHEMA_VERSION_25; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java new file mode 100644 index 00000000..74b8174e --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ArweaveBlockImportDAO.java @@ -0,0 +1,137 @@ +package shine.db.dao; + +import shine.db.DbController; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +/** Persistent discovery/import queue for individual ANS-104 SHiNE DataItems found through Arweave. */ +public final class ArweaveBlockImportDAO { + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_REJECTED = "REJECTED"; + + public record QueueItem(byte[] dataItemId, long blockHeight, + byte[] rawDataItem, String status, String lastError, + long firstSeenAtMs, long updatedAtMs) {} + + private static volatile ArweaveBlockImportDAO instance; + private final DbController db = DbController.getInstance(); + + private ArweaveBlockImportDAO() {} + + public static ArweaveBlockImportDAO getInstance() { + if (instance == null) { + synchronized (ArweaveBlockImportDAO.class) { + if (instance == null) instance = new ArweaveBlockImportDAO(); + } + } + return instance; + } + + public long getLastBlockHeight() throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement("SELECT last_block_height FROM arweave_block_sync_state WHERE id=1"); + ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getLong(1) : 0L; + } + } + + public void setLastBlockHeight(long height, long nowMs) throws SQLException { + String sql = """ + INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms) + VALUES(1,?,?) + ON CONFLICT(id) DO UPDATE SET last_block_height=EXCLUDED.last_block_height, updated_at_ms=EXCLUDED.updated_at_ms + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, Math.max(0L, height)); + ps.setLong(2, nowMs); + ps.executeUpdate(); + } + } + + /** Insert once. Existing IDs (including REJECTED) are deliberately not re-enqueued. */ + public boolean enqueueIfMissing(byte[] dataItemId, long blockHeight, byte[] rawDataItem, long nowMs) + throws SQLException { + String sql = """ + INSERT INTO arweave_block_import_queue( + data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms + ) VALUES(?,?,?,?, '',?,?) + ON CONFLICT(data_item_id) DO NOTHING + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setBytes(1, dataItemId); + ps.setLong(2, blockHeight); + ps.setBytes(3, rawDataItem); + ps.setString(4, STATUS_PENDING); + ps.setLong(5, nowMs); + ps.setLong(6, nowMs); + return ps.executeUpdate() > 0; + } + } + + public boolean exists(byte[] dataItemId) throws SQLException { + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement("SELECT 1 FROM arweave_block_import_queue WHERE data_item_id=? LIMIT 1")) { + ps.setBytes(1, dataItemId); + try (ResultSet rs = ps.executeQuery()) { return rs.next(); } + } + } + + public List listPending(int limit) throws SQLException { + int safeLimit = Math.max(1, Math.min(limit, 100_000)); + String sql = """ + SELECT data_item_id,block_height,raw_data_item,status,last_error,first_seen_at_ms,updated_at_ms + FROM arweave_block_import_queue + WHERE status=? + ORDER BY block_height ASC, first_seen_at_ms ASC + LIMIT ? + """; + List out = new ArrayList<>(); + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, STATUS_PENDING); + ps.setInt(2, safeLimit); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + out.add(new QueueItem( + rs.getBytes("data_item_id"), rs.getLong("block_height"), + rs.getBytes("raw_data_item"), rs.getString("status"), rs.getString("last_error"), + rs.getLong("first_seen_at_ms"), rs.getLong("updated_at_ms"))); + } + } + } + return out; + } + + public void setPendingError(byte[] id, String error, long nowMs) throws SQLException { + updateStatus(id, STATUS_PENDING, error, nowMs); + } + + public void reject(byte[] id, String error, long nowMs) throws SQLException { + updateStatus(id, STATUS_REJECTED, error, nowMs); + } + + public void delete(byte[] id) throws SQLException { + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement( + "DELETE FROM arweave_block_import_queue WHERE data_item_id=?")) { + ps.setBytes(1, id); + ps.executeUpdate(); + } + } + + private void updateStatus(byte[] id, String status, String error, long nowMs) throws SQLException { + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement( + "UPDATE arweave_block_import_queue SET status=?,last_error=?,updated_at_ms=? WHERE data_item_id=?")) { + ps.setString(1, status); + ps.setString(2, trimError(error)); + ps.setLong(3, nowMs); + ps.setBytes(4, id); + ps.executeUpdate(); + } + } + + private static String trimError(String s) { + String v = s == null ? "" : s.replace('\n',' ').replace('\r',' ').trim(); + return v.length() <= 500 ? v : v.substring(0,500); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java index 2cfc5645..4f461d3f 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlockchainResyncCleanupDAO.java @@ -23,7 +23,7 @@ import java.sql.SQLException; * - нам нужен один понятный "блок операции", который можно вызвать из resync-flow; * - эта схема проще и прозрачнее, чем много обратных триггеров по разным таблицам; * - если любой шаг не удался, делаем rollback и БД остаётся в исходном состоянии; - * - файловые действия (.bch / .tmp_bch) сознательно НЕ входят в эту транзакцию: + * - пользовательские blockchain-файлы больше не существуют; cleanup полностью DB-only. * SQL-транзакция и файловая система не коммитятся атомарно вместе; * поэтому БД-чистка делается здесь, а файловая чистка будет следующим шагом * отдельным recovery/resync-слоем после успешного commit. @@ -71,7 +71,7 @@ public final class BlockchainResyncCleanupDAO { * - если blockchain_state уже отсутствует, login берём из blockchainName. * * Отдельно важно: - * - здесь НЕТ удаления .bch/.tmp_bch; + * - filesystem blockchain storage отсутствует; * - здесь НЕТ повторной загрузки цепочки; * - это только атомарная SQL-очистка БД, на которую потом будет опираться resync-flow. */ diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java index 2719c31c..e7783ba6 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/BlocksDAO.java @@ -9,286 +9,146 @@ import java.sql.*; import java.util.ArrayList; import java.util.List; -/** - * DAO для таблицы blocks (новый формат). - * - * Правило: - * - методы с Connection НЕ закрывают соединение - * - методы без Connection сами открывают и закрывают соединение - * - * Ключ: - * - (bch_name, block_number) — уникальная пара в рамках общей БД сервера. - */ +/** DAO for SHiNE Frame v1 blocks. block_bytes contains the complete ANS-104 DataItem. */ public final class BlocksDAO { - private static volatile BlocksDAO instance; private final DbController db = DbController.getInstance(); private static final Logger log = LoggerFactory.getLogger(BlocksDAO.class); private BlocksDAO() { } - public static BlocksDAO getInstance() { - if (instance == null) { - synchronized (BlocksDAO.class) { - if (instance == null) instance = new BlocksDAO(); - } - } + if (instance == null) synchronized (BlocksDAO.class) { if (instance == null) instance = new BlocksDAO(); } return instance; } - // -------------------- INSERT -------------------- - - /** Вставка с внешним соединением. Соединение НЕ закрывает. */ public void insert(Connection c, BlockEntry e) throws SQLException { - log.info("DBG BlockEntry: type={} sub={} lineCode={} prevLineNumber={} thisLineNumber={} prevLineHashLen={}", - e.getMsgType(), e.getMsgSubType(), - e.getLineCode(), e.getPrevLineNumber(), e.getThisLineNumber(), - e.getPrevLineHash() == null ? null : e.getPrevLineHash().length - ); - String sql = """ INSERT INTO blocks ( - login, - bch_name, - block_number, - msg_type, - msg_sub_type, - block_bytes, - to_login, - to_bch_name, - to_block_number, - to_block_hash, - block_hash, - block_signature, - edited_by_block_number, - line_code, - prev_line_number, - prev_line_hash, - this_line_number - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, + to_login,to_bch_name,to_block_number,to_block_hash, + block_hash,block_signature,data_item_id, + arweave_publish_pending,arweave_published_at_ms, + edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """; - try (PreparedStatement ps = c.prepareStatement(sql)) { int i = 1; - ps.setString(i++, e.getLogin()); ps.setString(i++, e.getBchName()); ps.setInt(i++, e.getBlockNumber()); - ps.setInt(i++, e.getMsgType()); ps.setInt(i++, e.getMsgSubType()); - ps.setBytes(i++, e.getBlockBytes()); - - if (e.getToLogin() != null) ps.setString(i++, e.getToLogin()); - else ps.setNull(i++, Types.VARCHAR); - - if (e.getToBchName() != null) ps.setString(i++, e.getToBchName()); - else ps.setNull(i++, Types.VARCHAR); - - if (e.getToBlockNumber() != null) ps.setInt(i++, e.getToBlockNumber()); - else ps.setNull(i++, Types.INTEGER); - - if (e.getToBlockHash() != null) ps.setBytes(i++, e.getToBlockHash()); - else ps.setNull(i++, Types.BINARY); - + setNullableString(ps, i++, e.getToLogin()); + setNullableString(ps, i++, e.getToBchName()); + setNullableInt(ps, i++, e.getToBlockNumber()); + setNullableBytes(ps, i++, e.getToBlockHash()); ps.setBytes(i++, e.getBlockHash()); ps.setBytes(i++, e.getBlockSignature()); - - if (e.getEditedByBlockNumber() != null) ps.setInt(i++, e.getEditedByBlockNumber()); - else ps.setNull(i++, Types.INTEGER); - - // NEW: line_code - if (e.getLineCode() != null) ps.setInt(i++, e.getLineCode()); - else ps.setNull(i++, Types.INTEGER); - - if (e.getPrevLineNumber() != null) ps.setInt(i++, e.getPrevLineNumber()); - else ps.setNull(i++, Types.INTEGER); - - if (e.getPrevLineHash() != null) ps.setBytes(i++, e.getPrevLineHash()); - else ps.setNull(i++, Types.BINARY); - - if (e.getThisLineNumber() != null) ps.setInt(i++, e.getThisLineNumber()); - else ps.setNull(i++, Types.INTEGER); - + ps.setBytes(i++, e.getDataItemId()); + ps.setBoolean(i++, e.isArweavePublishPending()); + if (e.getArweavePublishedAtMs() == null) ps.setNull(i++, Types.BIGINT); else ps.setLong(i++, e.getArweavePublishedAtMs()); + setNullableInt(ps, i++, e.getEditedByBlockNumber()); + setNullableInt(ps, i++, e.getLineCode()); + setNullableInt(ps, i++, e.getPrevLineNumber()); + setNullableBytes(ps, i++, e.getPrevLineHash()); + setNullableInt(ps, i++, e.getThisLineNumber()); ps.executeUpdate(); } } - /** Вставка без внешнего соединения. Сам открывает/закрывает. */ - public void insert(BlockEntry e) throws SQLException { - try (Connection c = db.getConnection()) { - insert(c, e); + public void insert(BlockEntry e) throws SQLException { try (Connection c = db.getConnection()) { insert(c, e); } } + + public boolean existsByDataItemId(byte[] dataItemId) throws SQLException { + if (dataItemId == null || dataItemId.length != 32) return false; + try (Connection c = db.getConnection(); + PreparedStatement ps = c.prepareStatement("SELECT 1 FROM blocks WHERE data_item_id=? LIMIT 1")) { + ps.setBytes(1, dataItemId); + try (ResultSet rs = ps.executeQuery()) { return rs.next(); } } } - // -------------------- SELECT: HASH BY NUMBER -------------------- - - /** Получить block_hash по (bch_name, block_number). Нужен для линейной проверки. */ - public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException { + public List listPendingArweave(int limit) throws SQLException { + int safeLimit = Math.max(1, Math.min(limit, 100_000)); String sql = """ - SELECT block_hash + SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, + to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id, + arweave_publish_pending,arweave_published_at_ms, + edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number FROM blocks - WHERE bch_name = ? AND block_number = ? - LIMIT 1 + WHERE arweave_publish_pending = TRUE + ORDER BY bch_name ASC, block_number ASC + LIMIT ? """; - - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, bchName); - ps.setInt(2, blockNumber); - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return rs.getBytes("block_hash"); - } + List out = new ArrayList<>(); + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setInt(1, safeLimit); + try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); } } + return out; } - public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { + public void markArweavePublished(List dataItemIds, long publishedAtMs) throws SQLException { + if (dataItemIds == null || dataItemIds.isEmpty()) return; + String sql = """ + UPDATE blocks + SET arweave_publish_pending=FALSE, arweave_published_at_ms=? + WHERE data_item_id=? + """; try (Connection c = db.getConnection()) { - return getHashByNumber(c, bchName, blockNumber); + c.setAutoCommit(false); + try (PreparedStatement ps = c.prepareStatement(sql)) { + for (byte[] id : dataItemIds) { + ps.setLong(1, publishedAtMs); ps.setBytes(2, id); ps.addBatch(); + } + ps.executeBatch(); c.commit(); + } catch (Exception e) { c.rollback(); throw e; } + finally { c.setAutoCommit(true); } } } - // -------------------- SELECT: FULL ENTRY -------------------- + public byte[] getHashByNumber(Connection c, String bchName, int blockNumber) throws SQLException { + try (PreparedStatement ps = c.prepareStatement("SELECT block_hash FROM blocks WHERE bch_name=? AND block_number=? LIMIT 1")) { + ps.setString(1,bchName); ps.setInt(2,blockNumber); + try (ResultSet rs=ps.executeQuery()) { return rs.next()?rs.getBytes(1):null; } + } + } + public byte[] getHashByNumber(String bchName, int blockNumber) throws SQLException { try(Connection c=db.getConnection()){return getHashByNumber(c,bchName,blockNumber);} } public BlockEntry getByNumber(Connection c, String bchName, int blockNumber) throws SQLException { - String sql = """ - SELECT - login, - bch_name, - block_number, - msg_type, - msg_sub_type, - block_bytes, - to_login, - to_bch_name, - to_block_number, - to_block_hash, - block_hash, - block_signature, - edited_by_block_number, - line_code, - prev_line_number, - prev_line_hash, - this_line_number - FROM blocks - WHERE bch_name = ? AND block_number = ? - LIMIT 1 - """; + String sql = baseSelect()+" WHERE bch_name=? AND block_number=? LIMIT 1"; + try(PreparedStatement ps=c.prepareStatement(sql)){ ps.setString(1,bchName); ps.setInt(2,blockNumber); try(ResultSet rs=ps.executeQuery()){return rs.next()?mapRow(rs):null;} } + } + public BlockEntry getByNumber(String bchName,int blockNumber)throws SQLException{try(Connection c=db.getConnection()){return getByNumber(c,bchName,blockNumber);} } - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, bchName); - ps.setInt(2, blockNumber); - - try (ResultSet rs = ps.executeQuery()) { - if (!rs.next()) return null; - return mapRow(rs); - } - } + public List listRangeByNumber(String bchName,int from,int to)throws SQLException{try(Connection c=db.getConnection()){return listRangeByNumber(c,bchName,from,to);} } + public List listRangeByNumber(Connection c,String bchName,int from,int to)throws SQLException{ + String sql=baseSelect()+" WHERE bch_name=? AND block_number>=? AND block_number<=? ORDER BY block_number ASC"; + List out=new ArrayList<>(); + try(PreparedStatement ps=c.prepareStatement(sql)){ps.setString(1,bchName);ps.setInt(2,from);ps.setInt(3,to);try(ResultSet rs=ps.executeQuery()){while(rs.next())out.add(mapRow(rs));}} + return out; } - public BlockEntry getByNumber(String bchName, int blockNumber) throws SQLException { - try (Connection c = db.getConnection()) { - return getByNumber(c, bchName, blockNumber); - } - } - - public List listRangeByNumber(String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException { - try (Connection c = db.getConnection()) { - return listRangeByNumber(c, bchName, fromBlockNumberInclusive, toBlockNumberInclusive); - } - } - - public List listRangeByNumber(Connection c, String bchName, int fromBlockNumberInclusive, int toBlockNumberInclusive) throws SQLException { - String sql = """ - SELECT - login, - bch_name, - block_number, - msg_type, - msg_sub_type, - block_bytes, - to_login, - to_bch_name, - to_block_number, - to_block_hash, - block_hash, - block_signature, - edited_by_block_number, - line_code, - prev_line_number, - prev_line_hash, - this_line_number - FROM blocks - WHERE bch_name = ? - AND block_number >= ? - AND block_number <= ? - ORDER BY block_number ASC - """; - - List result = new ArrayList<>(); - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, bchName); - ps.setInt(2, fromBlockNumberInclusive); - ps.setInt(3, toBlockNumberInclusive); - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - result.add(mapRow(rs)); - } - } - } - return result; - } - - // -------------------- INTERNAL -------------------- - - private BlockEntry mapRow(ResultSet rs) throws SQLException { - BlockEntry e = new BlockEntry(); - - e.setLogin(rs.getString("login")); - e.setBchName(rs.getString("bch_name")); - e.setBlockNumber(rs.getInt("block_number")); - - e.setMsgType(rs.getInt("msg_type")); - e.setMsgSubType(rs.getInt("msg_sub_type")); - - e.setBlockBytes(rs.getBytes("block_bytes")); - - String toLogin = rs.getString("to_login"); - if (rs.wasNull()) toLogin = null; - e.setToLogin(toLogin); - - String toBchName = rs.getString("to_bch_name"); - if (rs.wasNull()) toBchName = null; - e.setToBchName(toBchName); - - Integer toBlockNumber = (Integer) rs.getObject("to_block_number"); - e.setToBlockNumber(toBlockNumber); - - byte[] toHash = rs.getBytes("to_block_hash"); - if (rs.wasNull()) toHash = null; - e.setToBlockHash(toHash); - - e.setBlockHash(rs.getBytes("block_hash")); - e.setBlockSignature(rs.getBytes("block_signature")); - - Integer editedBy = (Integer) rs.getObject("edited_by_block_number"); - e.setEditedByBlockNumber(editedBy); - - // NEW: line_code - Integer lineCode = (Integer) rs.getObject("line_code"); - e.setLineCode(lineCode); - - Integer prevLn = (Integer) rs.getObject("prev_line_number"); - e.setPrevLineNumber(prevLn); - - byte[] prevLh = rs.getBytes("prev_line_hash"); - if (rs.wasNull()) prevLh = null; - e.setPrevLineHash(prevLh); - - Integer thisLn = (Integer) rs.getObject("this_line_number"); - e.setThisLineNumber(thisLn); + private static String baseSelect(){return """ + SELECT login,bch_name,block_number,msg_type,msg_sub_type,block_bytes, + to_login,to_bch_name,to_block_number,to_block_hash,block_hash,block_signature,data_item_id, + arweave_publish_pending,arweave_published_at_ms, + edited_by_block_number,line_code,prev_line_number,prev_line_hash,this_line_number + FROM blocks + """;} + private BlockEntry mapRow(ResultSet rs)throws SQLException{ + BlockEntry e=new BlockEntry(); + e.setLogin(rs.getString("login")); e.setBchName(rs.getString("bch_name")); e.setBlockNumber(rs.getInt("block_number")); + e.setMsgType(rs.getInt("msg_type")); e.setMsgSubType(rs.getInt("msg_sub_type")); e.setBlockBytes(rs.getBytes("block_bytes")); + e.setToLogin(rs.getString("to_login")); e.setToBchName(rs.getString("to_bch_name")); e.setToBlockNumber((Integer)rs.getObject("to_block_number")); e.setToBlockHash(rs.getBytes("to_block_hash")); + e.setBlockHash(rs.getBytes("block_hash")); e.setBlockSignature(rs.getBytes("block_signature")); e.setDataItemId(rs.getBytes("data_item_id")); + e.setArweavePublishPending(rs.getBoolean("arweave_publish_pending")); e.setArweavePublishedAtMs((Long)rs.getObject("arweave_published_at_ms")); + e.setEditedByBlockNumber((Integer)rs.getObject("edited_by_block_number")); e.setLineCode((Integer)rs.getObject("line_code")); e.setPrevLineNumber((Integer)rs.getObject("prev_line_number")); e.setPrevLineHash(rs.getBytes("prev_line_hash")); e.setThisLineNumber((Integer)rs.getObject("this_line_number")); return e; } + + private static void setNullableString(PreparedStatement ps,int i,String v)throws SQLException{if(v==null)ps.setNull(i,Types.VARCHAR);else ps.setString(i,v);} + private static void setNullableInt(PreparedStatement ps,int i,Integer v)throws SQLException{if(v==null)ps.setNull(i,Types.INTEGER);else ps.setInt(i,v);} + private static void setNullableBytes(PreparedStatement ps,int i,byte[] v)throws SQLException{if(v==null)ps.setNull(i,Types.BINARY);else ps.setBytes(i,v);} } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java index 762a4292..d9076c99 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelNameStateDAO.java @@ -120,4 +120,15 @@ public final class ChannelNameStateDAO { insert(c, entry); } } + public String getSlugByOwnerAndRoot(String ownerBchName, int rootBlockNumber) throws SQLException { + String sql = "SELECT slug FROM channel_names_state WHERE owner_bch_name=? AND channel_root_block_number=? LIMIT 1"; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerBchName); + ps.setInt(2, rootBlockNumber); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString(1) : null; + } + } + } + } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java new file mode 100644 index 00000000..14ddb017 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/ChannelReadStateDAO.java @@ -0,0 +1,298 @@ +package shine.db.dao; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +/** + * Server projection for per-user channel read/unread state. + * + * Important rules: + * - rows are created only by a signed client SetChannelReadState; + * - channel publications increment unread_count only for rows that already exist; + * - read_count is monotonic and unread_count is reduced by the accepted read delta; + * - unread_count is never part of the client signature and is server-owned. + */ +public final class ChannelReadStateDAO { + private static volatile ChannelReadStateDAO instance; + + private ChannelReadStateDAO() {} + + public static ChannelReadStateDAO getInstance() { + if (instance == null) { + synchronized (ChannelReadStateDAO.class) { + if (instance == null) instance = new ChannelReadStateDAO(); + } + } + return instance; + } + + public record ReadState(long readCount, long unreadCount, long readUpdatedAtMs, + String clientKey, String readSignature) {} + + public record UpsertResult(boolean applied, long readCount, long unreadCount) {} + + public ReadState get(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException { + String sql = """ + SELECT read_count, unread_count, read_updated_at_ms, client_key, read_signature + FROM channel_read_state + WHERE LOWER(viewer_login)=LOWER(?) AND owner_bch_name=? AND channel_name=? + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setString(2, ownerBchName); + ps.setString(3, channelName); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + return new ReadState( + Math.max(0L, rs.getLong("read_count")), + Math.max(0L, rs.getLong("unread_count")), + rs.getLong("read_updated_at_ms"), + rs.getString("client_key"), + rs.getString("read_signature")); + } + } + } + + public long getUnreadCount(Connection c, String viewerLogin, String ownerBchName, String channelName) throws SQLException { + ReadState state = get(c, viewerLogin, ownerBchName, channelName); + return state == null ? 0L : state.unreadCount(); + } + + public long sumUnreadCount(Connection c, String viewerLogin) throws SQLException { + String sql = """ + SELECT COALESCE(SUM(crs.unread_count),0) + FROM channel_read_state crs + WHERE LOWER(crs.viewer_login)=LOWER(?) + AND EXISTS ( + SELECT 1 + FROM channel_names_state cns + JOIN connections_state cs + ON LOWER(cs.login)=LOWER(crs.viewer_login) + AND cs.rel_type=30 + AND cs.to_bch_name=cns.owner_bch_name + AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number + AND cs.to_block_hash=cns.channel_root_block_hash + WHERE cns.owner_bch_name=crs.owner_bch_name + AND cns.slug=crs.channel_name + AND cns.channel_type_code=1 + ) + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L; + } + } + } + + /** + * Validates that the requested public channel exists and that viewer currently follows it. + */ + public boolean hasActiveSubscription(Connection c, String viewerLogin, String ownerBchName, String channelName, + int followRelType) throws SQLException { + String sql = """ + SELECT 1 + FROM channel_names_state cns + JOIN connections_state cs + ON LOWER(cs.login)=LOWER(?) + AND cs.rel_type=? + AND cs.to_bch_name=cns.owner_bch_name + AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number + AND cs.to_block_hash=cns.channel_root_block_hash + WHERE cns.owner_bch_name=? + AND cns.slug=? + AND cns.channel_type_code=1 + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setInt(2, followRelType); + ps.setString(3, ownerBchName); + ps.setString(4, channelName); + try (ResultSet rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + public String resolvePublicChannelNameByRoot(Connection c, String ownerBchName, int rootBlockNumber) throws SQLException { + String sql = """ + SELECT slug + FROM channel_names_state + WHERE owner_bch_name=? AND channel_root_block_number=? AND channel_type_code=1 + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerBchName); + ps.setInt(2, rootBlockNumber); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString("slug") : null; + } + } + } + + + public Integer resolvePublicChannelRootByName(Connection c, String ownerBchName, String channelName) throws SQLException { + String sql = """ + SELECT channel_root_block_number + FROM channel_names_state + WHERE owner_bch_name=? AND slug=? AND channel_type_code=1 + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerBchName); + ps.setString(2, channelName); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getInt("channel_root_block_number") : null; + } + } + } + + public String resolveOwnerLogin(Connection c, String ownerBchName, String channelName) throws SQLException { + String sql = """ + SELECT owner_login + FROM channel_names_state + WHERE owner_bch_name=? AND slug=? AND channel_type_code=1 + LIMIT 1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, ownerBchName); + ps.setString(2, channelName); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString("owner_login") : null; + } + } + } + + /** + * Inserts the first client baseline with unread=0, or advances an existing read watermark. + * Older timestamps and backwards read_count values are ignored. + */ + public UpsertResult upsertSignedReadIfNewer(Connection c, + String viewerLogin, + String ownerLogin, + String ownerBchName, + String channelName, + long readCount, + long readUpdatedAtMs, + String clientKey, + String readSignature, + long nowMs) throws SQLException { + String sql = """ + INSERT INTO channel_read_state ( + viewer_login, owner_login, owner_bch_name, channel_name, + read_count, unread_count, + read_updated_at_ms, client_key, read_signature, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?) + ON CONFLICT (viewer_login, owner_bch_name, channel_name) + DO UPDATE SET + owner_login = EXCLUDED.owner_login, + unread_count = GREATEST( + 0, + channel_read_state.unread_count - + GREATEST(0, EXCLUDED.read_count - channel_read_state.read_count) + ), + read_count = GREATEST(channel_read_state.read_count, EXCLUDED.read_count), + read_updated_at_ms = EXCLUDED.read_updated_at_ms, + client_key = EXCLUDED.client_key, + read_signature = EXCLUDED.read_signature, + updated_at_ms = EXCLUDED.updated_at_ms + WHERE channel_read_state.read_updated_at_ms < EXCLUDED.read_updated_at_ms + AND EXCLUDED.read_count >= channel_read_state.read_count + RETURNING read_count, unread_count + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setString(2, ownerLogin); + ps.setString(3, ownerBchName); + ps.setString(4, channelName); + ps.setLong(5, Math.max(0L, readCount)); + ps.setLong(6, readUpdatedAtMs); + ps.setString(7, clientKey); + ps.setString(8, readSignature); + ps.setLong(9, nowMs); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return new UpsertResult(true, + Math.max(0L, rs.getLong("read_count")), + Math.max(0L, rs.getLong("unread_count"))); + } + } + } + ReadState current = get(c, viewerLogin, ownerBchName, channelName); + return new UpsertResult(false, + current == null ? 0L : current.readCount(), + current == null ? 0L : current.unreadCount()); + } + + /** + * Increments existing rows for current subscribers of one public channel. + * Missing channel_read_state rows are deliberately NOT created. + * Returns affected viewer logins for post-commit counter pushes. + */ + public List incrementUnreadForSubscribers(Connection c, + String ownerBchName, + String channelName, + int followRelType, + long nowMs) throws SQLException { + String sql = """ + UPDATE channel_read_state crs + SET unread_count = crs.unread_count + 1, + updated_at_ms = ? + WHERE crs.owner_bch_name = ? + AND crs.channel_name = ? + AND LOWER(crs.viewer_login) <> LOWER(crs.owner_login) + AND EXISTS ( + SELECT 1 + FROM channel_names_state cns + JOIN connections_state cs + ON LOWER(cs.login)=LOWER(crs.viewer_login) + AND cs.rel_type=? + AND cs.to_bch_name=cns.owner_bch_name + AND COALESCE(cs.to_block_number,0)=cns.channel_root_block_number + AND cs.to_block_hash=cns.channel_root_block_hash + WHERE cns.owner_bch_name=crs.owner_bch_name + AND cns.slug=crs.channel_name + AND cns.channel_type_code=1 + ) + RETURNING viewer_login + """; + List affected = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setLong(1, nowMs); + ps.setString(2, ownerBchName); + ps.setString(3, channelName); + ps.setInt(4, followRelType); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) affected.add(rs.getString("viewer_login")); + } + } + return affected; + } + + /** Delete the projection row when a channel FOLLOW is explicitly removed. */ + public boolean deleteForUnfollowTarget(Connection c, String viewerLogin, String ownerBchName, + int rootBlockNumber) throws SQLException { + String sql = """ + DELETE FROM channel_read_state crs + USING channel_names_state cns + WHERE LOWER(crs.viewer_login)=LOWER(?) + AND crs.owner_bch_name=? + AND cns.owner_bch_name=crs.owner_bch_name + AND cns.slug=crs.channel_name + AND cns.channel_root_block_number=? + AND cns.channel_type_code=1 + """; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, viewerLogin); + ps.setString(2, ownerBchName); + ps.setInt(3, rootBlockNumber); + return ps.executeUpdate() > 0; + } + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java index 923bff29..560945a4 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/SolanaUserPdaCurrentDAO.java @@ -8,6 +8,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.Base64; /** * Доступ к актуальной PostgreSQL-таблице пользователей, поддерживаемой sync-модулем. @@ -60,6 +61,23 @@ public final class SolanaUserPdaCurrentDAO { } } + /** Resolve a current user by the 32-byte Ed25519 blockchain key used as ANS-104 owner. */ + public SolanaUserPdaCurrentEntry getByBlockchainKey(byte[] owner32) throws SQLException { + if (owner32 == null || owner32.length != 32) return null; + String wanted = Base64.getEncoder().encodeToString(owner32); + String sql = """ + SELECT login, blockchain_name, blockchain_key, paid_limit_bytes + FROM solana_user_pda_current + """; + try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + SolanaUserPdaCurrentEntry e = mapRow(rs); + if (wanted.equals(e.getBlockchainKey())) return e; + } + } + return null; + } + private SolanaUserPdaCurrentEntry mapRow(ResultSet rs) throws SQLException { SolanaUserPdaCurrentEntry entry = new SolanaUserPdaCurrentEntry(); entry.setLogin(rs.getString("login")); diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java index 39256dc4..965de4f9 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/entities/BlockEntry.java @@ -37,6 +37,9 @@ public class BlockEntry { private byte[] blockHash; private byte[] blockSignature; + private byte[] dataItemId; + private boolean arweavePublishPending; + private Long arweavePublishedAtMs; private Integer editedByBlockNumber; @@ -85,6 +88,13 @@ public class BlockEntry { public byte[] getBlockSignature() { return blockSignature; } public void setBlockSignature(byte[] blockSignature) { this.blockSignature = blockSignature; } + public byte[] getDataItemId() { return dataItemId; } + public void setDataItemId(byte[] dataItemId) { this.dataItemId = dataItemId; } + public boolean isArweavePublishPending() { return arweavePublishPending; } + public void setArweavePublishPending(boolean arweavePublishPending) { this.arweavePublishPending = arweavePublishPending; } + public Long getArweavePublishedAtMs() { return arweavePublishedAtMs; } + public void setArweavePublishedAtMs(Long arweavePublishedAtMs) { this.arweavePublishedAtMs = arweavePublishedAtMs; } + public Integer getEditedByBlockNumber() { return editedByBlockNumber; } public void setEditedByBlockNumber(Integer editedByBlockNumber) { this.editedByBlockNumber = editedByBlockNumber; } diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v21.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v21.sql new file mode 100644 index 00000000..d5af4b69 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v21.sql @@ -0,0 +1,25 @@ +-- Channel unread/read projection. +-- The row is created only by a signed SetChannelReadState from the client. +-- New channel publications only increment existing rows. + +CREATE TABLE IF NOT EXISTS channel_read_state ( + viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + owner_login TEXT NOT NULL, + owner_bch_name TEXT NOT NULL, + channel_name TEXT NOT NULL, + read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0), + unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0), + read_updated_at_ms BIGINT NOT NULL, + client_key TEXT NOT NULL, + read_signature TEXT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (viewer_login, owner_bch_name, channel_name) +); + +CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer + ON channel_read_state(viewer_login); + +CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel + ON channel_read_state(owner_bch_name, channel_name); + +UPDATE db_schema_version SET schema_version = 21 WHERE id = 1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql new file mode 100644 index 00000000..ae030254 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v22.sql @@ -0,0 +1,56 @@ +CREATE TABLE IF NOT EXISTS archive_chain_cursor ( + blockchain_name TEXT PRIMARY KEY, + last_archived_source_block_number BIGINT NOT NULL, + last_archived_source_block_hash BYTEA NOT NULL, + last_archive_big_block_number BIGINT NOT NULL, + last_archive_big_block_hash BYTEA NOT NULL, + last_chunk_offset BIGINT NOT NULL, + last_chunk_size BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS archive_publish_job ( + id BIGSERIAL PRIMARY KEY, + big_block_number BIGINT NOT NULL, + status TEXT NOT NULL, + created_at_ms BIGINT NOT NULL, + local_archive_path TEXT, + archive_hash BYTEA, + arweave_tx_id BYTEA, + arweave_confirmations INTEGER, + solana_signature TEXT, + error_text TEXT, + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_archive_publish_job_status + ON archive_publish_job(status); +CREATE INDEX IF NOT EXISTS idx_archive_publish_job_big_block_number + ON archive_publish_job(big_block_number); + +CREATE TABLE IF NOT EXISTS archive_publish_job_chain ( + job_id BIGINT NOT NULL REFERENCES archive_publish_job(id) ON DELETE CASCADE, + blockchain_name TEXT NOT NULL, + from_source_block_number BIGINT NOT NULL, + to_source_block_number BIGINT NOT NULL, + previous_source_block_hash BYTEA NOT NULL, + last_source_block_hash BYTEA NOT NULL, + previous_archive_big_block_number BIGINT, + previous_archive_big_block_hash BYTEA, + previous_chunk_offset BIGINT, + previous_chunk_size BIGINT, + new_chunk_offset BIGINT, + new_chunk_size BIGINT, + PRIMARY KEY (job_id, blockchain_name) +); + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_history + ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_history + ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; + +UPDATE db_schema_version SET schema_version = 22, updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id = 1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql new file mode 100644 index 00000000..664fcffe --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v23.sql @@ -0,0 +1,32 @@ +-- v23: trusted SHINE-ARCHIVE import state + last chunk location index. +-- Solana archive head itself is already copied by the normal Solana users sync (v22). + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending + ON solana_user_pda_current(is_server, archive_imported) + WHERE archive_head_tx_id <> ''; + +CREATE TABLE IF NOT EXISTS archive_blockchain_location ( + blockchain_name TEXT PRIMARY KEY, + publisher_login TEXT NOT NULL, + arweave_tx_id TEXT NOT NULL, + archive_hash BYTEA NOT NULL, + big_block_number BIGINT NOT NULL, + chunk_offset BIGINT NOT NULL, + chunk_size BIGINT NOT NULL, + source_last_block_number BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_archive_blockchain_location_publisher + ON archive_blockchain_location(publisher_login); + +UPDATE db_schema_version +SET schema_version = 23, + updated_at_ms = CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) +WHERE id = 1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v24.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v24.sql new file mode 100644 index 00000000..cba0781a --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v24.sql @@ -0,0 +1,56 @@ +-- Frame v1 intentionally has no backward compatibility with the old signed block format. +-- Refuse to migrate a populated blockchain database: test/dev data must be reset first. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM blocks LIMIT 1) + OR EXISTS (SELECT 1 FROM blockchain_state WHERE last_block_number >= 0 LIMIT 1) THEN + RAISE EXCEPTION 'migration_v24 requires an empty blockchain database; reset test/dev blockchain data before upgrading'; + END IF; +END $$; + +-- Старый SHINE-ARCHIVE v1 больше не используется. +DROP TABLE IF EXISTS archive_publish_job_chain CASCADE; +DROP TABLE IF EXISTS archive_publish_job CASCADE; +DROP TABLE IF EXISTS archive_chain_cursor CASCADE; +DROP TABLE IF EXISTS archive_blockchain_location CASCADE; + +-- SHiNE Frame v1 / ANS-104 DataItem storage. No legacy .bch files are required. +ALTER TABLE blocks + ADD COLUMN IF NOT EXISTS data_item_id BYTEA; +ALTER TABLE blocks + ADD COLUMN IF NOT EXISTS arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE blocks + ADD COLUMN IF NOT EXISTS arweave_published_at_ms BIGINT; +ALTER TABLE blocks + ADD COLUMN IF NOT EXISTS arweave_root_tx_id TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id + ON blocks(data_item_id) + WHERE data_item_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending + ON blocks(block_number) + WHERE arweave_publish_pending = TRUE; + +CREATE TABLE IF NOT EXISTS arweave_block_sync_state ( + id SMALLINT PRIMARY KEY CHECK (id = 1), + last_block_height BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL DEFAULT 0 +); +INSERT INTO arweave_block_sync_state(id,last_block_height,updated_at_ms) +VALUES(1,0,0) ON CONFLICT(id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS arweave_block_import_queue ( + data_item_id BYTEA PRIMARY KEY, + root_tx_id TEXT NOT NULL, + block_height BIGINT NOT NULL, + raw_data_item BYTEA NOT NULL, + status TEXT NOT NULL, + last_error TEXT NOT NULL DEFAULT '', + first_seen_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending + ON arweave_block_import_queue(block_height,first_seen_at_ms) + WHERE status='PENDING'; + +UPDATE db_schema_version SET schema_version=24, updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) WHERE id=1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql new file mode 100644 index 00000000..8c0484d9 --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v25.sql @@ -0,0 +1,11 @@ +-- Turbo/direct-Arweave transport no longer stores root transaction IDs for user DataItems. +ALTER TABLE blocks + DROP COLUMN IF EXISTS arweave_root_tx_id; + +ALTER TABLE arweave_block_import_queue + DROP COLUMN IF EXISTS root_tx_id; + +UPDATE db_schema_version +SET schema_version=25, + updated_at_ms=CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT) +WHERE id=1; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 08330371..f13a37a4 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version ( ); INSERT INTO db_schema_version (id, schema_version, updated_at_ms) - VALUES (1, 20, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) + VALUES (1, 24, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)) ON CONFLICT (id) DO UPDATE SET schema_version = EXCLUDED.schema_version, updated_at_ms = EXCLUDED.updated_at_ms; @@ -291,12 +291,6 @@ AFTER TRUNCATE ON solana_user_pda_current FOR EACH STATEMENT EXECUTE FUNCTION trg_refresh_user_access_servers_from_user_pda_truncate(); -DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current; -CREATE TRIGGER trg_user_stats_state_ai -AFTER INSERT ON solana_user_pda_current -FOR EACH ROW -EXECUTE FUNCTION shine_user_stats_state_ai(); - CREATE TABLE IF NOT EXISTS solana_user_pda_history ( id BIGSERIAL PRIMARY KEY, tx_signature TEXT NOT NULL, @@ -425,6 +419,26 @@ CREATE INDEX IF NOT EXISTS idx_user_settings_login CREATE INDEX IF NOT EXISTS idx_user_settings_type_key ON user_settings(setting_type, setting_key); +CREATE TABLE IF NOT EXISTS channel_read_state ( + viewer_login TEXT NOT NULL REFERENCES solana_user_pda_current(login), + owner_login TEXT NOT NULL, + owner_bch_name TEXT NOT NULL, + channel_name TEXT NOT NULL, + read_count BIGINT NOT NULL DEFAULT 0 CHECK (read_count >= 0), + unread_count BIGINT NOT NULL DEFAULT 0 CHECK (unread_count >= 0), + read_updated_at_ms BIGINT NOT NULL, + client_key TEXT NOT NULL, + read_signature TEXT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (viewer_login, owner_bch_name, channel_name) +); + +CREATE INDEX IF NOT EXISTS idx_channel_read_state_viewer + ON channel_read_state(viewer_login); + +CREATE INDEX IF NOT EXISTS idx_channel_read_state_channel + ON channel_read_state(owner_bch_name, channel_name); + CREATE TABLE IF NOT EXISTS ip_geo_cache ( ip TEXT PRIMARY KEY, geo TEXT, @@ -483,6 +497,9 @@ CREATE TABLE IF NOT EXISTS blocks ( to_block_hash BYTEA, block_hash BYTEA NOT NULL, block_signature BYTEA NOT NULL, + data_item_id BYTEA NOT NULL, + arweave_publish_pending BOOLEAN NOT NULL DEFAULT FALSE, + arweave_published_at_ms BIGINT, edited_by_block_number INTEGER CHECK (edited_by_block_number IS NULL OR edited_by_block_number >= 0), line_code INTEGER CHECK (line_code IS NULL OR line_code >= 0), prev_line_number INTEGER CHECK (prev_line_number IS NULL OR prev_line_number >= 0), @@ -494,12 +511,40 @@ CREATE TABLE IF NOT EXISTS blocks ( CREATE INDEX IF NOT EXISTS idx_blocks_by_chain_number ON blocks (bch_name, block_number); +CREATE UNIQUE INDEX IF NOT EXISTS uq_blocks_data_item_id + ON blocks(data_item_id); + +CREATE INDEX IF NOT EXISTS idx_blocks_arweave_publish_pending + ON blocks(block_number) + WHERE arweave_publish_pending = TRUE; + CREATE INDEX IF NOT EXISTS idx_blocks_to_target ON blocks (to_login, to_bch_name, to_block_number); CREATE INDEX IF NOT EXISTS idx_blocks_by_line ON blocks (bch_name, line_code, this_line_number); +CREATE TABLE IF NOT EXISTS arweave_block_sync_state ( + id SMALLINT PRIMARY KEY CHECK (id = 1), + last_block_height BIGINT NOT NULL DEFAULT 0, + updated_at_ms BIGINT NOT NULL DEFAULT 0 +); +INSERT INTO arweave_block_sync_state(id, last_block_height, updated_at_ms) +VALUES (1, 0, 0) ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS arweave_block_import_queue ( + data_item_id BYTEA PRIMARY KEY, + block_height BIGINT NOT NULL, + raw_data_item BYTEA NOT NULL, + status TEXT NOT NULL, + last_error TEXT NOT NULL DEFAULT '', + first_seen_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_arweave_block_import_queue_pending + ON arweave_block_import_queue(block_height,first_seen_at_ms) + WHERE status='PENDING'; + CREATE TABLE IF NOT EXISTS connections_state ( login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login), rel_type INTEGER NOT NULL, @@ -1640,6 +1685,12 @@ AFTER INSERT ON blocks FOR EACH ROW EXECUTE FUNCTION shine_blocks_edit_apply_ai(); +DROP TRIGGER IF EXISTS trg_user_stats_state_ai ON solana_user_pda_current; +CREATE TRIGGER trg_user_stats_state_ai +AFTER INSERT ON solana_user_pda_current +FOR EACH ROW +EXECUTE FUNCTION shine_user_stats_state_ai(); + DROP TRIGGER IF EXISTS trg_channel_names_state_stats_ai ON channel_names_state; CREATE TRIGGER trg_channel_names_state_stats_ai AFTER INSERT ON channel_names_state @@ -2008,9 +2059,27 @@ CREATE TABLE IF NOT EXISTS user_notification_seen_state ( PRIMARY KEY (owner_login, category) ); +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_history + ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE solana_user_pda_history + ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''; + + +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_imported BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE solana_user_pda_current + ADD COLUMN IF NOT EXISTS archive_last_imported_tx_id TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_solana_user_pda_current_archive_pending + ON solana_user_pda_current(is_server, archive_imported) + WHERE archive_head_tx_id <> ''; + INSERT INTO db_schema_version(id,schema_version,updated_at_ms) -VALUES(1,20,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) +VALUES(1,25,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT)) ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms; COMMIT; - diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 18861805..10406a25 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -46,11 +46,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_GetBlockchainBlock_Request; import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_GetUser_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestGetFreeAvatarQuota_Handler; -import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_TestUploadFreeAvatar_Handler; import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_GetUser_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestGetFreeAvatarQuota_Request; -import server.logic.ws_protocol.JSON.handlers.tempToTest.entyties.Net_TestUploadFreeAvatar_Request; // --- NEW: SearchUsers --- import server.logic.ws_protocol.JSON.handlers.tempToTest.Net_SearchUsers_Handler; @@ -75,18 +71,24 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler; +import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageLikes_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler; +import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler; +import server.logic.ws_protocol.JSON.handlers.channels.Net_SetChannelReadState_Handler; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request; import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler; import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler; @@ -159,8 +161,6 @@ public final class JsonHandlerRegistry { private static final Map HANDLERS = Map.ofEntries( Map.entry("GetUser", new Net_GetUser_Handler()), Map.entry("SearchUsers", new Net_SearchUsers_Handler()), - Map.entry("TestGetFreeAvatarQuota", new Net_TestGetFreeAvatarQuota_Handler()), - Map.entry("TestUploadFreeAvatar", new Net_TestUploadFreeAvatar_Handler()), // --- auth --- Map.entry("ResolveLoginForAuth", new Net_ResolveLoginForAuth_Handler()), @@ -208,9 +208,12 @@ public final class JsonHandlerRegistry { Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()), Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()), Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()), + Map.entry("GetMessageLikes", new Net_GetMessageLikes_Handler()), Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()), Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()), Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()), + Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()), + Map.entry("SetChannelReadState", new Net_SetChannelReadState_Handler()), Map.entry("ListContacts", new Net_ListContacts_Handler()), Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()), Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()), @@ -250,8 +253,6 @@ public final class JsonHandlerRegistry { private static final Map> REQUEST_TYPES = Map.ofEntries( Map.entry("GetUser", Net_GetUser_Request.class), Map.entry("SearchUsers", Net_SearchUsers_Request.class), - Map.entry("TestGetFreeAvatarQuota", Net_TestGetFreeAvatarQuota_Request.class), - Map.entry("TestUploadFreeAvatar", Net_TestUploadFreeAvatar_Request.class), // --- auth --- Map.entry("ResolveLoginForAuth", Net_ResolveLoginForAuth_Request.class), @@ -299,9 +300,12 @@ public final class JsonHandlerRegistry { Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class), Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class), Map.entry("GetMessageThread", Net_GetMessageThread_Request.class), + Map.entry("GetMessageLikes", Net_GetMessageLikes_Request.class), Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class), Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class), Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class), + Map.entry("GetUserCounters", Net_GetUserCounters_Request.class), + Map.entry("SetChannelReadState", Net_SetChannelReadState_Request.class), Map.entry("ListContacts", Net_ListContacts_Request.class), Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class), Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java index 68a3fed9..58ea1aa6 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/SolanaUserPdaImportService.java @@ -28,6 +28,7 @@ public final class SolanaUserPdaImportService { private static final HttpClient HTTP = HttpClient.newHttpClient(); private static final String MAGIC = "SHiNE"; private static final int MAX_EFFECTIVE_ACCESS_SERVERS = 1; + private static final int ARCHIVE_HEAD_PAYLOAD_BYTES = 64; private SolanaUserPdaImportService() {} @@ -266,6 +267,8 @@ public final class SolanaUserPdaImportService { } } else if (blockType == 70) { c += 1; + } else if (blockType == 100) { + c += ARCHIVE_HEAD_PAYLOAD_BYTES; } else { return null; } @@ -374,6 +377,8 @@ public final class SolanaUserPdaImportService { } } else if (blockType == 70) { c += 1; + } else if (blockType == 100) { + c += ARCHIVE_HEAD_PAYLOAD_BYTES; } else { return null; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index da343d11..77663501 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -26,6 +26,7 @@ import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_R import server.logic.ws_protocol.JSON.handlers.blockchain.entyties.Net_AddBlock_Response; import server.logic.ws_protocol.JSON.handlers.channels.ChannelMetaTextParser; import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper; +import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport; import server.logic.ws_protocol.WireCodes; import server.sync.AddBlockSyncService; import shine.db.channels.ChannelNameRules; @@ -42,6 +43,7 @@ import shine.db.entities.UserParamEntry; import utils.blockchain.BlockchainNameUtil; import java.util.Arrays; +import java.util.List; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; @@ -100,7 +102,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { blockchainName, req.getBlockNumber(), // старое поле, пока оставляем req.getPrevBlockHash(), // старое поле, пока оставляем - req.getBlockBytesB64() + req.getBlockBytesB64(), + true, + true ); // ✅ УСПЕХ: как раньше @@ -160,6 +164,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { case "limit_exceeded" -> "Превышен лимит размера блокчейна"; case "limit_check_failed" -> "Ошибка проверки лимита размера"; case "bad_block_format" -> "Некорректный формат блока"; + case "bad_app_tag" -> "Некорректный тестовый тег ANS-104"; + case "bad_channel_tag" -> "Некорректный тег канала ANS-104"; case "bad_block_body" -> "Некорректное тело блока"; case "bad_channel_name" -> "Некорректное название канала"; case "bad_block_number" -> "Некорректный номер блока"; @@ -186,7 +192,9 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { String blockchainName, int globalNumberFromReq, String prevGlobalHashHexFromReq, - String blockBytesB64 + String blockBytesB64, + boolean replicateAfterWrite, + boolean publishToArweave ) { if (blockchainName == null || blockchainName.isBlank()) { log.warn("AddBlock: пустой blockchainName (reqGlobalNumber={})", globalNumberFromReq); @@ -268,6 +276,10 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_block_format", serverLastNum, serverLastHashHex); } + if (!block.getDataItem().hasTag("App", "test5590")) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_app_tag", serverLastNum, serverLastHashHex); + } + // body.check() try { block.body.check(); @@ -397,6 +409,22 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { channelMetaUpdateEntry.setMetaUpdatedAtMs(block.timestamp * 1000L); } + // Channel DataItems are indexed by a signed canonical channel slug tag: c_test5590=. + try { + String expectedChannelSlug = expectedChannelSlug(blockchainName, block, channelNameStateEntry); + String actualChannelSlug = block.getDataItem().tagValue("c_test5590"); + if (expectedChannelSlug != null) { + if (!expectedChannelSlug.equals(actualChannelSlug)) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex); + } + } else if (actualChannelSlug != null) { + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex); + } + } catch (Exception e) { + log.warn("AddBlock: cannot validate channel tag blockchainName={} block={}", blockchainName, block.blockNumber, e); + return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_channel_tag", serverLastNum, serverLastHashHex); + } + if ((block.type & 0xFFFF) == 1 && (block.subType & 0xFFFF) == (MsgSubType.TEXT_EDIT_POST & 0xFFFF)) { try { @@ -454,7 +482,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, "bad_blockchain_key_len", serverLastNum, serverLastHashHex); } - // 6) подпись по hash32(preimage) + // 6) ANS-104 Ed25519 подпись и owner boolean sigOk; try { sigOk = BchCryptoVerifier.verifyBlock(block, pubKey32); @@ -512,7 +540,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } } - // 8) сформировать запись и записать (DB + state + файл) + // 8) сформировать запись и атомарно записать в PostgreSQL вместе с новым state try { BlockEntry be = new BlockEntry(); be.setLogin(login); @@ -525,6 +553,8 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { be.setBlockBytes(block.toBytes()); be.setBlockHash(block.getHash32()); be.setBlockSignature(block.getSignature64()); + be.setDataItemId(block.getDataItemId32()); + be.setArweavePublishPending(publishToArweave); // line columns (optional) be.setLineCode(lineCode); @@ -586,9 +616,13 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } UserNotificationEntry notificationEntry = buildNotificationEntry(block, be); - dbWriter.appendBlockAndState( + List counterChangedLogins = dbWriter.appendBlockAndState( blockchainName, block, st, be, upsertedParam, channelNameStateEntry, channelMetaUpdateEntry, notificationEntry); + for (String changedLogin : counterChangedLogins) { + UserCountersSupport.pushChanged(changedLogin); + } + if (chat200CreateSeed != null) { upsertChat200StateFromCreate(chat200CreateSeed); } @@ -607,15 +641,80 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { log.info("✅ AddBlock ok: login={}, blockchainName={}, blockNumber={}, newHash={}", login, blockchainName, block.blockNumber, newHashHex); - addBlockSyncService.replicateAsync(blockchainName, block.blockNumber); + if (replicateAfterWrite) { + addBlockSyncService.replicateAsync(blockchainName, block.blockNumber); + } return new AddBlockResult(WireCodes.Status.OK, null, block.blockNumber, newHashHex); } + /** + * Архивный импорт использует ту же проверку и запись, что обычный AddBlock, + * но не запускает повторную межсерверную репликацию уже импортированного блока. + */ + public ArweaveImportResult addBlockFromArweave(String blockchainName, byte[] rawBlockBytes) { + if (BlockchainResyncGuard.isBlockedForExternalAddBlock(blockchainName)) { + return new ArweaveImportResult(false, "chain_resync_in_progress", -1, ""); + } + if (rawBlockBytes == null || rawBlockBytes.length == 0) { + return new ArweaveImportResult(false, "empty_block", -1, ""); + } + try { + BchBlockEntry probe = new BchBlockEntry(rawBlockBytes); + if (blocksDAO.existsByDataItemId(probe.getDataItemId32())) { + return new ArweaveImportResult(true, "already_present", probe.blockNumber, toHex(probe.getHash32())); + } + } catch (Exception ignored) { + // Parsed again below to return the canonical bad_block_format result. + } + final BchBlockEntry parsed; + try { + parsed = new BchBlockEntry(rawBlockBytes); + } catch (Exception e) { + return new ArweaveImportResult(false, "bad_block_format", -1, ""); + } + ReentrantLock lock = BlockchainLocks.lockFor(blockchainName); + lock.lock(); + try { + AddBlockResult r = addBlock( + blockchainName, + parsed.blockNumber, + toHex(parsed.prevHash32), + Base64Ws.encode(rawBlockBytes), + false, + false + ); + return new ArweaveImportResult(r.isOk(), r.reasonCode, r.serverLastBlockNumber, r.serverLastBlockHashHex); + } finally { + lock.unlock(); + } + } + + public record ArweaveImportResult(boolean ok, String reasonCode, int serverLastBlockNumber, String serverLastBlockHashHex) {} + /* ===================================================================== */ /* ====================== Helpers ====================================== */ /* ===================================================================== */ + private String expectedChannelSlug(String blockchainName, BchBlockEntry block, ChannelNameStateEntry justCreated) throws Exception { + if (block.body instanceof CreateChannelBody) { + return justCreated == null ? null : justCreated.getSlug(); + } + int type = block.type & 0xffff; + int sub = block.subType & 0xffff; + boolean channelBlock = type == 1 && ( + sub == (MsgSubType.TEXT_POST & 0xffff) + || sub == (MsgSubType.TEXT_REPOST & 0xffff) + || sub == (MsgSubType.TEXT_CHANNEL_META & 0xffff) + || sub == (MsgSubType.TEXT_ENTRYPOINT & 0xffff) + || sub == (MsgSubType.TEXT_EXERCISE & 0xffff) + || sub == (MsgSubType.TEXT_SERVICE & 0xffff) + || sub == (MsgSubType.TEXT_COURSE & 0xffff) + || sub == (MsgSubType.TEXT_EDIT_POST & 0xffff)); + if (!channelBlock || !(block.body instanceof BodyHasLine line) || line.lineCode() <= 0) return null; + return channelNameStateDAO.getSlugByOwnerAndRoot(blockchainName, line.lineCode()); + } + private String validateConnectionVotingRights(String actorLogin, int subType, String targetLogin) { if (subType != (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF) && subType != (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)) return null; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java index 72a730d1..90e5a19a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler_utils/BlockchainWriter.java @@ -4,6 +4,8 @@ import blockchain.BchBlockEntry; import shine.db.dao.BlockchainStateDAO; import shine.db.dao.BlocksDAO; import shine.db.dao.ChannelNameStateDAO; +import shine.db.dao.ChannelReadStateDAO; +import shine.db.MsgSubType; import shine.db.dao.UserParamsDAO; import shine.db.dao.UserNotificationsStateDAO; import shine.db.entities.BlockchainStateEntry; @@ -11,40 +13,24 @@ import shine.db.entities.BlockEntry; import shine.db.entities.ChannelNameStateEntry; import shine.db.entities.UserParamEntry; import shine.db.entities.UserNotificationEntry; -import utils.files.FileStoreUtil; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; import java.sql.Connection; import java.sql.SQLException; -import java.util.HexFormat; +import java.util.ArrayList; +import java.util.List; -/** - * BlockchainWriter — запись блока в БД и формирование файловой версии цепочки. - * - * Текущая схема: - * 1) собираем .tmp_bch как готовый кандидат на замену основного файла; - * 2) пишем маленький sidecar .write_check с blockNumber/blockHash; - * 3) создаём пустой marker .write_pending; - * 4) выполняем одну SQL-транзакцию; - * 5) после commit атомарно заменяем основной .bch из tmp; - * 6) убираем временные файлы. - * - * Recovery на старте смотрит на marker/check/tmp и добивает незавершённую запись: - * - если marker отсутствует, а tmp/check остались, это мусор; - * - если marker есть, recovery проверяет БД и либо завершает swap, либо очищает мусор. +/** Stores the complete ANS-104 DataItem and all derived SHiNE state atomically in PostgreSQL. + * Local .bch/tmp/marker files are intentionally not used. */ public final class BlockchainWriter { - private static final HexFormat HEX = HexFormat.of(); private final BlocksDAO blocksDAO; private final BlockchainStateDAO stateDAO; private final ChannelNameStateDAO channelNameStateDAO; private final UserParamsDAO userParamsDAO; private final UserNotificationsStateDAO userNotificationsStateDAO; - private final FileStoreUtil fs = FileStoreUtil.getInstance(); + private final ChannelReadStateDAO channelReadStateDAO = ChannelReadStateDAO.getInstance(); public BlockchainWriter(BlocksDAO blocksDAO, BlockchainStateDAO stateDAO, @@ -58,7 +44,11 @@ public final class BlockchainWriter { this.userNotificationsStateDAO = userNotificationsStateDAO; } - public void appendBlockAndState(String blockchainName, + /** + * Stores the complete ANS-104 DataItem and all SQL projections in one DB transaction. + * There is intentionally no .bch/tmp/marker file layer anymore. + */ + public List appendBlockAndState(String blockchainName, BchBlockEntry block, BlockchainStateEntry st, BlockEntry be, @@ -68,121 +58,64 @@ public final class BlockchainWriter { UserNotificationEntry notificationEntry) throws SQLException { long nowMs = System.currentTimeMillis(); - byte[] blockBytes = block.toBytes(); - byte[] candidateBytes = buildCandidateBlockchainBytes(blockchainName, blockBytes); - String blockHashHex = HEX.formatHex(block.getHash32()); - - prepareWriteArtifacts(blockchainName, block.blockNumber, blockHashHex, candidateBytes); - - boolean committed = false; + byte[] rawDataItem = block.toBytes(); + List counterChangedLogins = new ArrayList<>(); try (Connection c = shine.db.DbController.getInstance().getConnection()) { c.setAutoCommit(false); try { - // 1) insert block blocksDAO.insert(c, be); - // 2) update state st.setLastBlockNumber(block.blockNumber); st.setLastBlockHash(block.getHash32()); - st.setFileSizeBytes(st.getFileSizeBytes() + blockBytes.length); + st.setFileSizeBytes(st.getFileSizeBytes() + rawDataItem.length); st.setUpdatedAtMs(nowMs); - stateDAO.upsert(c, st); - // 2.1) Если блок — USER_PARAM, синхронизируем снимок в users_params в той же транзакции. - if (userParamEntry != null) { - userParamsDAO.upsertIfNewer(c, userParamEntry); + if (userParamEntry != null) userParamsDAO.upsertIfNewer(c, userParamEntry); + if (channelNameStateEntry != null) channelNameStateDAO.insert(c, channelNameStateEntry); + if (channelMetaUpdateEntry != null) channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry); + if (notificationEntry != null) userNotificationsStateDAO.upsert(c, notificationEntry); + + int msgType = block.type & 0xFFFF; + int msgSubType = block.subType & 0xFFFF; + Integer lineCode = be.getLineCode(); + if (isPublicChannelPublication(msgType, msgSubType) && lineCode != null && lineCode > 0) { + String channelName = channelReadStateDAO.resolvePublicChannelNameByRoot(c, blockchainName, lineCode); + if (channelName != null && !channelName.isBlank()) { + counterChangedLogins.addAll(channelReadStateDAO.incrementUnreadForSubscribers( + c, blockchainName, channelName, MsgSubType.CONNECTION_FOLLOW, nowMs)); + } } - if (channelNameStateEntry != null) { - channelNameStateDAO.insert(c, channelNameStateEntry); - } - - if (channelMetaUpdateEntry != null) { - channelNameStateDAO.updateMeta(c, channelMetaUpdateEntry); - } - - // Notification projection is part of the same SQL transaction as the block. - // If notification indexing fails, the block/state write is rolled back as well. - if (notificationEntry != null) { - userNotificationsStateDAO.upsert(c, notificationEntry); + if (msgType == 3 + && msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF) + && be.getToBchName() != null && !be.getToBchName().isBlank() + && be.getToBlockNumber() != null && be.getToBlockNumber() > 0) { + if (channelReadStateDAO.deleteForUnfollowTarget( + c, be.getLogin(), be.getToBchName(), be.getToBlockNumber())) { + counterChangedLogins.add(be.getLogin()); + } } c.commit(); - committed = true; } catch (Exception e) { - try { - c.rollback(); - } catch (Exception ignored) { - } - - if (!committed) { - cleanupWriteArtifactsBestEffort(blockchainName); - } - - if (e instanceof SQLException se) { - throw se; - } + try { c.rollback(); } catch (Exception ignored) {} + if (e instanceof SQLException se) throw se; throw new SQLException("appendBlockAndState failed", e); } finally { - try { - c.setAutoCommit(true); - } catch (Exception ignored) { - } + try { c.setAutoCommit(true); } catch (Exception ignored) {} } } - - // 3) После commit — атомарно подменяем основной файл. - try { - fs.atomicReplaceBlockchainFile(blockchainName); - } catch (RuntimeException e) { - // marker/check/tmp оставляем для startup-recovery - throw e; - } - - // 4) После успешной подмены — чистим временные артефакты. - cleanupWriteArtifactsBestEffort(blockchainName); + return counterChangedLogins; } - private byte[] buildCandidateBlockchainBytes(String blockchainName, byte[] blockBytes) { - byte[] base; - try { - if (Files.exists(fs.resolveBlockchainPath(blockchainName))) { - base = fs.readBlockchain(blockchainName); - } else { - base = new byte[0]; - } - } catch (RuntimeException e) { - throw e; - } - - byte[] out = new byte[base.length + blockBytes.length]; - System.arraycopy(base, 0, out, 0, base.length); - System.arraycopy(blockBytes, 0, out, base.length, blockBytes.length); - return out; - } - - private void prepareWriteArtifacts(String blockchainName, int blockNumber, String blockHashHex, byte[] candidateBytes) { - fs.writeBlockchainTmp(blockchainName, candidateBytes); - fs.writeBlockchainWriteCheck(blockchainName, blockNumber, blockHashHex); - fs.writeBlockchainWritePendingMarker(blockchainName); - } - - private void cleanupWriteArtifactsBestEffort(String blockchainName) { - deleteQuietly(() -> fs.deleteBlockchainWritePendingMarkerIfExists(blockchainName)); - deleteQuietly(() -> fs.deleteBlockchainWriteCheckIfExists(blockchainName)); - deleteQuietly(() -> fs.deleteBlockchainTmpFileIfExists(blockchainName)); - } - - private static void deleteQuietly(DeleteAction action) { - try { - action.run(); - } catch (RuntimeException ignored) { - } - } - - @FunctionalInterface - private interface DeleteAction { - void run(); + private static boolean isPublicChannelPublication(int msgType, int msgSubType) { + if (msgType != 1) return false; + return msgSubType == (MsgSubType.TEXT_POST & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_REPOST & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_ENTRYPOINT & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF) + || msgSubType == (MsgSubType.TEXT_COURSE & 0xFFFF); } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java index b0cfce4d..762d8fba 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/ChannelsReadSupport.java @@ -144,38 +144,12 @@ final class ChannelsReadSupport { } } - static String userSettingsChannelKey(String ownerBch, String channelName) { - String bch = ownerBch == null ? "" : ownerBch.trim(); - String name = channelName == null ? "" : channelName.trim(); - return bch + "/" + name; - } - static int countUnreadMessages(Connection c, String viewerLogin, String ownerBch, String channelName, int messagesCount) throws SQLException { - if (viewerLogin == null || viewerLogin.isBlank()) return 0; - String key = userSettingsChannelKey(ownerBch, channelName); - String sql = """ - SELECT value_num - FROM user_settings - WHERE LOWER(login) = LOWER(?) - AND setting_type = ? - AND setting_key = ? - LIMIT 1 - """; - long lastSeen = messagesCount; - try (PreparedStatement ps = c.prepareStatement(sql)) { - ps.setString(1, viewerLogin); - ps.setInt(2, 1); - ps.setString(3, key); - try (ResultSet rs = ps.executeQuery()) { - if (rs.next()) { - long value = rs.getLong("value_num"); - if (!rs.wasNull()) lastSeen = value; - } - } - } - if (lastSeen < 0) lastSeen = 0; - if (lastSeen > messagesCount) return 0; - return Math.max(0, messagesCount - (int) lastSeen); + if (viewerLogin == null || viewerLogin.isBlank() || ownerBch == null || ownerBch.isBlank() + || channelName == null || channelName.isBlank()) return 0; + long unread = shine.db.dao.ChannelReadStateDAO.getInstance() + .getUnreadCount(c, viewerLogin, ownerBch, channelName); + return unread > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0L, unread); } static PostBlock loadLastPost(Connection c, String ownerBch, int lineCode) throws SQLException { @@ -262,12 +236,13 @@ final class ChannelsReadSupport { static List channelPosts(Connection c, String ownerBch, int lineCode, int limit, boolean asc) throws SQLException { String order = asc ? "ASC" : "DESC"; + boolean bounded = limit > 0; String sql = """ SELECT login,bch_name,block_number,block_hash,block_bytes,to_bch_name,to_block_number,to_block_hash,msg_sub_type,this_line_number FROM blocks WHERE bch_name=? AND msg_type=? AND msg_sub_type IN (?, ?, ?, ?, ?, ?) AND line_code=? ORDER BY block_number - """ + order + " LIMIT ?"; + """ + order + (bounded ? " LIMIT ?" : ""); try (PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, ownerBch); ps.setInt(2, MSG_TYPE_TEXT); @@ -278,7 +253,7 @@ final class ChannelsReadSupport { ps.setInt(7, MsgSubType.TEXT_SERVICE); ps.setInt(8, MsgSubType.TEXT_COURSE); ps.setInt(9, lineCode); - ps.setInt(10, limit); + if (bounded) ps.setInt(10, limit); try (ResultSet rs = ps.executeQuery()) { List out = new ArrayList<>(); while (rs.next()) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java index bb95d644..f3c6e71c 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetChannelMessages_Handler.java @@ -34,9 +34,11 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля channel"); } - int limit = req.getLimit() == null ? 30 : req.getLimit(); - if (limit <= 0 || limit > 1000) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit"); + // limit is optional. null/0 means "return the whole channel". + // Positive values are kept for backwards-compatible callers that still want a bounded response. + int limit = req.getLimit() == null ? 0 : req.getLimit(); + if (limit < 0) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_limit", "Некорректный limit"); } boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort()); @@ -113,7 +115,7 @@ public class Net_GetChannelMessages_Handler implements JsonMessageHandler { if (!asc) { java.util.Collections.reverse(posts); } - if (posts.size() > limit) { + if (limit > 0 && posts.size() > limit) { posts = new ArrayList<>(posts.subList(0, limit)); } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java new file mode 100644 index 00000000..3cd3fa8d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetMessageLikes_Handler.java @@ -0,0 +1,101 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageLikes_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; + +public class Net_GetMessageLikes_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_GetMessageLikes_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetMessageLikes_Request req = (Net_GetMessageLikes_Request) baseRequest; + Net_GetMessageLikes_Request.MessageSelector message = req.getMessage(); + if (message == null || message.getBlockchainName() == null || message.getBlockchainName().isBlank() + || message.getBlockNumber() == null || message.getBlockNumber() < 0 + || message.getBlockHash() == null || message.getBlockHash().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля message"); + } + + final byte[] blockHash; + try { + blockHash = ChannelsReadSupport.hexToBytes(message.getBlockHash()); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash"); + } + if (blockHash == null || blockHash.length == 0) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_hash", "Некорректный blockHash"); + } + + try (Connection c = DbController.getInstance().getConnection()) { + String sql = """ + SELECT rs.from_login, + COALESCE(ups.first_name, '') AS first_name, + COALESCE(ups.last_name, '') AS last_name, + COALESCE(ups.ava_ar, '') AS avatar_ar, + COALESCE(ups.account_role, 'unknown') AS account_role, + COALESCE(ups.shine_status, 'unknown') AS shine_status + FROM reactions_state rs + LEFT JOIN user_profile_state ups ON LOWER(ups.login) = LOWER(rs.from_login) + WHERE rs.reaction_type = 1 + AND rs.last_sub_type = 1 + AND rs.to_bch_name = ? + AND rs.to_block_number = ? + AND rs.to_block_hash = ? + ORDER BY LOWER(rs.from_login) + """; + + List shining = new ArrayList<>(); + List official = new ArrayList<>(); + List others = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, message.getBlockchainName().trim()); + ps.setInt(2, message.getBlockNumber()); + ps.setBytes(3, blockHash); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + Net_GetMessageLikes_Response.UserItem user = new Net_GetMessageLikes_Response.UserItem(); + user.setLogin(rs.getString("from_login")); + user.setFirstName(rs.getString("first_name")); + user.setLastName(rs.getString("last_name")); + user.setAvatarAr(rs.getString("avatar_ar")); + + boolean isOfficial = "primary".equalsIgnoreCase(rs.getString("account_role")); + boolean isShining = "shining".equalsIgnoreCase(rs.getString("shine_status")); + if (isOfficial && isShining) shining.add(user); + else if (isOfficial) official.add(user); + else others.add(user); + } + + Net_GetMessageLikes_Response resp = new Net_GetMessageLikes_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setShining(shining); + resp.setOfficial(official); + resp.setOthers(others); + resp.setTotal(shining.size() + official.size() + others.size()); + resp.setTruncated(false); + return resp; + } + } + } catch (Exception e) { + log.error("GetMessageLikes failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера"); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetUserCounters_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetUserCounters_Handler.java new file mode 100644 index 00000000..8583b316 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetUserCounters_Handler.java @@ -0,0 +1,46 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; + +import java.sql.Connection; + +public class Net_GetUserCounters_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_GetUserCounters_Handler.class); + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetUserCounters_Request req = (Net_GetUserCounters_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация"); + } + String login = ctx.getLogin().trim(); + try (Connection c = DbController.getInstance().getConnection()) { + UserCountersSupport.Snapshot s = UserCountersSupport.calculate(c, login); + Net_GetUserCounters_Response r = new Net_GetUserCounters_Response(); + r.setOp(req.getOp()); + r.setRequestId(req.getRequestId()); + r.setStatus(WireCodes.Status.OK); + r.setLogin(login); + r.setDmUnreadCount(s.dmUnreadCount()); + r.setChannelsUnreadCount(s.channelsUnreadCount()); + r.setNotificationsUnreadCount(s.notificationsUnreadCount()); + r.setNotificationRepliesUnreadCount(s.repliesUnreadCount()); + r.setNotificationConnectionsUnreadCount(s.connectionsUnreadCount()); + r.setNotificationEventsUnreadCount(s.eventsUnreadCount()); + return r; + } catch (Exception e) { + log.error("GetUserCounters failed for {}", login, e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Не удалось получить счётчики"); + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java index faebbb6c..797295a8 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_ListSubscriptionsFeed_Handler.java @@ -12,6 +12,7 @@ import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.MsgSubType; import shine.db.DbController; +import shine.db.dao.ChannelReadStateDAO; import java.sql.Connection; import java.sql.PreparedStatement; @@ -81,11 +82,15 @@ public class Net_ListSubscriptionsFeed_Handler implements JsonMessageHandler { channelRef.setChannelRoot(rootRef); row.setChannel(channelRef); - row.setMessagesCount(ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber)); + int messagesCount = ChannelsReadSupport.countPosts(c, key.ownerBch, key.rootNumber); + row.setMessagesCount(messagesCount); boolean ownChannel = key.ownerLogin != null && key.ownerLogin.equalsIgnoreCase(viewerLogin); - row.setUnreadCount(ownChannel - ? 0 - : ChannelsReadSupport.countUnreadMessages(c, viewerLogin, key.ownerBch, meta.channelName, row.getMessagesCount())); + ChannelReadStateDAO.ReadState readState = ownChannel || meta.channelName == null + ? null + : ChannelReadStateDAO.getInstance().get(c, viewerLogin, key.ownerBch, meta.channelName); + row.setReadStateInitialized(readState != null); + row.setReadCount(readState == null ? 0L : Math.min(messagesCount, Math.max(0L, readState.readCount()))); + row.setUnreadCount(readState == null ? 0 : (int) Math.min(Integer.MAX_VALUE, readState.unreadCount())); ChannelsReadSupport.PostBlock lastPost = ChannelsReadSupport.loadLastPost(c, key.ownerBch, key.rootNumber); if (lastPost != null) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java new file mode 100644 index 00000000..cefc6df3 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_SetChannelReadState_Handler.java @@ -0,0 +1,145 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.Base64Ws; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_SetChannelReadState_Response; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; +import shine.db.MsgSubType; +import shine.db.dao.ChannelReadStateDAO; +import utils.config.ShineSignatureConstants; +import utils.crypto.Ed25519Util; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; + +/** Accepts the signed client read watermark for one followed public channel. */ +public final class Net_SetChannelReadState_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_SetChannelReadState_Handler.class); + private static final long MAX_FUTURE_SKEW_MS = 5 * 60_000L; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_SetChannelReadState_Request req = (Net_SetChannelReadState_Request) baseRequest; + if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "NOT_AUTHENTICATED", "Требуется авторизация"); + } + if (req.getLogin() == null || req.getLogin().isBlank() + || req.getOwner_bch_name() == null || req.getOwner_bch_name().isBlank() + || req.getChannel_name() == null || req.getChannel_name().isBlank() + || req.getRead_count() == null || req.getRead_count() < 0 + || req.getTime_ms() == null || req.getTime_ms() <= 0 + || req.getClient_key() == null || req.getClient_key().isBlank() + || req.getSignature() == null || req.getSignature().isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, + "BAD_FIELDS", "Некорректные поля ChannelReadState"); + } + + String authenticatedLogin = String.valueOf(ctx.getCurrentUser().getLogin()).trim(); + String login = req.getLogin().trim(); + String ownerBch = req.getOwner_bch_name().trim(); + String channelName = req.getChannel_name().trim(); + long readCount = req.getRead_count(); + long timeMs = req.getTime_ms(); + String clientKeyB64 = req.getClient_key().trim(); + String signatureB64 = req.getSignature().trim(); + + if (!authenticatedLogin.equalsIgnoreCase(login)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "LOGIN_MISMATCH", "Состояние чтения принадлежит другому пользователю"); + } + long nowMs = System.currentTimeMillis(); + if (timeMs > nowMs + MAX_FUTURE_SKEW_MS) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "BAD_TIME", "Некорректное время подписи"); + } + + try { + byte[] pubKey32; + byte[] sig64; + try { + pubKey32 = Base64Ws.decodeLen(clientKeyB64, 32, "client_key"); + sig64 = Base64Ws.decodeLen(signatureB64, 64, "signature"); + } catch (IllegalArgumentException e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, + "BAD_BASE64", "client_key/signature должны быть Base64"); + } + + String userClientKey = String.valueOf(ctx.getCurrentUser().getClientKey() == null + ? "" : ctx.getCurrentUser().getClientKey()).trim(); + if (userClientKey.isBlank() || !userClientKey.equals(clientKeyB64)) { + return NetExceptionResponseFactory.error(req, 403, + "DEVICE_KEY_MISMATCH", "client_key не соответствует пользователю"); + } + + String signText = ShineSignatureConstants.CHANNEL_READ_STATE_PREFIX + + escapePart(login) + '|' + + escapePart(ownerBch) + '|' + + escapePart(channelName) + '|' + + timeMs + '|' + + readCount; + if (!Ed25519Util.verify(signText.getBytes(StandardCharsets.UTF_8), sig64, pubKey32)) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, + "INVALID_SIGNATURE", "Подпись состояния чтения канала не прошла проверку"); + } + + ChannelReadStateDAO dao = ChannelReadStateDAO.getInstance(); + ChannelReadStateDAO.UpsertResult result; + try (Connection c = DbController.getInstance().getConnection()) { + if (!dao.hasActiveSubscription(c, authenticatedLogin, ownerBch, channelName, + MsgSubType.CONNECTION_FOLLOW)) { + return NetExceptionResponseFactory.error(req, 409, + "CHANNEL_NOT_FOLLOWED", "Пользователь не подписан на этот канал"); + } + String ownerLogin = dao.resolveOwnerLogin(c, ownerBch, channelName); + Integer channelRoot = dao.resolvePublicChannelRootByName(c, ownerBch, channelName); + if (ownerLogin == null || ownerLogin.isBlank() || channelRoot == null) { + return NetExceptionResponseFactory.error(req, 404, + "CHANNEL_NOT_FOUND", "Канал не найден"); + } + + // Never persist a watermark beyond the number of messages that really exists. + // Without this guard, one buggy/malicious oversized read_count would be monotonic + // and could make all future unread counters impossible to clear correctly. + long actualMessagesCount = ChannelsReadSupport.countPosts(c, ownerBch, channelRoot); + long effectiveReadCount = Math.min(readCount, actualMessagesCount); + + result = dao.upsertSignedReadIfNewer(c, + authenticatedLogin, ownerLogin, ownerBch, channelName, + effectiveReadCount, timeMs, clientKeyB64, signatureB64, nowMs); + } + + Net_SetChannelReadState_Response resp = new Net_SetChannelReadState_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setLogin(authenticatedLogin); + resp.setOwner_bch_name(ownerBch); + resp.setChannel_name(channelName); + resp.setRead_count(result.readCount()); + resp.setUnread_count(result.unreadCount()); + resp.setTime_ms(timeMs); + resp.setApplied(result.applied()); + if (result.applied()) UserCountersSupport.pushChanged(authenticatedLogin); + return resp; + } catch (Exception e) { + log.error("SetChannelReadState failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, + "INTERNAL_ERROR", NetExceptionResponseFactory.detailedMessage( + "Внутренняя ошибка SetChannelReadState", e)); + } + } + + private static String escapePart(String value) { + String s = String.valueOf(value == null ? "" : value); + return s.replace("\\", "\\\\").replace("|", "\\|"); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java new file mode 100644 index 00000000..c01830db --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java @@ -0,0 +1,94 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.push.WsEventSender; +import shine.db.DbController; +import shine.db.dao.UserNotificationSeenStateDAO; +import shine.db.dao.ChannelReadStateDAO; +import shine.db.dao.UserNotificationsStateDAO; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** + * Temporary calculator behind the stable GetUserCounters/UserCountersChanged contract. + * It intentionally uses current DB state today; later it can read one materialized row/cache + * without changing API or UI. + */ +public final class UserCountersSupport { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private UserCountersSupport() {} + + public record Snapshot(long dmUnreadCount, long channelsUnreadCount, long repliesUnreadCount, + long connectionsUnreadCount, long eventsUnreadCount) { + public long notificationsUnreadCount() { + return repliesUnreadCount + connectionsUnreadCount + eventsUnreadCount; + } + } + + public static Snapshot calculate(Connection c, String login) throws Exception { + long dm = countDmUnread(c, login); + long channels = countChannelsUnread(c, login); + UserNotificationSeenStateDAO seen = UserNotificationSeenStateDAO.getInstance(); + UserNotificationsStateDAO notifications = UserNotificationsStateDAO.getInstance(); + long repliesSeen = seen.getSeenAt(c, login, "replies"); + long connectionsSeen = seen.getSeenAt(c, login, "connections"); + long eventsSeen = seen.getSeenAt(c, login, "events"); + long replies = notifications.countUnseen(c, login, "reply", repliesSeen); + long connections = notifications.countUnseen(c, login, "connection", connectionsSeen); + long events = notifications.countUnseen(c, login, "event", eventsSeen); + return new Snapshot(dm, channels, replies, connections, events); + } + + public static Snapshot calculate(String login) throws Exception { + try (Connection c = DbController.getInstance().getConnection()) { + return calculate(c, login); + } + } + + public static void pushChanged(String login) { + if (login == null || login.isBlank()) return; + try { + Snapshot s = calculate(login); + ObjectNode payload = toPayload(login, s); + String eventId = "user-counters-" + System.currentTimeMillis(); + for (ConnectionContext ctx : ActiveConnectionsRegistry.getInstance().getByLogin(login)) { + WsEventSender.sendEvent(ctx, "UserCountersChanged", eventId, payload); + } + } catch (Exception ignored) { + // Counter push is best-effort. The client can always recover with GetUserCounters. + } + } + + public static ObjectNode toPayload(String login, Snapshot s) { + ObjectNode p = MAPPER.createObjectNode(); + p.put("login", login); + p.put("dmUnreadCount", s.dmUnreadCount()); + p.put("channelsUnreadCount", s.channelsUnreadCount()); + p.put("notificationsUnreadCount", s.notificationsUnreadCount()); + ObjectNode n = p.putObject("notifications"); + n.put("replies", s.repliesUnreadCount()); + n.put("connections", s.connectionsUnreadCount()); + n.put("events", s.eventsUnreadCount()); + return p; + } + + private static long countDmUnread(Connection c, String login) throws Exception { + String sql = "SELECT COALESCE(SUM(unread_count),0) FROM dm_dialog_state WHERE LOWER(owner_login)=LOWER(?)"; + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, login); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L; + } + } + } + + private static long countChannelsUnread(Connection c, String login) throws Exception { + return ChannelReadStateDAO.getInstance().sumUnreadCount(c, login); + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Request.java new file mode 100644 index 00000000..7fdd1c61 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Request.java @@ -0,0 +1,29 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetMessageLikes_Request extends Net_Request { + private MessageSelector message; + private Integer limit; + + public MessageSelector getMessage() { return message; } + public void setMessage(MessageSelector message) { this.message = message; } + + public Integer getLimit() { return limit; } + public void setLimit(Integer limit) { this.limit = limit; } + + public static class MessageSelector { + private String blockchainName; + private Integer blockNumber; + private String blockHash; + + public String getBlockchainName() { return blockchainName; } + public void setBlockchainName(String blockchainName) { this.blockchainName = blockchainName; } + + public Integer getBlockNumber() { return blockNumber; } + public void setBlockNumber(Integer blockNumber) { this.blockNumber = blockNumber; } + + public String getBlockHash() { return blockHash; } + public void setBlockHash(String blockHash) { this.blockHash = blockHash; } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Response.java new file mode 100644 index 00000000..9f74bc9d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetMessageLikes_Response.java @@ -0,0 +1,40 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import java.util.ArrayList; +import java.util.List; + +public class Net_GetMessageLikes_Response extends Net_Response { + private List shining = new ArrayList<>(); + private List official = new ArrayList<>(); + private List others = new ArrayList<>(); + private int total; + private boolean truncated; + + public List getShining() { return shining; } + public void setShining(List shining) { this.shining = shining; } + public List getOfficial() { return official; } + public void setOfficial(List official) { this.official = official; } + public List getOthers() { return others; } + public void setOthers(List others) { this.others = others; } + public int getTotal() { return total; } + public void setTotal(int total) { this.total = total; } + public boolean isTruncated() { return truncated; } + public void setTruncated(boolean truncated) { this.truncated = truncated; } + + public static class UserItem { + private String login; + private String firstName; + private String lastName; + private String avatarAr; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public String getFirstName() { return firstName; } + public void setFirstName(String firstName) { this.firstName = firstName; } + public String getLastName() { return lastName; } + public void setLastName(String lastName) { this.lastName = lastName; } + public String getAvatarAr() { return avatarAr; } + public void setAvatarAr(String avatarAr) { this.avatarAr = avatarAr; } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Request.java new file mode 100644 index 00000000..7a1129af --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Request.java @@ -0,0 +1,7 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +/** Lightweight authenticated request for bottom-toolbar counters. */ +public class Net_GetUserCounters_Request extends Net_Request { +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Response.java new file mode 100644 index 00000000..398132a5 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Response.java @@ -0,0 +1,29 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +/** Stable UI contract. Server-side calculation may be replaced by materialized counters later. */ +public class Net_GetUserCounters_Response extends Net_Response { + private String login; + private long dmUnreadCount; + private long channelsUnreadCount; + private long notificationsUnreadCount; + private long notificationRepliesUnreadCount; + private long notificationConnectionsUnreadCount; + private long notificationEventsUnreadCount; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public long getDmUnreadCount() { return dmUnreadCount; } + public void setDmUnreadCount(long value) { this.dmUnreadCount = value; } + public long getChannelsUnreadCount() { return channelsUnreadCount; } + public void setChannelsUnreadCount(long value) { this.channelsUnreadCount = value; } + public long getNotificationsUnreadCount() { return notificationsUnreadCount; } + public void setNotificationsUnreadCount(long value) { this.notificationsUnreadCount = value; } + public long getNotificationRepliesUnreadCount() { return notificationRepliesUnreadCount; } + public void setNotificationRepliesUnreadCount(long value) { this.notificationRepliesUnreadCount = value; } + public long getNotificationConnectionsUnreadCount() { return notificationConnectionsUnreadCount; } + public void setNotificationConnectionsUnreadCount(long value) { this.notificationConnectionsUnreadCount = value; } + public long getNotificationEventsUnreadCount() { return notificationEventsUnreadCount; } + public void setNotificationEventsUnreadCount(long value) { this.notificationEventsUnreadCount = value; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java index 2c1651ac..457e3f2e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_ListSubscriptionsFeed_Response.java @@ -26,7 +26,9 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { public static class ChannelSummary { private ChannelRef channel; private int messagesCount; + private long readCount; private int unreadCount; + private boolean readStateInitialized; private LastMessage lastMessage; public ChannelRef getChannel() { return channel; } @@ -35,9 +37,15 @@ public class Net_ListSubscriptionsFeed_Response extends Net_Response { public int getMessagesCount() { return messagesCount; } public void setMessagesCount(int messagesCount) { this.messagesCount = messagesCount; } + public long getReadCount() { return readCount; } + public void setReadCount(long readCount) { this.readCount = readCount; } + public int getUnreadCount() { return unreadCount; } public void setUnreadCount(int unreadCount) { this.unreadCount = unreadCount; } + public boolean isReadStateInitialized() { return readStateInitialized; } + public void setReadStateInitialized(boolean readStateInitialized) { this.readStateInitialized = readStateInitialized; } + public LastMessage getLastMessage() { return lastMessage; } public void setLastMessage(LastMessage lastMessage) { this.lastMessage = lastMessage; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Request.java new file mode 100644 index 00000000..35c52496 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Request.java @@ -0,0 +1,34 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_SetChannelReadState_Request extends Net_Request { + private String login; + private String owner_bch_name; + private String channel_name; + private Long read_count; + private Long time_ms; + private String client_key; + private String signature; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public String getOwner_bch_name() { return owner_bch_name; } + public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; } + + public String getChannel_name() { return channel_name; } + public void setChannel_name(String channel_name) { this.channel_name = channel_name; } + + public Long getRead_count() { return read_count; } + public void setRead_count(Long read_count) { this.read_count = read_count; } + + public Long getTime_ms() { return time_ms; } + public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } + + public String getClient_key() { return client_key; } + public void setClient_key(String client_key) { this.client_key = client_key; } + + public String getSignature() { return signature; } + public void setSignature(String signature) { this.signature = signature; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Response.java new file mode 100644 index 00000000..9db51d78 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_SetChannelReadState_Response.java @@ -0,0 +1,34 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_SetChannelReadState_Response extends Net_Response { + private String login; + private String owner_bch_name; + private String channel_name; + private Long read_count; + private Long unread_count; + private Long time_ms; + private Boolean applied; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public String getOwner_bch_name() { return owner_bch_name; } + public void setOwner_bch_name(String owner_bch_name) { this.owner_bch_name = owner_bch_name; } + + public String getChannel_name() { return channel_name; } + public void setChannel_name(String channel_name) { this.channel_name = channel_name; } + + public Long getRead_count() { return read_count; } + public void setRead_count(Long read_count) { this.read_count = read_count; } + + public Long getUnread_count() { return unread_count; } + public void setUnread_count(Long unread_count) { this.unread_count = unread_count; } + + public Long getTime_ms() { return time_ms; } + public void setTime_ms(Long time_ms) { this.time_ms = time_ms; } + + public Boolean getApplied() { return applied; } + public void setApplied(Boolean applied) { this.applied = applied; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java index f795b472..a51e0c6e 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java @@ -5,6 +5,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; import server.logic.ws_protocol.JSON.handlers.notifications.entyties.*; +import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes; import shine.db.DbController; @@ -23,7 +24,7 @@ public final class Net_SetNotificationState_Handler implements JsonMessageHandle byte[] pub=Ed25519Util.keyFromBase64(ctx.getCurrentUser().getClientKey()); if(!Ed25519Util.verify(p.signedBody,p.signature64,pub)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_SIGNATURE","Некорректная подпись clientKey"); long now=System.currentTimeMillis(); if(p.timeMs>now+5*60_000L) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_TIME","Некорректное время подписи"); long actual; try(Connection c=DbController.getInstance().getConnection()){ actual=UserNotificationSeenStateDAO.getInstance().advance(c,login,p.categoryName(),p.seenAtMs,p.timeMs,raw); } - Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); return r; + Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); UserCountersSupport.pushChanged(login); return r; }catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); } } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java index 053038bc..39f504e7 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java @@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; @@ -59,6 +60,8 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler { SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters(); if (status.applied()) { counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming); + UserCountersSupport.pushChanged(incoming.toLogin); + UserCountersSupport.pushChanged(incoming.fromLogin); } if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java index 50146df3..d7797ca0 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java @@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.entyties.Net_Request; import server.logic.ws_protocol.JSON.entyties.Net_Response; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request; import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; @@ -78,6 +79,14 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler { SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters(); if (pairStatus.applied()) { outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId); + + // SendMessagePair is also the local-server delivery path. Without this push, + // a recipient on the same access server receives the DM but the toolbar + // counter stays stale until the next explicit GetUserCounters refresh. + UserCountersSupport.pushChanged(incoming.toLogin); + if (!incoming.fromLogin.equalsIgnoreCase(incoming.toLogin)) { + UserCountersSupport.pushChanged(incoming.fromLogin); + } } if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java index 76bd9306..a3f50099 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/SignedMessagesRealtime.java @@ -147,7 +147,7 @@ public final class SignedMessagesRealtime { if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) { return false; } - String text = "Вам пришло новое личное сообщение от " + message.getFromLogin() + "."; + String text = "Новое сообщение от " + message.getFromLogin(); String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}"; return WebPushSender.sendBase64Payload( session.getPushEndpoint(), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java index 676e34cb..de71bc0f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/push/WebPushSender.java @@ -3,6 +3,7 @@ package server.logic.ws_protocol.JSON.push; import nl.martijndwars.webpush.Notification; import nl.martijndwars.webpush.PushService; import nl.martijndwars.webpush.Subscription; +import nl.martijndwars.webpush.Urgency; import org.jose4j.lang.JoseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -61,7 +62,7 @@ public final class WebPushSender { endpoint, new Subscription.Keys(p256dhKey, authKey) ); - Notification notification = new Notification(subscription, payloadB64); + Notification notification = new Notification(subscription, payloadB64, Urgency.HIGH); var response = service().send(notification); int code = response.getStatusLine().getStatusCode(); return code >= 200 && code < 300; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java index f7d35acc..1f1a9e55 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/AddBlockSyncService.java @@ -10,6 +10,7 @@ import shine.db.dao.SyncServersDAO; import shine.db.entities.BlockEntry; import shine.db.entities.SyncServerEntry; import utils.blockchain.BlockchainNameUtil; +import utils.config.AppConfig; import java.util.List; import java.util.Locale; @@ -46,6 +47,11 @@ public final class AddBlockSyncService { private final SyncServersDAO syncServersDAO = SyncServersDAO.getInstance(); public void replicateAsync(String blockchainName, int blockNumber) { + if (!isEnabled()) { + log.debug("AddBlock sync skipped: blockchain.sync.enabled=false blockchainName={} blockNumber={}", + blockchainName, blockNumber); + return; + } EXECUTOR.execute(() -> { try { replicate(blockchainName, blockNumber); @@ -56,6 +62,10 @@ public final class AddBlockSyncService { }); } + private static boolean isEnabled() { + return AppConfig.getInstance().getBoolean("blockchain.sync.enabled", true); + } + private void replicate(String blockchainName, int blockNumber) throws Exception { String ownerLogin = normalize(BlockchainNameUtil.loginFromBlockchainName(blockchainName)); if (ownerLogin == null) { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java index 44fa49cf..22555c14 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/sync/BlockchainResyncGuard.java @@ -4,7 +4,6 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import utils.files.FileStoreUtil; /** * In-memory guard для цепочек, которые сейчас находятся в полном resync. @@ -39,10 +38,7 @@ public final class BlockchainResyncGuard { if (isBypassed(key)) { return false; } - if (ACTIVE.contains(key)) { - return true; - } - return FileStoreUtil.getInstance().existsBlockchainResyncMarker(key); + return ACTIVE.contains(key); } public static T withBypass(String blockchainName, ThrowingSupplier supplier) throws Exception { diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java index 38597f90..0b17c1e6 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/codec/ShineUsersCodec.java @@ -59,6 +59,9 @@ public final class ShineUsersCodec { private static final int BLOCK_TYPE_TRUSTED_STATE = 70; + private static final int BLOCK_TYPE_ARCHIVE_HEAD = + 100; + private static final int BLOCK_VERSION_0 = 0; @@ -306,6 +309,12 @@ public final class ShineUsersCodec { int trustedCount = 0; + String archiveHeadTxId = + ""; + + String archiveHeadHash = + ""; + for (int i = 0; i < blocksCount; i++) { int blockType = @@ -455,6 +464,13 @@ public final class ShineUsersCodec { trustedCount = reader.readU8(); + case BLOCK_TYPE_ARCHIVE_HEAD -> { + archiveHeadTxId = + Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32)); + archiveHeadHash = + toHex(reader.readFixed(32)); + } + default -> throw new IllegalArgumentException( "Unsupported block type: " + blockType @@ -484,6 +500,8 @@ public final class ShineUsersCodec { lastBlockHash, lastBlockSignature, arweaveTxId, + archiveHeadTxId, + archiveHeadHash, isServer, addressFormatType, addressFormatVersion, @@ -520,7 +538,9 @@ public final class ShineUsersCodec { mutation.createdAtMs(), mutation.createdAtMs(), toHex(ZERO_HASH), - paidLimitBytes + paidLimitBytes, + mutation.fields().archiveHeadSupplied() ? mutation.fields().archiveHeadTxId() : "", + mutation.fields().archiveHeadSupplied() ? mutation.fields().archiveHeadHash() : "" ); } @@ -535,6 +555,13 @@ public final class ShineUsersCodec { previous.paidLimitBytes() + mutation.additionalLimit(); + String archiveTx = mutation.fields().archiveHeadSupplied() + ? mutation.fields().archiveHeadTxId() + : previous.archiveHeadTxId(); + String archiveHash = mutation.fields().archiveHeadSupplied() + ? mutation.fields().archiveHeadHash() + : previous.archiveHeadHash(); + return buildSnapshot( mutation, mutation.version(), @@ -543,7 +570,9 @@ public final class ShineUsersCodec { mutation.createdAtMs(), mutation.updatedAtMs(), toHex(mutation.prevHash()), - paidLimitBytes + paidLimitBytes, + archiveTx, + archiveHash ); } @@ -617,10 +646,13 @@ public final class ShineUsersCodec { pushFixed(out, prevHash); pushStringU8(out, loginBytes); + boolean hasArchiveHead = + snapshot.archiveHeadTxId() != null + && !snapshot.archiveHeadTxId().isBlank(); + int blocksCount = - snapshot.isServer() - ? 8 - : 7; + (snapshot.isServer() ? 8 : 7) + + (hasArchiveHead ? 1 : 0); out.add((byte) blocksCount); @@ -695,6 +727,18 @@ public final class ShineUsersCodec { out.add((byte) BLOCK_VERSION_0); out.add((byte) snapshot.trustedCount()); + if (hasArchiveHead) { + byte[] txId = Base64.getUrlDecoder().decode(snapshot.archiveHeadTxId()); + byte[] archiveHash = fromHex(snapshot.archiveHeadHash()); + if (txId.length != 32 || archiveHash.length != 32) { + throw new IllegalArgumentException("Archive head должен содержать txId/hash по 32 байта"); + } + out.add((byte) BLOCK_TYPE_ARCHIVE_HEAD); + out.add((byte) BLOCK_VERSION_0); + pushFixed(out, txId); + pushFixed(out, archiveHash); + } + int recordLength = out.size() + 64; @@ -871,6 +915,8 @@ public final class ShineUsersCodec { UserFields fields = parseFields(reader); + fields = parseOptionalArchiveExtension(reader, fields); + String recordSignature = Base58Util.encode( reader.readFixed(64) @@ -1008,7 +1054,35 @@ public final class ShineUsersCodec { List.copyOf(accessServers), sessionsMode, List.copyOf(sessions), - trustedCount + trustedCount, + false, + "", + "" + ); + } + + private static UserFields parseOptionalArchiveExtension(Reader reader, UserFields fields) { + if (reader.remaining() == 64) { + return fields; + } + if (reader.remaining() < 65) { + throw new IllegalArgumentException("Некорректная archive extension в update_user_pda"); + } + int present = reader.readU8(); + String tx = ""; + String hash = ""; + if (present == 1) { + tx = Base64.getUrlEncoder().withoutPadding().encodeToString(reader.readFixed(32)); + hash = toHex(reader.readFixed(32)); + } else if (present != 0) { + throw new IllegalArgumentException("Некорректный archive head marker: " + present); + } + return new UserFields( + fields.clientKey(), fields.blockchainKey(), fields.blockchainName(), fields.usedBytes(), + fields.lastBlockNumber(), fields.lastBlockHash(), fields.lastBlockSignature(), fields.arweaveTxId(), + fields.isServer(), fields.addressFormatType(), fields.addressFormatVersion(), fields.serverAddress(), + fields.syncServers(), fields.accessServers(), fields.sessionsMode(), fields.sessions(), fields.trustedCount(), + true, tx, hash ); } @@ -1020,7 +1094,9 @@ public final class ShineUsersCodec { long createdAtMs, long updatedAtMs, String prevRecordHash, - long paidLimitBytes + long paidLimitBytes, + String archiveHeadTxId, + String archiveHeadHash ) { UserFields fields = @@ -1044,6 +1120,8 @@ public final class ShineUsersCodec { fields.lastBlockHash(), fields.lastBlockSignature(), fields.arweaveTxId(), + archiveHeadTxId == null ? "" : archiveHeadTxId, + archiveHeadHash == null ? "" : archiveHeadHash, fields.isServer(), fields.addressFormatType(), fields.addressFormatVersion(), @@ -1266,6 +1344,10 @@ public final class ShineUsersCodec { ) { cursor += bytes; } + + private int remaining() { + return data.length - cursor; + } } public enum TxKind { @@ -1327,7 +1409,10 @@ public final class ShineUsersCodec { List accessServers, int sessionsMode, List sessions, - int trustedCount + int trustedCount, + boolean archiveHeadSupplied, + String archiveHeadTxId, + String archiveHeadHash ) { } @@ -1389,6 +1474,8 @@ public final class ShineUsersCodec { String lastBlockHash, String lastBlockSignature, String arweaveTxId, + String archiveHeadTxId, + String archiveHeadHash, boolean isServer, int addressFormatType, int addressFormatVersion, @@ -1424,6 +1511,8 @@ public final class ShineUsersCodec { lastBlockHash, lastBlockSignature, arweaveTxId, + archiveHeadTxId, + archiveHeadHash, isServer, addressFormatType, addressFormatVersion, diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java index 5ef0b1c0..ccfc16ff 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/service/SolanaUsersSyncService.java @@ -327,6 +327,25 @@ public final class SolanaUsersSyncService state.lastSeenSignature() ); + if (state.lastSeenSignature() == null) { + log.warn( + "History checkpoint is empty. Running current-state full snapshot bootstrap instead of replaying all historical transactions." + ); + + runFullSnapshotFallback( + state, + fetchResult, + nowMs + ); + + markReadyAfterSync( + state, + nowMs + ); + + return; + } + if (state.lastSeenSignature() != null && !fetchResult.anchorFound()) { @@ -629,7 +648,7 @@ public final class SolanaUsersSyncService account.address(), account.slot(), account.dataBase64(), - state.lastSeenSignature() + "" ) ); } catch (Exception ignored) { diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java index f279f65d..c8cbf1aa 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/storage/postgres/PostgresStorageRepository.java @@ -299,13 +299,7 @@ public final class PostgresStorageRepository connection.setAutoCommit(false); - try (Statement statement = - connection.createStatement()) { - - statement.executeUpdate( - "TRUNCATE TABLE solana_user_pda_current" - ); - + try { upsertCurrentSnapshots( connection, snapshots @@ -425,13 +419,13 @@ public final class PostgresStorageRepository "record_number, recovery_key, root_key, client_key, " + "blockchain_name, blockchain_key, paid_limit_bytes, " + "used_bytes, last_block_number, last_block_hash, " + - "last_block_signature, arweave_tx_id, is_server, " + + "last_block_signature, arweave_tx_id, archive_head_tx_id, archive_head_hash, is_server, " + "address_format_type, address_format_version, " + "server_address, sync_servers_json, access_servers_json, " + "sessions_mode, sessions_json, trusted_count, " + "created_at_ms, updated_at_ms, prev_record_hash, " + "record_signature, raw_data_base64, saved_at_ms" + - ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT (pda_address, record_number) DO NOTHING"; try (PreparedStatement statement = @@ -470,13 +464,13 @@ public final class PostgresStorageRepository "recovery_key, root_key, client_key, blockchain_name, " + "blockchain_key, paid_limit_bytes, used_bytes, " + "last_block_number, last_block_hash, last_block_signature, " + - "arweave_tx_id, is_server, address_format_type, " + + "arweave_tx_id, archive_head_tx_id, archive_head_hash, is_server, address_format_type, " + "address_format_version, server_address, sync_servers_json, " + "access_servers_json, sessions_mode, sessions_json, " + "trusted_count, created_at_ms, updated_at_ms, " + "prev_record_hash, record_signature, raw_data_base64, " + "first_seen_at_ms, last_synced_at_ms" + - ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT (pda_address) DO UPDATE SET " + "login = EXCLUDED.login, " + "normalized_login = EXCLUDED.normalized_login, " + @@ -494,6 +488,16 @@ public final class PostgresStorageRepository "last_block_hash = EXCLUDED.last_block_hash, " + "last_block_signature = EXCLUDED.last_block_signature, " + "arweave_tx_id = EXCLUDED.arweave_tx_id, " + + "archive_imported = CASE WHEN " + + "EXCLUDED.archive_head_tx_id IS DISTINCT FROM solana_user_pda_current.archive_head_tx_id " + + "OR EXCLUDED.archive_head_hash IS DISTINCT FROM solana_user_pda_current.archive_head_hash " + + "THEN FALSE ELSE solana_user_pda_current.archive_imported END, " + + "archive_last_imported_tx_id = CASE WHEN " + + "EXCLUDED.archive_head_tx_id = solana_user_pda_current.archive_head_tx_id " + + "AND EXCLUDED.archive_head_hash IS DISTINCT FROM solana_user_pda_current.archive_head_hash " + + "THEN '' ELSE solana_user_pda_current.archive_last_imported_tx_id END, " + + "archive_head_tx_id = EXCLUDED.archive_head_tx_id, " + + "archive_head_hash = EXCLUDED.archive_head_hash, " + "is_server = EXCLUDED.is_server, " + "address_format_type = EXCLUDED.address_format_type, " + "address_format_version = EXCLUDED.address_format_version, " + @@ -536,141 +540,41 @@ public final class PostgresStorageRepository ShineUsersCodec.UserPdaSnapshot snapshot, long nowMs ) throws Exception { - - statement.setString( - 1, - snapshot.lastTxSignature() - ); - statement.setLong( - 2, - snapshot.slot() - ); - statement.setNull( - 3, - Types.BIGINT - ); - statement.setString( - 4, - snapshot.pdaAddress() - ); - statement.setString( - 5, - snapshot.login() - ); - statement.setInt( - 6, - snapshot.recordNumber() - ); - statement.setString( - 7, - snapshot.recoveryKey() - ); - statement.setString( - 8, - snapshot.rootKey() - ); - statement.setString( - 9, - snapshot.clientKey() - ); - statement.setString( - 10, - snapshot.blockchainName() - ); - statement.setString( - 11, - snapshot.blockchainKey() - ); - statement.setLong( - 12, - snapshot.paidLimitBytes() - ); - statement.setLong( - 13, - snapshot.usedBytes() - ); - statement.setInt( - 14, - snapshot.lastBlockNumber() - ); - statement.setString( - 15, - snapshot.lastBlockHash() - ); - statement.setString( - 16, - snapshot.lastBlockSignature() - ); - statement.setString( - 17, - snapshot.arweaveTxId() - ); - statement.setBoolean( - 18, - snapshot.isServer() - ); - statement.setInt( - 19, - snapshot.addressFormatType() - ); - statement.setInt( - 20, - snapshot.addressFormatVersion() - ); - statement.setString( - 21, - snapshot.serverAddress() - ); - statement.setString( - 22, - writeJson( - snapshot.syncServers() - ) - ); - statement.setString( - 23, - writeJson( - snapshot.accessServers() - ) - ); - statement.setInt( - 24, - snapshot.sessionsMode() - ); - statement.setString( - 25, - writeJson( - snapshot.sessions() - ) - ); - statement.setInt( - 26, - snapshot.trustedCount() - ); - statement.setLong( - 27, - snapshot.createdAtMs() - ); - statement.setLong( - 28, - snapshot.updatedAtMs() - ); - statement.setString( - 29, - snapshot.prevRecordHash() - ); - statement.setString( - 30, - snapshot.recordSignature() - ); - statement.setString( - 31, - snapshot.rawDataBase64() - ); - statement.setLong( - 32, - nowMs - ); + int i = 1; + statement.setString(i++, snapshot.lastTxSignature()); + statement.setLong(i++, snapshot.slot()); + statement.setNull(i++, Types.BIGINT); + statement.setString(i++, snapshot.pdaAddress()); + statement.setString(i++, snapshot.login()); + statement.setInt(i++, snapshot.recordNumber()); + statement.setString(i++, snapshot.recoveryKey()); + statement.setString(i++, snapshot.rootKey()); + statement.setString(i++, snapshot.clientKey()); + statement.setString(i++, snapshot.blockchainName()); + statement.setString(i++, snapshot.blockchainKey()); + statement.setLong(i++, snapshot.paidLimitBytes()); + statement.setLong(i++, snapshot.usedBytes()); + statement.setInt(i++, snapshot.lastBlockNumber()); + statement.setString(i++, snapshot.lastBlockHash()); + statement.setString(i++, snapshot.lastBlockSignature()); + statement.setString(i++, snapshot.arweaveTxId()); + statement.setString(i++, snapshot.archiveHeadTxId()); + statement.setString(i++, snapshot.archiveHeadHash()); + statement.setBoolean(i++, snapshot.isServer()); + statement.setInt(i++, snapshot.addressFormatType()); + statement.setInt(i++, snapshot.addressFormatVersion()); + statement.setString(i++, snapshot.serverAddress()); + statement.setString(i++, writeJson(snapshot.syncServers())); + statement.setString(i++, writeJson(snapshot.accessServers())); + statement.setInt(i++, snapshot.sessionsMode()); + statement.setString(i++, writeJson(snapshot.sessions())); + statement.setInt(i++, snapshot.trustedCount()); + statement.setLong(i++, snapshot.createdAtMs()); + statement.setLong(i++, snapshot.updatedAtMs()); + statement.setString(i++, snapshot.prevRecordHash()); + statement.setString(i++, snapshot.recordSignature()); + statement.setString(i++, snapshot.rawDataBase64()); + statement.setLong(i, nowMs); } private void bindCurrentSnapshot( @@ -678,40 +582,42 @@ public final class PostgresStorageRepository ShineUsersCodec.UserPdaSnapshot snapshot, long nowMs ) throws Exception { - - statement.setString(1, snapshot.pdaAddress()); - statement.setString(2, snapshot.login()); - statement.setString(3, normalizeLogin(snapshot.login())); - statement.setInt(4, snapshot.recordNumber()); - statement.setLong(5, snapshot.slot()); - statement.setString(6, snapshot.lastTxSignature()); - statement.setString(7, snapshot.recoveryKey()); - statement.setString(8, snapshot.rootKey()); - statement.setString(9, snapshot.clientKey()); - statement.setString(10, snapshot.blockchainName()); - statement.setString(11, snapshot.blockchainKey()); - statement.setLong(12, snapshot.paidLimitBytes()); - statement.setLong(13, snapshot.usedBytes()); - statement.setInt(14, snapshot.lastBlockNumber()); - statement.setString(15, snapshot.lastBlockHash()); - statement.setString(16, snapshot.lastBlockSignature()); - statement.setString(17, snapshot.arweaveTxId()); - statement.setBoolean(18, snapshot.isServer()); - statement.setInt(19, snapshot.addressFormatType()); - statement.setInt(20, snapshot.addressFormatVersion()); - statement.setString(21, snapshot.serverAddress()); - statement.setString(22, writeJson(snapshot.syncServers())); - statement.setString(23, writeJson(snapshot.accessServers())); - statement.setInt(24, snapshot.sessionsMode()); - statement.setString(25, writeJson(snapshot.sessions())); - statement.setInt(26, snapshot.trustedCount()); - statement.setLong(27, snapshot.createdAtMs()); - statement.setLong(28, snapshot.updatedAtMs()); - statement.setString(29, snapshot.prevRecordHash()); - statement.setString(30, snapshot.recordSignature()); - statement.setString(31, snapshot.rawDataBase64()); - statement.setLong(32, nowMs); - statement.setLong(33, nowMs); + int i=1; + statement.setString(i++, snapshot.pdaAddress()); + statement.setString(i++, snapshot.login()); + statement.setString(i++, normalizeLogin(snapshot.login())); + statement.setInt(i++, snapshot.recordNumber()); + statement.setLong(i++, snapshot.slot()); + statement.setString(i++, snapshot.lastTxSignature()); + statement.setString(i++, snapshot.recoveryKey()); + statement.setString(i++, snapshot.rootKey()); + statement.setString(i++, snapshot.clientKey()); + statement.setString(i++, snapshot.blockchainName()); + statement.setString(i++, snapshot.blockchainKey()); + statement.setLong(i++, snapshot.paidLimitBytes()); + statement.setLong(i++, snapshot.usedBytes()); + statement.setInt(i++, snapshot.lastBlockNumber()); + statement.setString(i++, snapshot.lastBlockHash()); + statement.setString(i++, snapshot.lastBlockSignature()); + statement.setString(i++, snapshot.arweaveTxId()); + statement.setString(i++, snapshot.archiveHeadTxId()); + statement.setString(i++, snapshot.archiveHeadHash()); + statement.setBoolean(i++, snapshot.isServer()); + statement.setInt(i++, snapshot.addressFormatType()); + statement.setInt(i++, snapshot.addressFormatVersion()); + statement.setString(i++, snapshot.serverAddress()); + statement.setString(i++, writeJson(snapshot.syncServers())); + statement.setString(i++, writeJson(snapshot.accessServers())); + statement.setInt(i++, snapshot.sessionsMode()); + statement.setString(i++, writeJson(snapshot.sessions())); + statement.setInt(i++, snapshot.trustedCount()); + statement.setLong(i++, snapshot.createdAtMs()); + statement.setLong(i++, snapshot.updatedAtMs()); + statement.setString(i++, snapshot.prevRecordHash()); + statement.setString(i++, snapshot.recordSignature()); + statement.setString(i++, snapshot.rawDataBase64()); + statement.setLong(i++, nowMs); + statement.setLong(i, nowMs); } private String normalizeLogin(String login) { @@ -763,6 +669,8 @@ public final class PostgresStorageRepository resultSet.getString("last_block_hash"), resultSet.getString("last_block_signature"), resultSet.getString("arweave_tx_id"), + resultSet.getString("archive_head_tx_id"), + resultSet.getString("archive_head_hash"), resultSet.getBoolean("is_server"), resultSet.getInt("address_format_type"), resultSet.getInt("address_format_version"), @@ -933,6 +841,8 @@ public final class PostgresStorageRepository "last_block_hash TEXT NOT NULL, " + "last_block_signature TEXT NOT NULL, " + "arweave_tx_id TEXT NOT NULL, " + + "archive_head_tx_id TEXT NOT NULL DEFAULT '', " + + "archive_head_hash TEXT NOT NULL DEFAULT '', " + "is_server BOOLEAN NOT NULL, " + "address_format_type INTEGER NOT NULL, " + "address_format_version INTEGER NOT NULL, " + @@ -962,6 +872,9 @@ public final class PostgresStorageRepository " OR normalized_login <> LOWER(BTRIM(login))" ); + statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''"); + statement.executeUpdate("ALTER TABLE solana_user_pda_current ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''"); + statement.executeUpdate( "CREATE INDEX IF NOT EXISTS idx_user_pda_current_slot " + "ON solana_user_pda_current(slot)" @@ -995,6 +908,8 @@ public final class PostgresStorageRepository "last_block_hash TEXT NOT NULL, " + "last_block_signature TEXT NOT NULL, " + "arweave_tx_id TEXT NOT NULL, " + + "archive_head_tx_id TEXT NOT NULL DEFAULT '', " + + "archive_head_hash TEXT NOT NULL DEFAULT '', " + "is_server BOOLEAN NOT NULL, " + "address_format_type INTEGER NOT NULL, " + "address_format_version INTEGER NOT NULL, " + @@ -1014,6 +929,9 @@ public final class PostgresStorageRepository ")" ); + statement.executeUpdate("ALTER TABLE solana_user_pda_history ADD COLUMN IF NOT EXISTS archive_head_tx_id TEXT NOT NULL DEFAULT ''"); + statement.executeUpdate("ALTER TABLE solana_user_pda_history ADD COLUMN IF NOT EXISTS archive_head_hash TEXT NOT NULL DEFAULT ''"); + statement.executeUpdate( "CREATE INDEX IF NOT EXISTS idx_user_pda_history_login " + "ON solana_user_pda_history(login)" diff --git a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java index af065063..5e5fd518 100644 --- a/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java +++ b/SHiNE-server/shine-server-solana-users-sync/src/main/java/sync/util/SolanaPdaUtil.java @@ -87,7 +87,7 @@ public final class SolanaPdaUtil { return false; } - return Ed25519.validatePublicKeyFull( + return Ed25519.validatePublicKeyPartial( publicKey, 0 ); diff --git a/SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/util/SolanaPdaUtilTest.java b/SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/util/SolanaPdaUtilTest.java new file mode 100644 index 00000000..55fe85a9 --- /dev/null +++ b/SHiNE-server/shine-server-solana-users-sync/src/test/java/sync/util/SolanaPdaUtilTest.java @@ -0,0 +1,27 @@ +package sync.util; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SolanaPdaUtilTest { + + @Test + void findProgramAddressMatchesSolanaRuntimeForUserLoginSeed() { + String pda = SolanaPdaUtil.findProgramAddress( + List.of( + "user_login=".getBytes(StandardCharsets.UTF_8), + "server_t2".getBytes(StandardCharsets.UTF_8) + ), + "SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6" + ); + + assertEquals( + "EJCW7cPXgEMvQAzmhJMZ7pxQxxxFbQYZoJNhcfJpWnz8", + pda + ); + } +} 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..f6758a23 --- /dev/null +++ b/SHiNE-server/src/main/java/server/files/DmFileServlet.java @@ -0,0 +1,414 @@ +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 объект неизменяем. Нельзя доверять одному только имени файла: + // перед ответом «уже есть» повторно проверяем реальный SHA-256 сохранённых байтов. + // Если файл на диске повреждён/подменён, удаляем его и принимаем корректную загрузку заново. + if (Files.isRegularFile(target)) { + StoredObjectCheck stored = verifyStoredObject(target, fileId); + if (stored.valid()) { + if (stored.size() != auth.declaredLength) { + writeError(resp, HttpServletResponse.SC_CONFLICT, "FILE_ID_CONFLICT", "Файл с таким hash уже существует с другим размером"); + return; + } + writeUploadOk(resp, fileId, auth.declaredLength, true); + return; + } + Files.deleteIfExists(target); + } + + 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) { + StoredObjectCheck stored = verifyStoredObject(target, fileId); + if (stored.valid() && stored.size() == actualLength) { + alreadyExists = true; + } else { + // Даже в редкой гонке не подтверждаем объект, пока его реальные байты не прошли hash-проверку. + Files.deleteIfExists(target); + try { + Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temp, target); + } + keepTemp = 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; + } + + StoredObjectCheck stored = verifyStoredObject(target, fileId); + if (!stored.valid()) { + // Повреждённый content-addressed объект нельзя подтверждать через HEAD и нельзя отдавать через GET. + Files.deleteIfExists(target); + writeError(resp, HttpServletResponse.SC_NOT_FOUND, "FILE_NOT_FOUND", "Файл не найден"); + return; + } + + long size = stored.size(); + 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 static StoredObjectCheck verifyStoredObject(Path target, String expectedFileId) throws IOException { + MessageDigest digest = sha256Digest(); + long size = 0L; + byte[] buffer = new byte[64 * 1024]; + try (InputStream in = Files.newInputStream(target)) { + int read; + while ((read = in.read(buffer)) >= 0) { + if (read == 0) continue; + size += read; + digest.update(buffer, 0, read); + } + } + String actualFileId = toBase58(digest.digest()); + return new StoredObjectCheck(Objects.equals(expectedFileId, actualFileId), size); + } + + private record StoredObjectCheck(boolean valid, long size) {} + + 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/sync/BlockchainResyncRecoveryOnStartup.java b/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java deleted file mode 100644 index 8c79f513..00000000 --- a/SHiNE-server/src/main/java/server/sync/BlockchainResyncRecoveryOnStartup.java +++ /dev/null @@ -1,150 +0,0 @@ -package server.sync; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import shine.db.entities.SyncServerEntry; -import utils.files.FileStoreUtil; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.DirectoryStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * Startup-recovery для цепочек, которые были помечены как resync-pending. - * - * Правило простое: - * - marker-file означает, что chain должен быть пересобран заново; - * - мы не пытаемся продолжать с середины; - * - если recovery не завершён, обычная работа сервера не стартует. - */ -public final class BlockchainResyncRecoveryOnStartup { - - private static final Logger log = LoggerFactory.getLogger(BlockchainResyncRecoveryOnStartup.class); - private static final RemoteBlockchainSyncClient REMOTE = new RemoteBlockchainSyncClient(); - - private BlockchainResyncRecoveryOnStartup() {} - - public static void runRecoveryOrThrow() { - Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME); - ensureDirExists(dataDir); - - List markers = listMarkerFiles(dataDir); - if (markers.isEmpty()) { - log.info("🟢 BlockchainResyncRecovery: resync marker-файлы не найдены."); - return; - } - - log.warn("🟡 BlockchainResyncRecovery: найдено marker-файлов: {}", markers.size()); - for (Path marker : markers) { - recoverSingleMarkerOrThrow(marker); - } - log.info("✅ BlockchainResyncRecovery: все marker-файлы обработаны."); - } - - private static void recoverSingleMarkerOrThrow(Path markerPath) { - String fileName = markerPath.getFileName().toString(); - String blockchainName = extractBlockchainName(fileName); - if (blockchainName == null || blockchainName.isBlank()) { - throw new IllegalStateException("Bad resync marker name: " + fileName); - } - - Map meta = parseMarker(markerPath); - String partnerLogin = normalize(meta.get("partnerLogin")); - String partnerAddress = normalize(meta.get("partnerAddress")); - if (partnerAddress == null) { - throw new IllegalStateException("Resync marker has no partnerAddress for blockchainName=" + blockchainName); - } - - log.warn("🔁 BlockchainResyncRecovery: processing marker blockchainName={} partnerLogin={} partnerAddress={}", - blockchainName, partnerLogin, partnerAddress); - - try { - List heads = - REMOTE.listBlockchainHeads(partnerLogin, partnerAddress); - RemoteBlockchainSyncClient.RemoteBlockchainHead remoteHead = heads.stream() - .filter(h -> h != null && blockchainName.equals(h.blockchainName())) - .findFirst() - .orElseThrow(() -> new IllegalStateException( - "Partner does not expose blockchainName=" + blockchainName + " address=" + partnerAddress)); - - SyncServerEntry partner = new SyncServerEntry( - partnerLogin == null ? "" : partnerLogin, - partnerAddress, - System.currentTimeMillis() - ); - - PeriodicBlockchainSyncService.resyncFromScratch(partner, remoteHead); - } catch (Exception e) { - throw new IllegalStateException( - "Не удалось восстановить resync-помеченную цепочку blockchainName=" + blockchainName, - e - ); - } - } - - private static List listMarkerFiles(Path dataDir) { - try (DirectoryStream ds = Files.newDirectoryStream(dataDir, "*" + FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) { - return stream(ds).toList(); - } catch (IOException e) { - throw new IllegalStateException("Cannot list resync markers in: " + dataDir, e); - } - } - - private static java.util.stream.Stream stream(DirectoryStream ds) { - return java.util.stream.StreamSupport.stream(ds.spliterator(), false) - .filter(Files::isRegularFile); - } - - private static Map parseMarker(Path path) { - try { - List lines = Files.readAllLines(path, StandardCharsets.UTF_8); - Map result = new HashMap<>(); - for (String line : lines) { - if (line == null) continue; - String s = line.trim(); - if (s.isEmpty() || s.startsWith("#")) continue; - int idx = s.indexOf('='); - if (idx <= 0) continue; - String key = s.substring(0, idx).trim(); - String value = s.substring(idx + 1).trim(); - result.put(key, value); - } - return result; - } catch (IOException e) { - throw new IllegalStateException("Cannot read resync marker: " + path, e); - } - } - - private static void ensureDirExists(Path dir) { - try { - if (!Files.exists(dir)) { - Files.createDirectories(dir); - } - } catch (IOException e) { - throw new IllegalStateException("Cannot create data dir: " + dir, e); - } - } - - private static String extractBlockchainName(String fileName) { - if (fileName == null) return null; - String s = fileName.trim(); - if (!s.endsWith(FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION)) { - return null; - } - return s.substring(0, s.length() - FileStoreUtil.BLOCKCHAIN_RESYNC_MARKER_EXTENSION.length()); - } - - private static String normalize(String value) { - if (value == null) return null; - String s = value.trim(); - return s.isEmpty() ? null : s.toLowerCase(Locale.ROOT); - } -} diff --git a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java index a007d58d..0ed5955d 100644 --- a/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java +++ b/SHiNE-server/src/main/java/server/sync/PeriodicBlockchainSyncService.java @@ -15,8 +15,8 @@ import shine.db.entities.BlockchainStateEntry; import shine.db.entities.SolanaUserPdaCurrentEntry; import shine.db.entities.SyncServerEntry; import server.sync.BlockchainResyncGuard; -import utils.files.FileStoreUtil; import utils.blockchain.BlockchainNameUtil; +import utils.config.AppConfig; import java.util.concurrent.locks.ReentrantLock; import java.util.List; @@ -52,12 +52,15 @@ public final class PeriodicBlockchainSyncService { private static final BlockchainStateDAO STATE_DAO = BlockchainStateDAO.getInstance(); private static final SyncServersDAO SYNC_SERVERS_DAO = SyncServersDAO.getInstance(); private static final BlockchainResyncCleanupDAO RESYNC_CLEANUP_DAO = BlockchainResyncCleanupDAO.getInstance(); - private static final FileStoreUtil FILE_STORE = FileStoreUtil.getInstance(); private static final SolanaUserPdaCurrentDAO SOLANA_USER_PDA_CURRENT_DAO = SolanaUserPdaCurrentDAO.getInstance(); private PeriodicBlockchainSyncService() {} public static void startOrLog() { + if (!isEnabled()) { + log.info("Periodic blockchain sync disabled by blockchain.sync.enabled=false"); + return; + } if (!STARTED.compareAndSet(false, true)) { return; } @@ -70,6 +73,10 @@ public final class PeriodicBlockchainSyncService { log.info("Periodic blockchain sync scheduled: startup + every {} hours", PERIOD_HOURS); } + private static boolean isEnabled() { + return AppConfig.getInstance().getBoolean("blockchain.sync.enabled", true); + } + private static void runCycleSafe() { try { runCycle(); @@ -214,26 +221,6 @@ public final class PeriodicBlockchainSyncService { return; } - String markerContent = """ - blockchainName=%s - partnerLogin=%s - partnerAddress=%s - remoteLastBlockNumber=%d - remoteLastBlockHash=%s - remoteFileSizeBytes=%d - startedAtMs=%d - """.formatted( - blockchainName, - partnerLogin == null ? "" : partnerLogin, - partner.getServerAddress() == null ? "" : partner.getServerAddress(), - remoteHead.lastBlockNumber(), - remoteHead.lastBlockHash(), - remoteHead.fileSizeBytes(), - System.currentTimeMillis() - ); - - FILE_STORE.writeBlockchainResyncMarker(blockchainName, markerContent); - ReentrantLock chainLock = BlockchainLocks.lockFor(blockchainName); chainLock.lock(); boolean success = false; @@ -248,9 +235,6 @@ public final class PeriodicBlockchainSyncService { cleanup.deletedBlocksRows(), cleanup.deletedBlockchainStateRows()); - FILE_STORE.deleteBlockchainFileIfExists(blockchainName); - FILE_STORE.deleteBlockchainTmpFileIfExists(blockchainName); - if (!ensureLocalChainExists(partner, blockchainName)) { throw new IllegalStateException("failed to recreate local chain state for " + blockchainName); } @@ -260,7 +244,6 @@ public final class PeriodicBlockchainSyncService { throw new IllegalStateException("failed to replay remote chain for " + blockchainName); } - FILE_STORE.deleteBlockchainResyncMarkerIfExists(blockchainName); success = true; log.info("Blockchain resync completed: partner={} blockchainName={} blocks=0..{}", partnerLogin, blockchainName, remoteHead.lastBlockNumber()); diff --git a/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java b/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java index 491bbb1d..1e0ec914 100644 --- a/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java +++ b/SHiNE-server/src/main/java/server/sync/SolanaUsersSyncStartupService.java @@ -58,7 +58,15 @@ public final class SolanaUsersSyncStartupService { ); service.start(); - service.awaitReady(); + try { + service.awaitReady(); + } catch (Exception exception) { + log.warn( + "Solana users sync is not ready yet; continuing server startup and leaving sync service running", + exception + ); + return; + } log.info( "Solana users sync is READY. Server startup may continue." diff --git a/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java b/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java deleted file mode 100644 index 8e063dff..00000000 --- a/SHiNE-server/src/main/java/server/ws/BlockchainTmpRecoveryOnStartup.java +++ /dev/null @@ -1,348 +0,0 @@ -package server.ws; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import shine.db.dao.BlocksDAO; -import shine.db.dao.BlockchainStateDAO; -import shine.db.entities.BlockEntry; -import shine.db.entities.BlockchainStateEntry; -import utils.files.FileStoreUtil; -import shine.log.BlockchainAdminNotifier; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.*; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.HexFormat; - -/** - * =============================================================== - * BlockchainTmpRecoveryOnStartup — восстановление консистентности - * файлов обычного AddBlock при старте сервера. - * - * Новая модель обычной записи: - * 1) собирается .tmp_bch как полный кандидат на замену main; - * 2) пишется sidecar .write_check (blockNumber/blockHash); - * 3) создаётся пустой marker .write_pending; - * 4) выполняется SQL-транзакция; - * 5) после commit tmp атомарно ставится на место main; - * 6) marker и sidecar удаляются. - * - * На старте: - * - если найден marker, recovery добивает запись или чистит мусор; - * - если marker нет, а tmp/check остались, это мусор и он удаляется; - * - legacy tmp-файлы без marker тоже считаются мусором. - * - * Принцип: - * - marker означает, что операция вошла в опасную фазу и должна быть доведена до конца или откатана; - * - sidecar нужен только как маленькое описание текущей операции (blockNumber/blockHash); - * - если marker отсутствует, временные файлы не считаются валидными. - * =============================================================== - */ -public final class BlockchainTmpRecoveryOnStartup { - - private static final Logger log = LoggerFactory.getLogger(BlockchainTmpRecoveryOnStartup.class); - private static final HexFormat HEX = HexFormat.of(); - - private BlockchainTmpRecoveryOnStartup() {} - - public static void runRecoveryOrThrow() { - FileStoreUtil fs = FileStoreUtil.getInstance(); - BlockchainStateDAO stateDAO = BlockchainStateDAO.getInstance(); - BlocksDAO blocksDAO = BlocksDAO.getInstance(); - - Path dataDir = Paths.get(FileStoreUtil.DATA_DIR_NAME); - ensureDirExists(dataDir); - - List markers = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION); - if (!markers.isEmpty()) { - log.warn("🟡 BlockchainTmpRecovery: найдено marker-файлов обычного AddBlock: {}", markers.size()); - } else { - log.info("🟢 BlockchainTmpRecovery: marker-файлов обычного AddBlock не найдено."); - } - - for (Path marker : markers) { - recoverSingleWriteMarkerOrThrow(marker, fs, stateDAO, blocksDAO); - } - - cleanupOrphanTempArtifacts(dataDir, fs); - log.info("✅ BlockchainTmpRecovery: обработка временных файлов AddBlock завершена."); - } - - private static void recoverSingleWriteMarkerOrThrow(Path markerPath, - FileStoreUtil fs, - BlockchainStateDAO stateDAO, - BlocksDAO blocksDAO) { - String markerFileName = markerPath.getFileName().toString(); - String blockchainName = extractBlockchainName(markerFileName, FileStoreUtil.BLOCKCHAIN_WRITE_PENDING_MARKER_EXTENSION); - if (blockchainName == null || blockchainName.isBlank()) { - BlockchainAdminNotifier.critical( - "НАЙДЕН write_pending marker С НЕОЖИДАННЫМ ИМЕНЕМ: " + markerFileName, - null - ); - throw new IllegalStateException("Bad write marker name: " + markerFileName); - } - - Path tmpPath = fs.resolveBlockchainTmpPath(blockchainName); - Path checkPath = fs.resolveBlockchainWriteCheckPath(blockchainName); - Path mainPath = fs.resolveBlockchainPath(blockchainName); - - Map meta = parseKeyValueFile(checkPath); - Integer expectedBlockNumber = parseInt(meta.get("blockNumber")); - String expectedBlockHashHex = normalizeHex(meta.get("blockHash")); - - try { - BlockchainStateEntry st = stateDAO.getByBlockchainName(blockchainName); - boolean mainExists = Files.exists(mainPath); - boolean tmpExists = Files.exists(tmpPath); - long mainSize = mainExists ? safeSize(mainPath) : -1L; - long tmpSize = tmpExists ? safeSize(tmpPath) : -1L; - - if (st == null) { - log.warn("🟠 BlockchainTmpRecovery: marker есть, но blockchain_state отсутствует. blockchainName={}. Удаляем временные файлы.", - blockchainName); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - - long stateSize = st.getFileSizeBytes(); - - if (expectedBlockNumber == null || expectedBlockHashHex == null) { - log.warn("🟠 BlockchainTmpRecovery: write_check повреждён или пуст. blockchainName={}. Пробуем recovery по размерам.", - blockchainName); - - if (mainExists && mainSize == stateSize) { - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - if (tmpExists && tmpSize == stateSize) { - fs.atomicReplaceBlockchainFile(blockchainName); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - if (tmpExists && mainExists) { - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - - BlockEntry block = blocksDAO.getByNumber(blockchainName, expectedBlockNumber); - if (block == null || block.getBlockHash() == null) { - log.warn("🟠 BlockchainTmpRecovery: в blocks нет ожидаемого блока. blockchainName={}, blockNumber={}. Чистим временные файлы.", - blockchainName, expectedBlockNumber); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - - String actualHashHex = HEX.formatHex(block.getBlockHash()); - if (!actualHashHex.equalsIgnoreCase(expectedBlockHashHex)) { - log.warn("🟠 BlockchainTmpRecovery: hash в write_check не совпал с DB. blockchainName={}, expected={}, actual={}. Чистим временные файлы.", - blockchainName, expectedBlockHashHex, actualHashHex); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - - // Если main уже совпадает со state — tmp/check/marker лишние. - if (mainExists && mainSize == stateSize) { - log.info("🟢 BlockchainTmpRecovery: main уже соответствует state. blockchainName={}, stateSize={}, mainSize={}", - blockchainName, stateSize, mainSize); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - - // Если tmp уже готов и совпадает со state — просто ставим его на место main. - if (tmpExists && tmpSize == stateSize) { - log.warn("🟡 BlockchainTmpRecovery: tmp соответствует state, восстанавливаем main. blockchainName={}, stateSize={}, tmpSize={}", - blockchainName, stateSize, tmpSize); - fs.atomicReplaceBlockchainFile(blockchainName); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - - // Если tmp нет, но DB уже закоммитила блок — пробуем восстановить tmp из main + block_bytes. - if (!tmpExists && mainExists) { - long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length; - if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) { - log.warn("🟡 BlockchainTmpRecovery: tmp отсутствует, но main+DB дают валидный кандидат. blockchainName={}. Восстанавливаем tmp и main.", - blockchainName); - byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes()); - fs.writeBlockchainTmp(blockchainName, rebuilt); - fs.atomicReplaceBlockchainFile(blockchainName); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - } - - // Если tmp есть, но его размер не совпал, пробуем восстановить из main + block_bytes. - if (mainExists) { - long expectedDelta = block.getBlockBytes() == null ? -1L : block.getBlockBytes().length; - if (expectedDelta >= 0 && mainSize + expectedDelta == stateSize) { - log.warn("🟡 BlockchainTmpRecovery: tmp/size не совпали, пересобираем tmp из main+block_bytes. blockchainName={}", - blockchainName); - byte[] rebuilt = rebuildTmpFromMainAndBlock(mainPath, block.getBlockBytes()); - fs.writeBlockchainTmp(blockchainName, rebuilt); - fs.atomicReplaceBlockchainFile(blockchainName); - cleanupWriteArtifacts(markerPath, checkPath, tmpPath); - return; - } - } - - // Если ничего не совпало, это уже подозрительное состояние. - BlockchainAdminNotifier.critical( - "НЕСОГЛАСОВАННОСТЬ ОПЕРАЦИИ AddBlock ПРИ СТАРТЕ. blockchainName=" + blockchainName + - ", stateSize=" + stateSize + - ", mainExists=" + mainExists + - ", mainSize=" + mainSize + - ", tmpExists=" + tmpExists + - ", tmpSize=" + tmpSize + - ", expectedBlockNumber=" + expectedBlockNumber + - ", expectedBlockHash=" + expectedBlockHashHex + - ". Требуется ручная проверка.", - null - ); - throw new IllegalStateException("AddBlock recovery mismatch for " + blockchainName); - } catch (SQLException e) { - BlockchainAdminNotifier.critical( - "ОШИБКА БД ПРИ ВОССТАНОВЛЕНИИ AddBlock marker: blockchainName=" + blockchainName, - e - ); - throw new IllegalStateException("DB error during AddBlock recovery for " + blockchainName, e); - } - } - - private static void cleanupOrphanTempArtifacts(Path dataDir, FileStoreUtil fs) { - List tmpFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION); - List checkFiles = listFilesWithSuffix(dataDir, FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION); - - if (tmpFiles.isEmpty() && checkFiles.isEmpty()) { - return; - } - - log.warn("🟡 BlockchainTmpRecovery: найдено orphan tmp/check файлов. tmp={}, check={}", tmpFiles.size(), checkFiles.size()); - for (Path tmp : tmpFiles) { - String blockchainName = extractBlockchainName(tmp.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_TMP_EXTENSION); - if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) { - continue; - } - safeDelete(tmp); - } - for (Path check : checkFiles) { - String blockchainName = extractBlockchainName(check.getFileName().toString(), FileStoreUtil.BLOCKCHAIN_WRITE_CHECK_EXTENSION); - if (blockchainName != null && Files.exists(fs.resolveBlockchainWritePendingMarkerPath(blockchainName))) { - continue; - } - safeDelete(check); - } - } - - private static void cleanupWriteArtifacts(Path markerPath, Path checkPath, Path tmpPath) { - safeDelete(markerPath); - safeDelete(checkPath); - safeDelete(tmpPath); - } - - private static byte[] rebuildTmpFromMainAndBlock(Path mainPath, byte[] blockBytes) { - try { - byte[] mainBytes = Files.exists(mainPath) ? Files.readAllBytes(mainPath) : new byte[0]; - byte[] out = new byte[mainBytes.length + blockBytes.length]; - System.arraycopy(mainBytes, 0, out, 0, mainBytes.length); - System.arraycopy(blockBytes, 0, out, mainBytes.length, blockBytes.length); - return out; - } catch (IOException e) { - throw new IllegalStateException("Cannot rebuild tmp from main: " + mainPath, e); - } - } - - private static Map parseKeyValueFile(Path path) { - Map result = new HashMap<>(); - if (path == null || !Files.exists(path)) { - return result; - } - try { - for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) { - if (line == null) continue; - String s = line.trim(); - if (s.isEmpty() || s.startsWith("#")) continue; - int idx = s.indexOf('='); - if (idx <= 0) continue; - String key = s.substring(0, idx).trim(); - String value = s.substring(idx + 1).trim(); - result.put(key, value); - } - return result; - } catch (IOException e) { - throw new IllegalStateException("Cannot read write_check file: " + path, e); - } - } - - private static Integer parseInt(String value) { - if (value == null || value.isBlank()) return null; - try { - return Integer.parseInt(value.trim()); - } catch (Exception e) { - return null; - } - } - - private static String normalizeHex(String value) { - if (value == null) return null; - String s = value.trim(); - return s.isEmpty() ? null : s; - } - - private static void ensureDirExists(Path dir) { - try { - if (!Files.exists(dir)) { - Files.createDirectories(dir); - } - } catch (IOException e) { - throw new IllegalStateException("Cannot create data dir: " + dir, e); - } - } - - private static List listFilesWithSuffix(Path dataDir, String suffix) { - List out = new ArrayList<>(); - try (DirectoryStream ds = Files.newDirectoryStream(dataDir, "*" + suffix)) { - for (Path p : ds) { - if (Files.isRegularFile(p)) { - out.add(p); - } - } - } catch (IOException e) { - throw new IllegalStateException("Cannot list files in: " + dataDir + " suffix=" + suffix, e); - } - return out; - } - - private static String extractBlockchainName(String fileName, String suffix) { - if (fileName == null) return null; - String s = fileName.trim(); - if (!s.endsWith(suffix)) return null; - String base = s.substring(0, s.length() - suffix.length()); - if (base.isBlank()) return null; - if (base.contains("/") || base.contains("\\") || base.contains("..")) return null; - return base; - } - - private static long safeSize(Path p) { - try { - return Files.size(p); - } catch (IOException e) { - throw new IllegalStateException("Cannot read file size: " + p, e); - } - } - - private static void safeDelete(Path p) { - try { - Files.deleteIfExists(p); - } catch (IOException e) { - throw new IllegalStateException("Cannot delete file: " + p, e); - } - } -} diff --git a/SHiNE-server/src/main/java/server/ws/WsServer.java b/SHiNE-server/src/main/java/server/ws/WsServer.java index b6186c8f..9a84e00a 100644 --- a/SHiNE-server/src/main/java/server/ws/WsServer.java +++ b/SHiNE-server/src/main/java/server/ws/WsServer.java @@ -5,8 +5,10 @@ import org.eclipse.jetty.servlet.ServletContextHandler; import org.eclipse.jetty.websocket.server.config.JettyWebSocketServletContainerInitializer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import server.archive.ArweaveBlockPublisherScheduler; +import server.archive.ArweaveBlockSyncScheduler; import server.debug.DebugApiConfigurator; -import server.sync.BlockchainResyncRecoveryOnStartup; +import server.files.DmFileApiConfigurator; import server.sync.PeriodicBlockchainSyncService; import server.sync.PeriodicDmDeliveryService; import server.sync.SolanaUsersSyncStartupService; @@ -19,10 +21,7 @@ import java.time.Duration; /** * WsServer — поднимает Jetty WS на /ws. * - * ВАЖНО: - * - перед стартом сервера выполняем recovery tmp-блокчейнов. - * - если обнаружена несогласованность, которую сервер сам чинить не может — - * recovery бросает исключение и сервер не стартует. + * Пользовательские блоки хранятся в PostgreSQL; локальные .bch-файлы больше не используются. */ public final class WsServer { @@ -30,28 +29,6 @@ public final class WsServer { public static void main(String[] args) throws Exception { - // ============================================================ - // 0) Восстановление консистентности blockchain файлов - // ============================================================ - try { - BlockchainTmpRecoveryOnStartup.runRecoveryOrThrow(); - } catch (Exception e) { - // Уже должно быть “большое” уведомление через BlockchainAdminNotifier, - // но на всякий случай логируем ещё раз. - log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain tmp-файлов.", e); - throw e; // останавливаем запуск - } - - // ============================================================ - // 0.1) Восстановление цепочек, зависших на full resync - // ============================================================ - try { - BlockchainResyncRecoveryOnStartup.runRecoveryOrThrow(); - } catch (Exception e) { - log.error("❌ Сервер НЕ будет запущен: критическая ошибка восстановления blockchain resync-маркеров.", e); - throw e; - } - // ============================================================ // 1.0) Синхронизация пользовательских Solana PDA // ============================================================ @@ -81,6 +58,10 @@ public final class WsServer { // ============================================================ PeriodicBlockchainSyncService.startOrLog(); + // ANS-104: publish locally-created signed user blocks and discover blocks published by other servers. + ArweaveBlockPublisherScheduler.startOrLog(); + ArweaveBlockSyncScheduler.startOrLog(); + // ============================================================ // 2) Запуск Jetty WS // ============================================================ @@ -93,6 +74,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 синтаксис) @@ -106,7 +90,7 @@ public final class WsServer { log.info("✅ WS сервер запущен на ws://localhost:{}/ws", port); ServerConnectionPool.getInstance().startOrLog(); Runtime.getRuntime().addShutdownHook(new Thread( - () -> ServerConnectionPool.getInstance().close(), + () -> { ServerConnectionPool.getInstance().close(); ArweaveBlockPublisherScheduler.close(); ArweaveBlockSyncScheduler.close(); }, "server-connection-pool-shutdown")); PeriodicDmDeliveryService.startOrLog(); server.join(); diff --git a/SHiNE-server/src/main/resources/application.properties b/SHiNE-server/src/main/resources/application.properties index 6a0b2c8b..12fe9f9e 100644 --- a/SHiNE-server/src/main/resources/application.properties +++ b/SHiNE-server/src/main/resources/application.properties @@ -27,6 +27,13 @@ solana.users.sync.pollIntervalSeconds=300 # ------------------------------------------------------------ sync.importUserProfileFromPartner.enabled=false +# ------------------------------------------------------------ +# Прямая межсерверная синхронизация пользовательских блокчейнов +# через sync_servers: ListBlockchainHeads/GetBlockchainBlock -> локальный AddBlock. +# Для проверки режима Solana + Arweave без прямых связей серверов можно выключить. +# ------------------------------------------------------------ +blockchain.sync.enabled=true + # ------------------------------------------------------------ # Server public info # Эти поля используются JSON-операцией GetServerInfo. @@ -53,6 +60,18 @@ 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 +# Лимит одного immutable ciphertext-объекта, НЕ всего файла. +# DM file v2 режет файл на 1 MiB части, поэтому общий размер файла этим параметром не ограничен. +# Значение 50 MiB сохранено для совместимости с DM file v1. +dm.files.maxBytes=52428816 + webpush.vapid.public=BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI webpush.vapid.private=3hCt7XxTvLzuoxinjT5QcKRQEBnGZHXn8ZilU31RPNE webpush.vapid.subject=mailto:admin@shine.local @@ -115,3 +134,37 @@ test.freeAvatar.limitPerUser=3 test.freeAvatar.maxBytes=131072 test.freeAvatar.walletAddress= test.freeAvatar.walletJwkPath= + +# ============================================================ +# Arweave per-user-block transport (ANS-104) +# Test namespace: each user DataItem is signed with App=test5590. +# Channel DataItems additionally contain c_test5590=. +# publish.mode: turbo | arweave | none +# ============================================================ +arweave.blocks.publish.mode=none +arweave.blocks.publish.intervalMinutes=15 +arweave.blocks.publish.maxItems=10000 + +# Direct Arweave L1 fallback: server combines user DataItems into one standard ANS-104 bundle. +arweave.blocks.publish.maxBundleBytes=134217728 +arweave.blocks.publish.gateway=https://arweave.net +arweave.blocks.publish.walletJwkPath= +arweave.blocks.publish.minConfirmations=0 +arweave.blocks.publish.confirmPollSeconds=30 +arweave.blocks.publish.confirmTimeoutMinutes=180 + +# Turbo: uploads each already user-signed DataItem separately, without re-signing it. +# paidByAddress is the public Turbo payer address. If it is empty and turbo.walletJwkPath is set, +# the Arweave payer address is derived locally from that JWK. The private key is never sent to Turbo. +# For paid uploads of someone else's signed DataItem, Turbo Credit Share Approval must exist for its signer. +arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx +arweave.blocks.publish.turbo.paidByAddress= +arweave.blocks.publish.turbo.walletJwkPath= + +arweave.blocks.sync.enabled=false +arweave.blocks.sync.intervalMinutes=15 +arweave.blocks.sync.gateway=https://turbo-gateway.com +arweave.blocks.sync.pageSize=100 +arweave.blocks.sync.queueBatchSize=10000 +arweave.blocks.sync.startBlockHeight=0 +arweave.blocks.sync.maxDataItemBytes=8388608 diff --git a/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java b/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java index f70ffb06..9a659908 100644 --- a/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java +++ b/SHiNE-server/src/test/java/test/it/blockchain/AddBlockSender.java @@ -1,5 +1,6 @@ package test.it.blockchain; +import blockchain.Ans104DataItem; import blockchain.BchBlockEntry; import blockchain.body.*; import test.it.utils.TestConfig; @@ -9,20 +10,19 @@ import test.it.utils.log.TestLog; import test.it.utils.ws.WsSession; import java.time.Duration; +import java.util.ArrayList; import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; /** - * AddBlockSender — под новый формат BchBlockEntry (Frame v0): - * - blockBytes = preimage + sigMarker(2) + signature64 - * - preimage начинается с frameCode(2) = 0x0000 - * - hash32 вычисляется как sha256(preimage) - * - signature = Ed25519.sign(hash32) - * - * ВАЖНО: - * - Линии по ТЗ ведём на стороне сервера/БД (триггеры), а в тестах считаем локально. + * AddBlockSender для Frame v1: blockBytesB64 содержит готовый ANS-104 DataItem. + * SHiNE hash цепочки остаётся SHA-256(Frame v1 bytes), а пользователь подписывает + * ANS-104 deep-hash одним Ed25519 ключом. */ public final class AddBlockSender { @@ -34,6 +34,7 @@ public final class AddBlockSender { private final String login; private final String blockchainName; private final byte[] loginPrivKey; + private final Map channelSlugByRoot = new HashMap<>(); public AddBlockSender(WsSession ws, ChainState state, String login, String blockchainName, byte[] loginPrivKey) { this.ws = ws; @@ -75,22 +76,18 @@ public final class AddBlockSender { byte[] bodyBytes = body.toBytes(); - // ВАЖНО: preimage должен быть БАЙТ-В-БАЙТ таким же, как в BchBlockEntry - byte[] preimage = buildPreimage(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes); + byte[] frame = buildFrame(prevHash32, blockNumber, tsSec, type, subType, version, bodyBytes); + byte[] owner32 = utils.crypto.Ed25519Util.derivePublicKey(loginPrivKey); - byte[] hash32 = blockchain.BchCryptoVerifier.sha256(preimage); - byte[] signature64 = utils.crypto.Ed25519Util.sign(hash32, loginPrivKey); + List tags = new ArrayList<>(); + tags.add(new Ans104DataItem.Tag("App", "test5590")); + String channelSlug = channelSlugFor(body); + if (channelSlug != null) tags.add(new Ans104DataItem.Tag("c_test5590", channelSlug)); - BchBlockEntry entry = new BchBlockEntry( - prevHash32, - blockNumber, - tsSec, - type, - subType, - version, - bodyBytes, - signature64 - ); + byte[] signingMessage = Ans104DataItem.buildSigningMessage(owner32, tags, frame); + byte[] signature64 = utils.crypto.Ed25519Util.sign(signingMessage, loginPrivKey); + byte[] rawDataItem = Ans104DataItem.serializeEd25519(signature64, owner32, tags, frame); + BchBlockEntry entry = new BchBlockEntry(rawDataItem); String prevHashHexForReq = (blockNumber == 0) ? ZERO64 : state.lastBlockHashHex(); @@ -119,6 +116,9 @@ public final class AddBlockSender { assertEquals(localHashHex, serverLastHash, op + ": serverLastBlockHash must match local hash"); state.applyAppendedBlock(blockNumber, entry.getHash32(), isHeader, type, body); + if (body instanceof CreateChannelBody ccb) { + channelSlugByRoot.put(blockNumber, shine.db.channels.ChannelNameRules.toCanonicalSlug(ccb.channelName)); + } if (TestConfig.DEBUG()) TestLog.info(op + ": state updated"); } @@ -194,48 +194,47 @@ public final class AddBlockSender { throw new IllegalArgumentException("Unknown body class: " + body.getClass()); } - // ---------- preimage builder (строго по BchBlockEntry Frame v0) ---------- + // ---------- Frame v1 + signed channel tag helpers ---------- - private static byte[] buildPreimage(byte[] prevHash32, - int blockNumber, - long tsSec, - short type, - short subType, - short version, - byte[] bodyBytes) { + private String channelSlugFor(BodyRecord body) { + if (body instanceof CreateChannelBody ccb) { + return shine.db.channels.ChannelNameRules.toCanonicalSlug(ccb.channelName); + } + if (body instanceof TextBody tb && tb.isLineMessage() && tb.lineCode > 0) { + String slug = channelSlugByRoot.get(tb.lineCode); + if (slug == null) { + throw new IllegalStateException("Unknown channel slug for lineCode=" + tb.lineCode); + } + return slug; + } + return null; + } + + private static byte[] buildFrame(byte[] prevHash32, + int blockNumber, + long tsSec, + short type, + short subType, + short version, + byte[] bodyBytes) { if (prevHash32 == null || prevHash32.length != 32) { throw new IllegalArgumentException("prevHash32 must be 32 bytes"); } - int bodyLen = (bodyBytes == null ? 0 : bodyBytes.length); - int blockSize = BchBlockEntry.PREIMAGE_HEADER_SIZE + bodyLen; - + int bodyLen = bodyBytes == null ? 0 : bodyBytes.length; + int blockSize = BchBlockEntry.FRAME_HEADER_SIZE + bodyLen; java.nio.ByteBuffer bb = java.nio.ByteBuffer.allocate(blockSize).order(java.nio.ByteOrder.BIG_ENDIAN); - - // [2] frameCode (v0) - bb.putShort((short) (BchBlockEntry.FRAME_CODE_V0 & 0xFFFF)); - - // [32] prevHash32 + bb.putShort((short) BchBlockEntry.FRAME_CODE_V1); bb.put(prevHash32); - - // [4] blockSize (preimage size) bb.putInt(blockSize); - - // [4] blockNumber bb.putInt(blockNumber); - - // [8] timestamp bb.putLong(tsSec); - - // [2][2][2] type/subType/version bb.putShort(type); bb.putShort(subType); bb.putShort(version); - - // [N] bodyBytes if (bodyBytes != null) bb.put(bodyBytes); - return bb.array(); } + } \ No newline at end of file diff --git a/VERSION.properties b/VERSION.properties index 595a0b57..edd0c34e 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.12.0 -server.version=1.10.0 +client.version=1.13.0 +server.version=1.11.0 diff --git a/build.gradle b/build.gradle index 28a0756f..278c8fd6 100644 --- a/build.gradle +++ b/build.gradle @@ -84,6 +84,7 @@ dependencies { implementation project(':shine-server-net-protocol') // Модуль отвечающий за протокол (классы Net..Request/Response implementation project(':shine-server-net-server') // Хэндлеры для обработки сетевых запросов implementation project(':shine-server-solana-users-sync') + implementation project(':shine-server-archive') diff --git a/bundle-offline.sh b/bundle-offline.sh index 2144f43d..07c1b329 100755 --- a/bundle-offline.sh +++ b/bundle-offline.sh @@ -123,6 +123,23 @@ find_offline_gradle_zip() { fi done + local extracted_dir="" + if [[ -d "$HOME/.gradle/wrapper/dists/gradle-8.14-bin" ]]; then + extracted_dir="$(find "$HOME/.gradle/wrapper/dists/gradle-8.14-bin" -type d -name 'gradle-8.14' | sort | head -n 1 || true)" + fi + if [[ -n "$extracted_dir" && -d "$extracted_dir" ]]; then + local generated_zip="$TMP/gradle-offline.zip" + ( + cd "$(dirname -- "$extracted_dir")" + rm -f -- "$generated_zip" + zip -qr "$generated_zip" "gradle-8.14" + ) + if [[ -f "$generated_zip" ]]; then + printf '%s\n' "$generated_zip" + return 0 + fi + fi + return 1 } @@ -269,7 +286,7 @@ if OFFLINE_ZIP_SRC="$(find_offline_gradle_zip)"; then OFFLINE_ZIP_NAME="gradle-offline.zip" else echo "ERROR: offline Gradle zip not found." >&2 - echo "Place it at ./offline/gradle-offline.zip or set BUNDLE_OFFLINE_GRADLE_ZIP." >&2 + echo "Place it at ./offline/gradle-offline.zip, set BUNDLE_OFFLINE_GRADLE_ZIP, or install Gradle 8.14 locally." >&2 echo "The bundle is not created because this variant is meant to be offline-ready." >&2 exit 4 fi diff --git a/bundle.sh b/bundle.sh index e3d1dc27..431aecad 100644 --- a/bundle.sh +++ b/bundle.sh @@ -13,7 +13,13 @@ set -Eeuo pipefail ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT" -OUT="${1:-SHiNE-bundle-$(date +%Y%m%d-%H%M%S).zip}" +DEFAULT_OUT=0 +if [[ $# -gt 0 ]]; then + OUT="$1" +else + DEFAULT_OUT=1 + OUT="SHiNE-bundle-$(date +%Y.%m.%d-%H.%M.%S).zip" +fi case "$OUT" in /*) ;; *) OUT="$ROOT/$OUT" ;; @@ -169,7 +175,11 @@ if [[ ! -s "$SAFE_LIST" ]]; then exit 3 fi -rm -f -- "$OUT" +if (( DEFAULT_OUT == 1 )); then + rm -f -- "$ROOT"/SHiNE-bundle-*.zip +else + rm -f -- "$OUT" +fi ( cd "$ROOT" diff --git a/deploy/README.md b/deploy/README.md index 19bd61ea..77851b69 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -43,6 +43,24 @@ - `scripts/deploy_server.sh` — обновить существующий серверный jar и перезапустить systemd service. - `scripts/deploy_ui.sh` — обновить существующий UI, проверить Caddy root и подставить `deploy-config.js`. +## Временные статические сайты + +Для дизайн-примеров, standalone-viewer'ов и временных тестовых страниц используется папка: + +```text +shine-UI/static-sites/ +``` + +Она деплоится обычным UI deploy вместе со всем `shine-UI`. Если Caddy root указывает на UI-каталог и используется стандартный fallback `try_files {path} /index.html`, отдельный Caddy-route для каждой новой подпапки не нужен. + +Примеры URL после deploy: + +```text +https:///static-sites/ +https:///static-sites/design-examples/channels-v1/ +https:///static-sites/arweave-viewer/ +``` + Production wrappers: - `scripts/production_shineupme_server.sh` 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/04_Add_Block_to_Blockchain_API.md b/docs/API/04_Add_Block_to_Blockchain_API.md index 576662cf..d6fac193 100644 --- a/docs/API/04_Add_Block_to_Blockchain_API.md +++ b/docs/API/04_Add_Block_to_Blockchain_API.md @@ -1,310 +1,49 @@ -# API для разработчиков: 04 — Запись и чтение блока блокчейна +# AddBlock API -Документ описывает **текущий рабочий формат** сетевых вызовов: +## Назначение -- `AddBlock` — запись любого блока в блокчейн пользователя; -- `GetBlockchainBlock` — публичное чтение одного конкретного блока по имени цепочки и номеру. +Добавляет один готовый подписанный пользовательский SHiNE Frame v1 / ANS-104 DataItem в blockchain. -`GetBlockchainBlock` нужен в том числе для межсерверной синхронизации и для открытого чтения публичного блокчейна по одному блоку. - -> Важный принцип: на уровне JSON API сейчас есть **один универсальный метод** записи — `AddBlock`. -> Конкретный смысл записи задаётся типом самого бинарного блока (`type/subType/version` в заголовке блока). - -## 1. Что делает `AddBlock` - -`AddBlock`: -- принимает имя блокчейна и base64 бинарного блока; -- проверяет непрерывность цепочки (`blockNumber`, `prevHash`); -- проверяет формат и подпись Ed25519; -- валидирует `body` по правилам типа блока; -- сохраняет блок и обновляет состояние цепочки. - -## 2. JSON формат запроса - -`op = "AddBlock"`. +## Request ```json { "op": "AddBlock", - "requestId": "req-1001", + "requestId": "...", "payload": { - "blockchainName": "alice-001", - "blockNumber": 12, - "prevBlockHash": "ab12...ff", - "blockBytesB64": "AAAB..." + "blockchainName": "alice-...", + "blockNumber": 42, + "prevBlockHash": "64 hex chars", + "blockBytesB64": "..." } } ``` -Поля `payload`: -- `blockchainName` — обязательно, формат `login-NNN`. -- `blockNumber` — обязательно (временное legacy-поле для совместимости; должно совпасть с номером внутри бинарного блока). -- `prevBlockHash` — legacy-поле, сейчас сервер использует `prevHash` из бинарного блока и состояние цепочки. -- `blockBytesB64` — обязательно: **полный бинарный блок** (`preimage + sigMarker + signature`) в Base64. +- `blockchainName` — целевая пользовательская chain; +- `blockNumber` — должен совпадать с номером внутри Frame v1 и быть `serverLast + 1`; +- `prevBlockHash` — 32-byte SHiNE hash предыдущего Frame в hex; должен совпадать с `prevHash32` внутри Frame и серверной вершиной; +- `blockBytesB64` — **полный serialized ANS-104 DataItem** в Base64. -## 3. Успешный ответ +Старый формат `preimage + sigMarker + signature` не поддерживается. -```json -{ - "op": "AddBlock", - "requestId": "req-1001", - "status": 200, - "ok": true, - "payload": { - "reasonCode": null, - "serverLastGlobalNumber": 12, - "serverLastGlobalHash": "9f0e...a1" - } -} -``` +## Требования к DataItem -## 4. Ошибка (единый формат) +- generic Ed25519 signature type `2`; +- Ed25519 signature 64 bytes; +- owner 32 bytes и равен текущему blockchain public key; +- обязательный тестовый tag `App=test5590`; +- для channel block — `c=`; +- `data` содержит SHiNE Frame v1 (`frameCode=1`). -При ошибках сервер отдаёт `Net_Exception_Response` со стандартными полями и дополнительно с состоянием сервера для ресинка: +## Основные ошибки -```json -{ - "op": "AddBlock", - "requestId": "req-1001", - "status": 400, - "ok": false, - "error": "bad_prev_hash", - "message": "Некорректный prevHash (цепочка не совпадает)", - "payload": { - "serverLastGlobalNumber": 11, - "serverLastGlobalHash": "c3d4...98" - } -} -``` +- `bad_app_tag` — отсутствует/неверен `App=test5590`; +- `bad_channel_tag` — `c` отсутствует, лишний или не совпадает с canonical slug; +- `bad_signature` / `signature_verify_failed` — DataItem не подписан текущим blockchain key; +- `bad_block_number` — нарушена последовательность; +- `bad_prev_hash` — нарушена SHiNE hash chain; +- `bad_block_bytes` — DataItem/Frame не парсится. -### Основные `reasonCode` +## Storage/publish -- `empty_blockchain_name`, `bad_blockchain_name` -- `blockchain_state_not_found` -- `bad_block_base64`, `bad_block_format`, `bad_block_body` -- `bad_block_number`, `req_global_mismatch`, `bad_prev_hash` -- `bad_signature`, `signature_verify_failed` -- `prev_line_block_not_found`, `bad_prev_line_hash` -- `limit_exceeded` -- `chain_resync_in_progress` — цепочка временно заблокирована полным resync -- `repost_disabled` — репосты временно отключены до будущей реализации -- `entrypoint_edit_forbidden` — `TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST` -- `status_confirmed_target_must_be_status_action` — `STATUS_CONFIRMED` должен ссылаться на статусный блок -- `status_action_target_not_allowed` — выбранный `STATUS_ACTION` нельзя ставить на этот тип материала -- `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META` -- `internal_error` - -## 5. Какие блоки реально можно добавлять через `AddBlock` - -Через `AddBlock` можно писать поддержанные форматы, кроме явно отключённых временных фич: - -1. **TECH (type=0)** - - `HEADER_COMPAT (subType=0)` - - `TECH_CREATE_CHANNEL (subType=1)` - -2. **TEXT (type=1)** - - `TEXT_POST (10)` - - `TEXT_EDIT_POST (11)` - - `TEXT_REPLY (20)` - - `TEXT_EDIT_REPLY (21)` - - `TEXT_RATING (30)` — target-based отзыв на конкретный блок - - `TEXT_REPOST (50)` — формат зарезервирован, но новые блоки временно отклоняются с `repost_disabled` - - `TEXT_CHANNEL_META (90)` — скрытый технический снимок профиля канала - - `TEXT_ENTRYPOINT (100)` — входная страница канала - - `TEXT_EXERCISE (110)` — line-based материал упражнения - - `TEXT_SERVICE (120)` — line-based материал услуги / процедуры - - `TEXT_COURSE (130)` — line-based материал курса - -3. **REACTION (type=2)** - - `REACTION_LIKE (1)` - -4. **CONNECTION (type=3)** - - `CONNECTION_FRIEND (10)` - - `CONNECTION_UNFRIEND (11)` - - `CONNECTION_CONTACT (20)` - - `CONNECTION_UNCONTACT (21)` - - `CONNECTION_FOLLOW (30)` - - `CONNECTION_UNFOLLOW (31)` - - `CONNECTION_SPOUSE (40)` - - `CONNECTION_UNSPOUSE (41)` - - `CONNECTION_PARENT (50)` - - `CONNECTION_UNPARENT (51)` - - `CONNECTION_CHILD (52)` - - `CONNECTION_UNCHILD (53)` - - `CONNECTION_SIBLING (54)` - - `CONNECTION_UNSIBLING (55)` - - `CONNECTION_KNOWN_PERSON (60)` - - `CONNECTION_UNKNOWN_PERSON (61)` - - `CONNECTION_SHINE_CONFIRMED (70)` - - `CONNECTION_SHINE_UNCONFIRMED (71)` - - `CONNECTION_SHINE_SEEN (74)` - - `CONNECTION_SHINE_UNSEEN (75)` - -5. **USER_PARAM (type=4)** - - `USER_PARAM_TEXT_TEXT (1)` - -6. **STATUS_ACTION (type=5)** - - `STATUS_DONE_ONCE (10)` - - `STATUS_LEARNED (20)` - - `STATUS_SERVICE_PASSED (30)` - - `STATUS_CONFIRMED (100)` - - `STATUS_INTERESTED (110)` - - `STATUS_STARTED (120)` - - `STATUS_IN_STUDY (130)` - - `STATUS_ABANDONED (140)` - - `STATUS_COMPLETED (150)` - -## 6. Практические payload-форматы для каналов и вложений - -`AddBlock` не имеет отдельных JSON-полей для вложений, аватаров или человекочитаемого имени канала. Клиент собирает бинарный блок нужного типа, а новые данные кладёт в текстовые поля тела блока по правилам blockchain-формата. - -### Вложения в сообщениях - -Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `S:att v=1`. - -Пример текстового содержимого body: - -```text - - -Текст сообщения -``` - -Пример вложения с отдельным preview-файлом: - -```text - -``` - -Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`. - -### Создание публичного канала с профилем - -Для публичного канала начальный профиль пишется одним блоком `TECH_CREATE_CHANNEL`. Поле `channelDescription` содержит meta-текст: - -```text - - -Описание канала -``` - -Ограничение `channelDescription` — до `2048` UTF-8 байт. Новый клиент не пишет отдельный `TEXT_CHANNEL_META` сразу после создания канала: создание канала и начальный профиль должны попадать в один `TECH_CREATE_CHANNEL`. - -### Изменение профиля канала - -Последующие изменения аватара, человекочитаемого имени или описания канала пишутся отдельным скрытым `TEXT_CHANNEL_META (subType=90)`. - -Текстовое содержимое body использует тот же формат полного снимка профиля: - -```text - - -Новое описание канала -``` - -Каждый `TEXT_CHANNEL_META` является полным состоянием профиля на момент записи. Если аватара нет, тег `S:ava` не пишется. Если описания нет, после meta-тегов не добавляется хвостовой текст. Полная спецификация находится в `docs/Blockchain/16_TEXT_Channel_Meta.md`. - -## 7. Хватает ли функций сейчас - -Коротко: **для записи событий в блокчейн — хватает**, для полноценного клиентского чтения — **пока не хватает**. - -Что есть: -- единый надёжный write-путь `AddBlock`; -- есть `GetFriendsLists` и API по `UserParam`; -- есть унифицированные коды ошибок и поля для ресинхронизации. - -Что пока ограничивает продукт: -- нет полноценного read API для каналов/постов/тредов; -- нет API списка подписок с серверными счётчиками непрочитанного; -- нет ленты событий (новые ответы/лайки/подписки) как отдельного RPC. - -## 8. Рекомендации по клиенту при записи блоков - -1. Перед отправкой держать локальный `lastNumber/lastHash`. -2. При `bad_prev_hash` или `bad_block_number`: - - взять `serverLastGlobalNumber/serverLastGlobalHash` из ошибки, - - пересобрать следующий блок на актуальной вершине. -3. Для edit-блоков всегда ссылаться на **оригинальный** блок, а не на предыдущий edit. -4. Для связей/подписок использовать target на **root** (HEADER или CREATE_CHANNEL), а не на произвольный пост. - - -## 9. USER_PARAM для «личных данных» - -Да, на текущем API это можно добавить **без изменения серверного кода**: - -- в `UserParam` поле `param` сейчас не ограничено фиксированным справочником; -- сервер хранит пары `param -> value` как строки (при наличии корректной подписи и `time_ms`); -- чтение уже есть через `GetUserParam` и `ListUserParams`. - -Рекомендуемый стартовый набор ключей для профиля (MVP): - -- `name` -- `last_name` -- `address_physical` -- `address_web` -- `phone` - -Практическая рекомендация: заранее зафиксировать единый словарь ключей в клиенте/документации, чтобы избежать дублей вида `lastname` vs `last_name`, `site` vs `address_web` и т.д. - -Ограничения, которые важно учесть: - -- сейчас нет серверной ACL-политики чтения параметров (в MVP их может читать любой клиент, который знает `login`); -- нет валидации формата значений для конкретных ключей (телефон, URL и т.д. проверяются только на стороне клиента); -- нет отдельного индекса/поиска по этим полям — только точечное чтение и listing по `login`. - ---- - -## 10. `GetBlockchainBlock` - -### Назначение - -Публичное чтение одного конкретного блока из цепочки. - -Нужно для: - -- открытого чтения блокчейна по одному блоку; -- межсерверной синхронизации; -- восстановления/докачки отсутствующего хвоста цепочки. - -### JSON формат запроса - -`op = "GetBlockchainBlock"`. - -```json -{ - "op": "GetBlockchainBlock", - "requestId": "req-2001", - "payload": { - "blockchainName": "alice-001", - "blockNumber": 12 - } -} -``` - -Поля `payload`: - -- `blockchainName` — обязательно, формат `login-NNN`. -- `blockNumber` — обязательно, номер блока в цепочке, `>= 0`. - -### Успешный ответ - -```json -{ - "op": "GetBlockchainBlock", - "requestId": "req-2001", - "status": 200, - "ok": true, - "payload": { - "blockchainName": "alice-001", - "blockNumber": 12, - "blockHash": "9f0eaabbccddeeff00112233445566778899aabbccddeeff0011223344556677", - "blockBytesB64": "AAAB..." - } -} -``` - -### Ошибки - -- `400 / BAD_FIELDS` — некорректные `blockchainName` или `blockNumber`. -- `404 / BLOCK_NOT_FOUND` — такого блока нет. -- `500 / INTERNAL_ERROR` — внутренняя ошибка сервера. +После успешного локального AddBlock полный DataItem хранится в PostgreSQL и ставится в Arweave publish queue. Импорт из Arweave использует ту же проверку, но не ставит блок на повторную публикацию. diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index 62b97634..33da7c92 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -14,19 +14,24 @@ 3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения: предки, фокус-сообщение, потомки. -4. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя. +4. `GetMessageLikes` — отдает списки пользователей, поставивших лайк сообщению, + сгруппированные для UI по статусу профиля. -5. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. +5. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя. -6. `ListGroupChats200` — отдает список групповых чатов типа `200`. +6. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. -7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. +7. `SetChannelReadState` — сохраняет подписанный watermark чтения канала и возвращает новый unread-счетчик. + +8. `ListGroupChats200` — отдает список групповых чатов типа `200`. + +9. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. > На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки. > -> `unreadCount` для канала считается по `user_settings`. +> `unreadCount` для канала считается по подписанному состоянию чтения канала. > Для собственных каналов владельцу всегда возвращается `unreadCount = 0`, чтобы его собственные публикации не становились «новыми» для него самого. -> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным; UI при загрузке списка каналов создаёт baseline на текущем `messagesCount`. После этого новые публикации увеличивают `unreadCount` до продвижения курсора чтения. +> Если для пары `ownerBlockchainName/channelName` ещё нет записи, канал временно считается полностью прочитанным. После появления записи новые публикации увеличивают `unreadCount` до продвижения курсора чтения. --- @@ -107,6 +112,9 @@ "channelRoot": { "blockNumber": 456, "blockHash": "..." } }, "messagesCount": 90, + "readCount": 0, + "unreadCount": 0, + "readStateInitialized": false, "lastMessage": { "messageRef": { "blockNumber": 1002, "blockHash": "..." }, "text": "актуальный текст", @@ -141,6 +149,8 @@ } ``` +`limit` необязателен. Если поле отсутствует или равно `0`, сервер возвращает всю ленту канала. Положительное значение ограничивает количество сообщений для совместимых клиентов. + ### Response (success) ```json { @@ -266,7 +276,63 @@ --- -## 4) GetPersonalDiary +## 4) GetMessageLikes + +Возвращает пользователей, которые поставили лайк конкретному сообщению канала. + +- `message.blockchainName`, `message.blockNumber`, `message.blockHash` должны указывать на исходное сообщение. +- `limit` в текущей реализации не требуется: сервер возвращает полный найденный список лайков. +- Пользователи группируются по состоянию профиля: + - `shining` — `account_role=primary` и `shine_status=shining`; + - `official` — `account_role=primary`, но без `shine_status=shining`; + - `others` — остальные пользователи. + +### Request +```json +{ + "op": "GetMessageLikes", + "requestId": "req-4", + "payload": { + "message": { + "blockchainName": "bob-001", + "blockNumber": 140, + "blockHash": "..." + } + } +} +``` + +### Response (success) +```json +{ + "op": "GetMessageLikes", + "requestId": "req-4", + "status": 200, + "ok": true, + "payload": { + "shining": [ + { "login": "Alice", "firstName": "Alice", "lastName": "", "avatarAr": "ArweaveTxId..." } + ], + "official": [], + "others": [ + { "login": "Carl", "firstName": "", "lastName": "", "avatarAr": "" } + ], + "total": 2, + "truncated": false + } +} +``` + +### Ошибки +- `bad_fields` — не передан `message` или обязательные поля ссылки на сообщение. +- `bad_hash` — `message.blockHash` не является корректным hex-хэшем блока. +- `internal_error` — внутренняя ошибка чтения. + +`truncated` сейчас всегда `false`; поле оставлено в ответе для совместимости с UI и возможной будущей пагинацией. + +--- + +## 5) GetPersonalDiary Возвращает виртуальный канал `Личный дневник` для самого пользователя. @@ -289,7 +355,7 @@ --- -## 4) GetChannelsCounters +## 6) GetChannelsCounters ### Request ```json @@ -321,7 +387,69 @@ --- -## 5) ListGroupChats200 +## 7) SetChannelReadState + +Сохраняет подписанную позицию чтения для одного канала, на который пользователь подписан. + +- Требует авторизованное WebSocket-соединение. +- `login` должен совпадать с текущим авторизованным пользователем. +- Подпись строится client key пользователя по строке: + +```text +SHiNe/ChannelReadState:|||| +``` + +### Request +```json +{ + "op": "SetChannelReadState", + "requestId": "req-7", + "payload": { + "login": "Alice", + "owner_bch_name": "bob-001", + "channel_name": "news", + "read_count": 90, + "time_ms": 1760000000000, + "client_key": "", + "signature": "" + } +} +``` + +### Response (success) +```json +{ + "op": "SetChannelReadState", + "requestId": "req-7", + "status": 200, + "ok": true, + "payload": { + "login": "Alice", + "owner_bch_name": "bob-001", + "channel_name": "news", + "read_count": 90, + "unread_count": 0, + "time_ms": 1760000000000, + "applied": true + } +} +``` + +### Ошибки +- `NOT_AUTHENTICATED` — нет авторизованной сессии. +- `BAD_FIELDS` — не переданы обязательные поля. +- `LOGIN_MISMATCH` — `login` не совпадает с текущей сессией. +- `BAD_TIME` — время подписи слишком далеко в будущем. +- `BAD_BASE64` — `client_key` или `signature` не являются корректным Base64. +- `DEVICE_KEY_MISMATCH` — `client_key` не совпадает с текущим client key пользователя. +- `INVALID_SIGNATURE` — подпись watermark не прошла проверку. +- `CHANNEL_NOT_FOLLOWED` — пользователь не подписан на канал. +- `CHANNEL_NOT_FOUND` — канал не найден. +- `INTERNAL_ERROR` — внутренняя ошибка записи. + +--- + +## 8) ListGroupChats200 ### Request ```json @@ -361,7 +489,7 @@ --- -## 6) GetGroupDialog +## 9) GetGroupDialog ### Request ```json @@ -414,8 +542,10 @@ - `user_not_found` - `channel_not_found` - `message_not_found` -- `limit_too_large` +- `bad_limit` - `channel_name_already_exists` +- `CHANNEL_NOT_FOLLOWED` +- `CHANNEL_NOT_FOUND` - `internal_error` --- diff --git a/docs/API/07_Channels_Feature_Runbook.md b/docs/API/07_Channels_Feature_Runbook.md index e60af339..235bf663 100644 --- a/docs/API/07_Channels_Feature_Runbook.md +++ b/docs/API/07_Channels_Feature_Runbook.md @@ -8,6 +8,8 @@ - `ListSubscriptionsFeed` — экран списка каналов. - `GetChannelMessages` — сообщения конкретного канала. - `GetMessageThread` — дерево обсуждения для сообщения. + - `GetMessageLikes` — списки пользователей, поставивших лайк сообщению. + - `SetChannelReadState` — подписанный watermark чтения канала. 2. **UI вкладки Каналы**: - при открытии пытается загрузить реальный feed с сервера; @@ -33,12 +35,16 @@ 1. Вызвать `ListSubscriptionsFeed`. 2. Для канала `ownedChannels[0]` вызвать `GetChannelMessages`. 3. Для первого `messages[0]` вызвать `GetMessageThread`. +4. Для первого `messages[0]` вызвать `GetMessageLikes`. +5. Для подписанного канала вызвать `SetChannelReadState` с текущим `messagesCount`. ### Ошибки 1. `ListSubscriptionsFeed` с пустым login -> `bad_fields`. 2. `GetChannelMessages` с битым channel payload -> `bad_fields`. 3. `GetMessageThread` с несуществующим block -> `message_not_found`. -4. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`. +4. `GetMessageLikes` с битым `message.blockHash` -> `bad_hash`. +5. `SetChannelReadState` без активной подписки -> `CHANNEL_NOT_FOLLOWED`. +6. `AddBlock(CreateChannel)` с уже существующим именем -> `channel_name_already_exists`. --- @@ -91,6 +97,38 @@ } ``` +## 3.4 GetMessageLikes +```json +{ + "op": "GetMessageLikes", + "requestId": "debug-likes-1", + "payload": { + "message": { + "blockchainName": "TestUser1-001", + "blockNumber": 123, + "blockHash": "" + } + } +} +``` + +## 3.5 SetChannelReadState +```json +{ + "op": "SetChannelReadState", + "requestId": "debug-read-state-1", + "payload": { + "login": "TestUser1", + "owner_bch_name": "TestUser2-001", + "channel_name": "news", + "read_count": 25, + "time_ms": 1760000000000, + "client_key": "", + "signature": "" + } +} +``` + --- ## 4) Что смотреть в ответах @@ -101,6 +139,8 @@ - у каждой записи есть: - `channel.channelRoot.blockNumber`, - `messagesCount`, + - `unreadCount`, + - `readStateInitialized`, - `lastMessage` (может быть null, если сообщений нет). ### GetChannelMessages @@ -115,6 +155,16 @@ - у узлов должны быть версии и счетчики. - у каждого узла дополнительно может приходить `rawBlockB64` (Base64 сырого `block_bytes`). +### GetMessageLikes +- `payload.shining[]`, `payload.official[]`, `payload.others[]` — группы пользователей. +- у каждого пользователя есть `login`, `firstName`, `lastName`, `avatarAr`. +- `payload.truncated` сейчас всегда `false`; поле оставлено для совместимости и будущей пагинации. + +### SetChannelReadState +- `payload.read_count` — сохраненная позиция чтения. +- `payload.unread_count` — пересчитанный unread для канала. +- `payload.applied=false` означает, что на сервере уже была более новая signed-позиция. + ### Важно по совместимости - `rawBlockB64` добавлен только в `GetMessageThread`. - `GetChannelMessages` не содержит `rawBlockB64` (без изменений формата ленты). diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index b961bcfc..07553d0b 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -14,8 +14,6 @@ | --- | --- | --- | | `GetUser` | `01_User_Registration_API.md` | чтение/проверка пользователя + server-состояние его блокчейна | | `SearchUsers` | `01_User_Registration_API.md` | поиск логинов по префиксу | -| `TestGetFreeAvatarQuota` | `14_Test_Free_Avatar_Upload_API.md` | временный тестовый просмотр остатка бесплатных загрузок аватара | -| `TestUploadFreeAvatar` | `14_Test_Free_Avatar_Upload_API.md` | временная тестовая бесплатная загрузка маленького аватара в Arweave | | `ResolveLoginForAuth` | `02_Authentication_API.md` | проверка login перед входом: LOCAL / REMOTE / NOT_FOUND / NO_ACCESS_SERVER + URL правильного access server | | `AuthChallenge` | `02_Authentication_API.md` | challenge для создания новой сессии | | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | @@ -46,8 +44,10 @@ | `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок | | `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала | | `GetMessageThread` | `06_Channels_Read_API.md` | тред сообщения | +| `GetMessageLikes` | `06_Channels_Read_API.md` | списки пользователей, поставивших лайк сообщению | | `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION | | `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов | +| `SetChannelReadState` | `06_Channels_Read_API.md` | подписанный watermark чтения канала | | `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` | | `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` | | `UpsertUserParam` | `10_User_Params_API.md` | запись параметра пользователя | @@ -80,6 +80,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/17_User_Counters_API.md b/docs/API/17_User_Counters_API.md new file mode 100644 index 00000000..8ef373ea --- /dev/null +++ b/docs/API/17_User_Counters_API.md @@ -0,0 +1,49 @@ +# User counters API + +## GetUserCounters +Authenticated lightweight WebSocket request used by the bottom toolbar. + +Request: +```json +{"op":"GetUserCounters","requestId":"...","payload":{}} +``` + +Success payload: +```json +{ + "login": "alice", + "dmUnreadCount": 7, + "channelsUnreadCount": 12, + "notificationsUnreadCount": 5, + "notificationRepliesUnreadCount": 2, + "notificationConnectionsUnreadCount": 1, + "notificationEventsUnreadCount": 2 +} +``` + +The fields form a stable UI contract. Their server-side calculation is intentionally replaceable by materialized counters/cache later. + +## UserCountersChanged +Server push event for every active session of the user. It lets the toolbar update without polling. + +```json +{ + "op":"UserCountersChanged", + "event":true, + "payload":{ + "login":"alice", + "dmUnreadCount":7, + "channelsUnreadCount":12, + "notificationsUnreadCount":5, + "notifications":{"replies":2,"connections":1,"events":2} + } +} +``` + +Current implementation emits this event after channel read-state writes, notification seen-state changes and applied incoming DM/read-receipt blocks. Future materialized counter logic can emit the same event immediately on any counter increment/decrement. + +## Channel read behaviour +- Opening a subscribed channel does **not** mark all messages read. +- The UI advances readCount only when message cards cross the viewport read threshold while scrolling. +- Writes are debounced; failed writes are shown to the user and retried. +- Immediately after a successful subscription from inside an open channel, the client stores `readCount = current messagesCount`, because those existing messages are treated as already viewed at subscription time. 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..0e4070ef --- /dev/null +++ b/docs/API/18_DM_File_Storage_API.md @@ -0,0 +1,113 @@ +# 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-объекта. Для legacy DM file v1 это фактически ограничивало целый файл. В DM file v2 файл состоит из 1-MiB ciphertext-объектов, поэтому общий размер логического файла этим параметром не ограничивается. + +## 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 объекта идемпотентна. Перед ответом `alreadyExists=true` сервер повторно вычисляет `SHA-256` уже сохранённого объекта и сверяет его с `fileId`; одного совпадения имени файла на диске недостаточно. Если объект повреждён или подменён, сервер удаляет некорректную копию и принимает корректный `PUT` заново. + +Успешный ответ: + +```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 без тела только после повторной проверки `Base58(SHA-256(ciphertext)) == fileId`; официальный UI использует этот запрос перед `PUT`, чтобы не отправлять уже существующие байты повторно; +- `OPTIONS /dm-files/*` обслуживает CORS preflight для браузерного PUT. + +## 6. Reverse proxy + +Caddy/Nginx должен проксировать `/dm-files/*` в тот же Jetty, что обслуживает `/ws`. Route должен находиться до SPA fallback. + +## 7. Ограничения legacy v1 + +- legacy-файл перед загрузкой целиком читается в память браузера и ограничен 50 MiB; +- новые отправки официального UI используют v2 и этого ограничения не имеют; +- удаления/TTL/garbage collection пока нет; +- если ciphertext уже загружен, а отправка E2EE DM затем не удалась, объект может остаться orphan-файлом; +- resumable/chunked upload не входит в v1. + +## 8. DM file v2: большие файлы + +Начиная с UI v2 один логический файл не загружается одним HTTP-объектом. Клиент режет plaintext на `1 MiB` части и каждый AES-GCM ciphertext-кусок загружает отдельным обычным `PUT /dm-files/{fileId}`. + +Поэтому `dm.files.maxBytes` — это лимит **одного immutable HTTP-объекта**, а не всего пользовательского файла. При стандартном `chunkSize=1 MiB` общий размер файла этим параметром не ограничивается. + +Манифест также хранится через тот же immutable API: + +- encrypted manifest page — до 256 chunk descriptors; +- encrypted root manifest — ссылки на страницы + BitTorrent v2 root metadata. + +Никаких новых доверенных серверных операций для v2 не требуется: сервер по-прежнему только проверяет подпись PUT, длину и `Base58(SHA-256(ciphertext))`. diff --git a/docs/Blockchain/01_Common_Block_Format.md b/docs/Blockchain/01_Common_Block_Format.md index a9fc5644..28a51ce2 100644 --- a/docs/Blockchain/01_Common_Block_Format.md +++ b/docs/Blockchain/01_Common_Block_Format.md @@ -1,49 +1,116 @@ -# Общий формат добавляемого блока (Frame v0) +# Общий формат пользовательского блока SHiNE — Frame v1 / ANS-104 -Этот файл описывает **единый бинарный формат** блока, который клиент отправляет через `AddBlock` в поле `blockBytesB64`. +Актуальный формат не поддерживает старый Frame v0. Новый пользовательский блок сразу создаётся как готовый подписанный ANS-104 DataItem. -## 1. Полная структура блока +## 1. Два уровня формата -Блок состоит из двух частей: +Полный объект, который клиент отправляет в `AddBlock`, хранится в PostgreSQL и затем архивируется в Arweave: -1. **PREIMAGE** (подписывается) -2. **TAIL** (маркер подписи + подпись) +```text +ANS-104 DataItem + signature_type = 2 (generic Ed25519) + signature = 64 bytes + owner = 32-byte blockchain public key + target = absent + anchor = absent + tags + data = SHiNE Frame v1 +``` -### PREIMAGE +Цепочка SHiNE **не зависит от Arweave**. Arweave DataItem ID хранится отдельно и используется для дедупликации/поиска, но не является `prevHash`. -- `frameCode (uint16)` -- `prevHash32 (32 bytes)` -- `blockSize (int32)` — размер PREIMAGE -- `blockNumber (int32)` -- `timestamp (int64)` -- `type (uint16)` -- `subType (uint16)` -- `version (uint16)` -- `bodyBytes (N)` +## 2. SHiNE Frame v1 -### TAIL +Все целые поля Frame v1 — BigEndian. -- `sigMarker (uint16)` -- `signature64 (64 bytes, Ed25519)` +| Поле | Размер | Описание | +|---|---:|---| +| `frameCode` | 2 | `0x0001` | +| `prevHash32` | 32 | SHA-256 полного Frame v1 предыдущего SHiNE-блока; для блока 0 — нули | +| `blockSize` | 4 | точный размер Frame v1, включая header и body | +| `blockNumber` | 4 | номер блока, начиная с 0 | +| `timestamp` | 8 | Unix time seconds | +| `type` | 2 | тип сообщения | +| `subType` | 2 | подтип | +| `version` | 2 | версия body | +| `body` | N | данные конкретного типа | -## 2. Что проверяет сервер при AddBlock +`FRAME_HEADER_SIZE = 56` bytes. -- `frameCode` должен быть `0x0000`. -- `sigMarker` должен быть `0x0100`. -- `blockNumber` должен идти строго по порядку (`last + 1`). -- `prevHash32` должен совпасть с вершиной цепочки на сервере. -- `body` должен пройти `check()` для конкретного типа. -- подпись должна валидироваться публичным ключом блокчейна. +```text +blockHash32 = SHA256(FrameV1Bytes) +next.prevHash32 = blockHash32 +``` -## 3. Ограничения +Подписи внутри Frame v1 нет. Единственная подпись пользователя — подпись окружающего ANS-104 DataItem. -- максимальный полный размер блока: до 4 MiB; -- timestamp не должен сильно уходить в будущее; -- `bodyBytes` парсится по `type/subType/version` из заголовка блока. +## 3. ANS-104 DataItem -## 4. Почему это важно +Для тестового контура используется generic Ed25519 signature type `2`: подпись 64 bytes, owner 32 bytes. Это отдельный generic Ed25519 signer type; Solana-specific signer в текущем Turbo/arbundles имеет другой type. -Одинаковый общий формат позволяет: -- передавать разные виды записей через один RPC `AddBlock`; -- валидировать блоки единообразно; -- расширять типы `body`, не ломая каркас блока. +Подписывается стандартный ANS-104 deep-hash: + +```text +[ + "dataitem", + "1", + "2", + owner, + target(empty), + anchor(empty), + rawAvroTags, + FrameV1Bytes +] +``` + +`rawAvroTags` — ровно те Avro-serialized bytes тегов, которые лежат внутри DataItem. + +`dataItemId32 = SHA256(signature64)`. + +### Обязательный тестовый тег + +Каждый блок: + +```text +App = test5590 +``` + +Это временное namespace-значение для разработки. Перед реальным запуском оно будет заменено отдельным изменением протокола/кода. + +### Канальный тег + +Если блок относится к конкретному каналу, он дополнительно содержит: + +```text +c_test5590 = +``` + +Slug входит в подпись DataItem и не может быть изменён сервером после подписи. + +## 4. Что хранится в PostgreSQL + +`blocks.block_bytes` содержит **полный serialized ANS-104 DataItem**, а не только Frame. + +Отдельно индексируются: + +- `block_hash` — SHA-256(Frame v1); +- `block_signature` — 64-byte Ed25519 signature из DataItem; +- `data_item_id` — SHA-256(signature), UNIQUE; +- `block_number`, `bch_name`, message fields; +- состояние публикации в Arweave. + +Локальные `.bch`, `.tmp_bch` и marker-файлы для пользовательских blockchain больше не используются. + +## 5. Проверка AddBlock + +Сервер обязан: + +1. распарсить полный ANS-104 DataItem; +2. проверить `App=test5590`; +3. проверить `c_test5590`, если тип блока требует канал; +4. проверить ANS-104 Ed25519 подпись; +5. проверить, что `owner` равен текущему blockchain public key пользователя; +6. распарсить Frame v1 и body; +7. проверить `blockNumber == last + 1`; +8. проверить `prevHash32 == lastBlockHash`; +9. записать DataItem и новое состояние атомарно в PostgreSQL. diff --git a/docs/Blockchain/17_ANS104_Arweave_Transport.md b/docs/Blockchain/17_ANS104_Arweave_Transport.md new file mode 100644 index 00000000..259b1ea1 --- /dev/null +++ b/docs/Blockchain/17_ANS104_Arweave_Transport.md @@ -0,0 +1,156 @@ +# ANS-104 / Arweave transport для пользовательских блоков SHiNE + +## Цель + +Каждый пользовательский блок SHiNE уже на клиенте является самостоятельным подписанным ANS-104 DataItem. Сервер проверяет и хранит **точно эти signed bytes** и может публиковать их одним из двух транспортов: через Turbo по одному DataItem либо через прямую Arweave L1-транзакцию в составе стандартного большого ANS-104 bundle. + +Способ публикации — локальная политика конкретного сервера. Формат пользовательского блока и импорт от него не зависят. + +## User DataItem tags + +Для тестового контура обязательно: + +```text +App=test5590 +``` + +Для блоков конкретного канала дополнительно: + +```text +c_test5590= +``` + +Теги входят в ANS-104 подпись пользователя. Старый тестовый тег `c` новым кодом не создаётся и не принимается как channel tag. + +## Publisher modes + +Настройка: + +```text +arweave.blocks.publish.mode=turbo | arweave | none +``` + +### `turbo` + +```text +blocks.arweave_publish_pending=true + ↓ +готовый signed user DataItem из blocks.block_bytes + ↓ +POST в Turbo как application/octet-stream + ↓ +Turbo bundling / Arweave +``` + +DataItem **не переподписывается** сервером. Его `data_item_id = SHA-256(user signature)` до и после загрузки должен оставаться тем же. + +Для Turbo можно задать публичный payer address напрямую: + +```text +arweave.blocks.publish.turbo.paidByAddress=... +``` + +либо путь к серверному Arweave JWK: + +```text +arweave.blocks.publish.turbo.walletJwkPath=/path/to/server-turbo-wallet.json +``` + +Из JWK локально вычисляется только публичный Arweave address для `x-paid-by`; приватный ключ Turbo upload endpoint не получает. + +Важно: если upload уже требует оплаты, а signed DataItem принадлежит другому signer, Turbo Credits серверного кошелька используются через Credit Share Approval в пользу signer-адреса. Для маленьких DataItem, попадающих под действующий free tier Turbo, payer может не понадобиться. Код не должен рассчитывать на вечное существование free tier: HTTP `402` считается ошибкой оплаты и блок остаётся pending. + +### `arweave` + +Сохраняется прежний fallback: + +```text +pending user DataItems + ↓ +standard ANS-104 binary bundle + ↓ +server Arweave RSA/JWK signature + ↓ +Arweave L1 +``` + +Root transaction содержит только стандартные bundle tags: + +```text +Bundle-Format=binary +Bundle-Version=2.0.0 +Content-Type=application/octet-stream +``` + +Специальный `App=test5590-batch` больше не используется. Важны вложенные user DataItems, у которых уже есть `App=test5590`. + +### `none` + +Сервер принимает и хранит блоки локально, но publisher не отправляет их в Arweave/Turbo. Importer при этом может работать независимо. + +## Состояние публикации в БД + +После успешной публикации: + +- `arweave_publish_pending=false`; +- заполняется `arweave_published_at_ms`. + +`arweave_root_tx_id` больше не хранится: один и тот же пользовательский DataItem может быть физически упакован разными bundler-ами, а стабильным сетевым идентификатором SHiNE является именно `data_item_id`. + +## Importer: только individual DataItems + +Importer всегда выполняет один discovery-запрос: + +```text +App=test5590 +``` + +Он **не ищет root bundles** и не зависит от `publish.mode`. + +Это одинаково работает для: + +- DataItem, отправленного через Turbo; +- DataItem, находящегося внутри большого direct-Arweave ANS-104 bundle сервера. + +После того как AR.IO gateway распаковал/indexed bundle, child DataItem присутствует в GraphQL как отдельная сущность со своим `id` и собственными tags. + +### Получение полного signed DataItem + +Обычная выдача DataItem по gateway URL может представлять только payload, а SHiNE для криптографической проверки нужны полные serialized ANS-104 bytes. + +Поэтому importer: + +1. получает `data_item_id` через GraphQL `App=test5590`; +2. запрашивает `GET /ar-io/offsets/{data_item_id}`; +3. получает `rootTxId`, `rootOffset`, `size`; +4. делает range-read `GET /raw/{rootTxId}` ровно по этому диапазону; +5. разбирает полученные bytes как `Ans104DataItem`; +6. проверяет, что `SHA-256(signature) == data_item_id`; +7. проверяет Ed25519 ANS-104 signature; +8. определяет пользователя по `owner`; +9. импортирует через обычную логику `AddBlock` без повторной публикации. + +Если GraphQL уже увидел DataItem, но gateway ещё не подготовил offsets, checkpoint не продвигается за этот height и DataItem будет повторён в следующем цикле. + +## Очередь и порядок блоков + +`arweave_block_import_queue` хранит: + +- `data_item_id`; +- `block_height`; +- полный `raw_data_item`; +- status/error/timestamps. + +`root_tx_id` очереди больше не нужен. + +Если block N+1 увиден раньше N, он остаётся `PENDING`; после появления предыдущего блока очередь повторно проигрывается. + +## Дедупликация + +`blocks.data_item_id` уникален. Один signed DataItem остаётся одним логическим SHiNE-блоком независимо от того, сколько серверов или bundler-ов физически включили его в Arweave. + +Импортированный блок записывается через `AddBlock` с отключённой повторной публикацией, поэтому серверы не создают цикл переархивирования. + +## Локальное хранение + +Полный serialized signed DataItem хранится в `blocks.block_bytes` PostgreSQL. Пользовательские `.bch`-файлы не являются источником истины. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index 9f1f6577..bc247cb5 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,20 @@ # История изменений документации блокчейна +## 2026-09-23 — Turbo transport для individual ANS-104 DataItems +- Базовый коммит-ориентир: `3483a0a`; изменения подготовлены как patch без нового git-коммита. +- Publisher получил режимы `turbo | arweave | none`: Turbo отправляет каждый исходный user-signed DataItem отдельно, direct Arweave fallback сохраняет standard ANS-104 bundle, `none` отключает внешнюю публикацию. +- Удалён технический namespace `App=test5590-batch`: importer всегда ищет только individual `App=test5590` DataItems независимо от способа их физической упаковки. +- Channel tag тестового контура изменён с `c` на `c_test5590`; новый тег является частью пользовательской ANS-104 подписи. +- Importer получает точные serialized signed DataItem bytes через AR.IO offsets + range-read root transaction и проверяет `data_item_id`/Ed25519 signature перед `AddBlock`. +- Из PostgreSQL удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`; добавлена migration v25. + +## 2026-09-23 — Тестовые каналы и Arweave-only синхронизация +- Базовый коммит-ориентир: `3483a0a`. +- Добавлены тестовые каналы и publisher для генерации пользовательских POST-блоков через обычный `AddBlock`. +- Добавлена возможность отключать прямую межсерверную синхронизацию блоков настройкой `blockchain.sync.enabled=false`. +- Arweave-импорт переведён на root ANS-104 bundle discovery: сервер находит batch-транзакции `App=test5590-batch`, разбирает вложенные signed DataItem и импортирует SHiNE-блоки с дедупликацией по `data_item_id`. +- Проверен тестовый сценарий Arweave-only: t3 поднял блоки из Arweave без прямой межсерверной синхронизации с t2. + ## 2026-08-25 19:45:18 +0400 - Базовый коммит-ориентир: `3a58519`. - Добавлены runtime-агрегаты статистики: @@ -236,3 +251,16 @@ - Добавлена поддержка командного префикса `/.` и команды `/.desc` для актуализации описания канала при чтении. - Зафиксированы команды `/.add` и `/.remove` для каналов `type=200` (зарезервировано под расширение участниками). - В `AGENTS.md` добавлено обязательное правило актуализации документации в `docs/Blockchain/`. + +## 2026-09-20 — Frame v1 + ANS-104 DataItem / Arweave per-block transport + +- Основание: пользователь явно подтвердил breaking change и запуск с чистой историей; patch archive, commit hash ещё отсутствует. +- Удалена поддержка старого Frame v0 (`preimage + sigMarker + signature`). +- Новый пользовательский block — SHiNE Frame v1 внутри стандартного ANS-104 DataItem, generic Ed25519 signature type 2. +- Signing message синхронизирован с фактическим arbundles/Turbo DataItem: включает строку signature type и raw Avro tag bytes. +- SHiNE chain hash остаётся независимым: `SHA256(FrameV1Bytes)`; Arweave DataItem ID хранится отдельно. +- В тестовом контуре каждый DataItem подписывает tag `App=test5590`; channel blocks дополнительно `c=`. +- PostgreSQL стал единственным локальным хранилищем пользовательских block bytes; `.bch/.tmp_bch/marker` storage/recovery удалён. +- Старый custom `SHINE-ARCHIVE` pipeline удалён. Добавлены standard ANS-104 bundle publisher через существующий Arweave L1 uploader и GraphQL importer. +- Импортированные из Arweave blocks не публикуются повторно; дедупликация идёт по `data_item_id`. +- ESP32 и связанный device code этим изменением не затронуты. diff --git a/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md b/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md new file mode 100644 index 00000000..791f37df --- /dev/null +++ b/docs/Blockchain/CODEX_APPLY_ANS104_TEST5590_PATCH.md @@ -0,0 +1,79 @@ +# Применение patch: Turbo + direct Arweave для `App=test5590` + +## Что меняется + +- `arweave.blocks.publish.mode=turbo|arweave|none` вместо boolean publisher switch. +- `turbo`: каждый готовый user-signed ANS-104 DataItem отправляется в Turbo отдельно. +- `arweave`: сохранён прямой L1 fallback — несколько user DataItems собираются в standard ANS-104 bundle. +- `none`: наружу ничего не публикуется. +- Importer всегда ищет только individual `App=test5590` DataItems. +- `App=test5590-batch` больше не используется. +- Channel tag: `c_test5590=` вместо `c=...`. +- Удалены `blocks.arweave_root_tx_id` и `arweave_block_import_queue.root_tx_id`. +- Схема PostgreSQL: v25. + +## Минимальная настройка Turbo + +```properties +arweave.blocks.publish.mode=turbo +arweave.blocks.publish.turbo.uploadUrl=https://turbo.ardrive.io/tx +``` + +Для действующего free tier маленьких DataItem этого может быть достаточно. + +Если upload платный и расходы должны идти с server Turbo Credits: + +```properties +arweave.blocks.publish.turbo.walletJwkPath=/home/player/SHiNE/secrets/turbo-wallet.json +# либо вместо JWK сразу публичный адрес: +# arweave.blocks.publish.turbo.paidByAddress= +``` + +JWK не отправляется Turbo: из него вычисляется публичный address для `x-paid-by`. +Для чужого signed DataItem платные Turbo Credits требуют действующего Credit Share Approval от server payer к signer-адресу DataItem. Если его нет, Turbo вернёт HTTP 402, а блок останется pending для повторной попытки. + +## Direct Arweave fallback + +```properties +arweave.blocks.publish.mode=arweave +arweave.blocks.publish.walletJwkPath=/home/player/SHiNE/secrets/arweave-wallet.json +arweave.blocks.publish.gateway=https://arweave.net +``` + +Root bundle больше не получает `App=test5590-batch`; child DataItems уже содержат `App=test5590` и именно их индексирует importer. + +## Отключение публикации + +```properties +arweave.blocks.publish.mode=none +``` + +Это не отключает `arweave.blocks.sync.enabled`: read/import и publish независимы. + +## Importer + +```properties +arweave.blocks.sync.enabled=true +arweave.blocks.sync.gateway=https://turbo-gateway.com +arweave.blocks.sync.maxDataItemBytes=8388608 +``` + +Importer: + +1. GraphQL `App=test5590`; +2. `/ar-io/offsets/`; +3. range `GET /raw/`; +4. проверка exact signed DataItem ID + signature; +5. обычный `AddBlock` import. + +## Миграция БД + +При старте schema v24 автоматически применит `migration_v25.sql`, которая удаляет два root-tx поля и ставит version 25. + +## Проверка после применения + +1. Создать новый channel/post и проверить signed tag `c_test5590=`. +2. В `mode=turbo` убедиться, что `blocks.data_item_id` совпадает с Turbo response `id` и pending становится false. +3. На втором сервере включить sync и убедиться, что DataItem находится GraphQL-запросом `App=test5590` и импортируется без прямой server-to-server связи. +4. Переключить первый сервер в `mode=arweave`, создать ещё несколько блоков и убедиться, что тот же importer второго сервера видит child DataItems без знания root bundle ID. +5. Проверить `mode=none`: новые локальные блоки остаются pending, наружу ничего не отправляется. diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index d00ba6c2..30928277 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -1,47 +1,41 @@ -# Документация блокчейна SHiNE (MVP) +# SHiNE Blockchain -Этот каталог описывает только текущий рабочий формат протокола для MVP. +Актуальная версия пользовательского blockchain использует **SHiNE Frame v1 внутри подписанного ANS-104 DataItem**. Старый Frame v0 и файловое хранение `.bch` не поддерживаются. ## Основные документы -1. [01_Common_Block_Format.md](./01_Common_Block_Format.md) - Единый бинарный формат блока (Frame v0), подпись, базовые проверки. -2. [02_Blockchain_Kinds_and_Lines.md](./02_Blockchain_Kinds_and_Lines.md) - Виды цепочек и правила line-полей. -3. [10_TECH_Blocks.md](./10_TECH_Blocks.md) - Системные блоки (`msg_type=0`). -4. [11_TEXT_Blocks.md](./11_TEXT_Blocks.md) - Текстовые блоки (`msg_type=1`). -5. [12_REACTION_Blocks.md](./12_REACTION_Blocks.md) - Реакции (`msg_type=2`). -6. [13_CONNECTION_Blocks.md](./13_CONNECTION_Blocks.md) - Социальные связи (`msg_type=3`). -7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md) - Параметры пользователя (`msg_type=4`). -8. [15_STATUS_ACTION_Blocks.md](./15_STATUS_ACTION_Blocks.md) - Статусные действия пользователя (`msg_type=5`). -9. [16_TEXT_Attachments.md](./16_TEXT_Attachments.md) - Вложения в TEXT-сообщениях через `S:att v=1`, включая опциональные `preAr/preSha256` для видео и крупных изображений. -10. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md) - Скрытый `TEXT_CHANNEL_META` для профиля канала. -11. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md) - Типы каналов и формат `CreateChannelBody`. -12. [02_Channel_Commands.md](./02_Channel_Commands.md) - Команды в текстовых сообщениях каналов. -13. [CHANGELOG.md](./CHANGELOG.md) - Журнал изменений документации. -## Смежная документация -- [../ИТХ/README.md](../ИТХ/README.md) — ежедневное закрытие блокчейна (ИТХ): краткий обзор. -- [../ИТХ/Спецификация_ИТХ_v1.md](../ИТХ/Спецификация_ИТХ_v1.md) — точная спецификация чекпоинтов (Arweave/Solana/канал закрытий). -- [sync-between-servers.md](./sync-between-servers.md) — живая межсерверная синхронизация блокчейна и доставка DM на единственный сервер получателя. +- [`01_Common_Block_Format.md`](01_Common_Block_Format.md) — точный Frame v1, ANS-104, подпись, хэши и теги. +- [`17_ANS104_Arweave_Transport.md`](17_ANS104_Arweave_Transport.md) — публикация блоков в Arweave и обратный импорт между серверами. +- [`sync-between-servers.md`](sync-between-servers.md) — межсерверная синхронизация и full-resync без файловой копии blockchain. +- [`CHANGELOG.md`](CHANGELOG.md) — история изменений протокола. -## Важные ограничения MVP -- Каналы `type=100` и `type=200` присутствуют в формате, но сейчас не используются в UI. -- Поддерживаемый рабочий сценарий UI на текущем этапе: `stories (type=0)` и `public (type=1)`. +## Короткая схема -## Обязательное сопровождение -- При любом изменении формата/правил блокчейна в коде документы этого каталога обновляются в том же наборе изменений. -- Обычный `AddBlock` сейчас пишет через `.tmp_bch`, `.write_check` и `.write_pending`; эта схема и `BlockchainTmpRecoveryOnStartup` должны быть описаны в актуальной документации по синхронизации и recovery. -- Для runtime-агрегатов статистики `user_stats_state` и `channel_stats_state` действует тот же принцип derived state: они обновляются вместе с `AddBlock` и полностью пересобираются при full resync. -- Если в старых данных есть канал владельца, которого ещё нет в `solana_user_pda_current`, сервер не падает: `channel_stats_state` всё равно обновляется, а `user_stats_state` создаётся только после появления пользователя в Solana PDA. -- Каждое обновление документов фиксируется в `CHANGELOG.md` с датой/временем и хэшем коммита-основания. +```text +User + -> создаёт Frame v1 + -> tags: App=test5590, при канале c_test5590= + -> Ed25519 подписывает ANS-104 deep-hash + -> готовый DataItem + -> AddBlock + +Server + -> verify DataItem + SHiNE chain + -> PostgreSQL + -> publish.mode=turbo: каждый signed DataItem через Turbo + ИЛИ publish.mode=arweave: большой standard ANS-104 bundle -> Arweave L1 + ИЛИ publish.mode=none: наружу не публиковать + +Other servers + -> GraphQL App=test5590 + -> скачивают/извлекают DataItem + -> verify + -> PostgreSQL без повторной публикации +``` + +## Важные свойства + +- SHiNE `prevHash` остаётся SHA-256 предыдущего Frame v1 и не зависит от Arweave. +- `data_item_id` нужен для Arweave, поиска и дедупликации. +- PostgreSQL — единственное локальное хранилище пользовательских блоков. +- Тестовый namespace `App=test5590` специально отделён от будущего production namespace. diff --git a/docs/Blockchain/sync-between-servers.md b/docs/Blockchain/sync-between-servers.md index c839e2b7..4d5d9849 100644 --- a/docs/Blockchain/sync-between-servers.md +++ b/docs/Blockchain/sync-between-servers.md @@ -1,280 +1,56 @@ -# Синхронизация блокчейнов и доставка DM между серверами SHiNE +# Синхронизация blockchain между SHiNE-серверами -Документ описывает архитектуру и протокол синхронизации данных между партнёрскими серверами SHiNE. +## Локальная модель хранения -## 1. Зачем нужна синхронизация +PostgreSQL является единственным локальным хранилищем пользовательских блоков. Файлы `.bch`, `.tmp_bch`, `.write_pending`, `.write_check`, `.resync_pending` не используются. -Пользователи SHiNE могут быть «приписаны» к разным серверам. -Когда пользователь A (на сервере X) пишет пользователю B (на сервере Y): +`blocks.block_bytes` хранит полный подписанный ANS-104 DataItem. `blockchain_state` хранит текущую вершину цепочки. -1. Сервер X принимает сообщение; -2. Сервер X должен переслать DM-блок серверу Y; -3. Сервер Y сохраняет блок и доставляет в активные сессии пользователя B. +## Обычный AddBlock -Аналогично, блоки пользовательского блокчейна (записи `AddBlock`) должны синхронизироваться, -чтобы любой партнёрский сервер мог отдать полную историю пользователя. +Все проверки и запись выполняются под lock конкретной chain. После проверки Frame v1, подписи и `prevHash` одна SQL-транзакция записывает block + derived state + новую вершину. -## 2. Список серверов синхронизации (`sync_servers`) +Локально созданный пользовательский блок получает `arweave_publish_pending=true`. -Каждый сервер регистрирует в своей Solana PDA список `sync_servers` — -логины SHiNE-аккаунтов партнёрских серверов, с которыми он синхронизируется. +## Периодический peer-to-peer sync -`sync_servers` относится к серверному узлу и не является списком -access-серверов обычного пользователя. У пользователя действует только первый -`access_servers[0]`. +Старый межсерверный P2P sync может получать `GetBlockchainBlock` и применять полученный полный DataItem через `AddBlock`. При divergence full-resync: -- Список хранится в блоке `ServerProfileBlock` внутри `user_pda` сервера. -- Адрес каждого партнёрского сервера читается из его PDA на Solana. -- Синхронизация двусторонняя: оба сервера должны иметь друг друга в `sync_servers`. +1. берёт lock chain; +2. очищает derived rows/blocks/state через `BlockchainResyncCleanupDAO`; +3. пересоздаёт state из актуального пользовательского реестра; +4. последовательно проигрывает удалённую цепочку с блока 0; +5. никаких файловых swap/recovery операций нет. -## 3. Что синхронизируется +Этот прямой sync включается параметром: -### 3.1 Личные сообщения (DM) +```properties +blockchain.sync.enabled=true +``` -- Все DM-блоки форматов типов `1/2` (текст) и `3/4` (read-receipt). -- Сервер-отправитель: сохраняет пару и ставит асинхронную delivery-задачу. -- Сервер-получатель: сохраняет входящий блок в `signed_messages`, затем доставляет его активным сессиям. -- Дедупликация по уникальному `message_key = from|to|timeMs|nonce|type`. -- Между access-серверами одного пользователя DM не реплицируются. -- Полная актуальная схема: `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`. +Для проверки режима без прямых server-to-server связей его можно выключить в override-конфиге сервера: -### 3.2 Блоки пользовательского блокчейна +```properties +blockchain.sync.enabled=false +``` -- Все блоки `AddBlock` пользователей, зарегистрированных на сервере или синхронизирующихся через него. -- Синхронизируются в обе стороны между всеми партнёрами из `sync_servers`. -- Порядок блоков сохраняется (по глобальному номеру блока и хэшу). -- Дедупликация по глобальному номеру блока и хэшу. +При выключенном прямом sync сервер по-прежнему может синхронизировать пользователей из Solana и импортировать пользовательские блоки через Arweave, если включён `arweave.blocks.sync.enabled`. -### 3.3 Пользовательские настройки +## Arweave sync -Пользовательские настройки хранятся локально на единственном access-сервере и -между серверами не синхронизируются. +Дополнительно каждый сервер может независимо включить `ArweaveBlockSyncScheduler`. -## 4. Текущая реализованная схема +Он ищет child DataItems по тестовому тегу `App=test5590`, проверяет их и импортирует через ту же бизнес-проверку AddBlock. Импортированные блоки не публикуются повторно. -На текущем этапе сервер уже умеет базовую межсерверную синхронизацию пользовательских блокчейнов. +Если блоки обнаружены не по порядку, persistent queue оставляет более поздние блоки pending до появления предыдущих. -### 4.1 Что уже сделано +## Источник истины для цепочки -1. При старте сервер читает свой `server.SHiNE.login`. -2. По этому логину он загружает из Solana свою server PDA. -3. Из неё вытаскивает список `sync_servers`. -4. Для каждого логина партнёра сервер читает его PDA и сохраняет локально: - - `login` - - `server_address` -5. После этого: - - новые локальные `AddBlock` рассылаются партнёрам в фоне; - - при старте запускается periodic sync; - - periodic sync повторяется каждые `12` часов после старта. +Arweave не участвует в вычислении SHiNE chain hash: -### 4.2 Какие server-to-server API уже используются +```text +block_hash = SHA256(FrameV1Bytes) +next.prevHash32 = block_hash +``` -- `ListBlockchainHeads` — список heads всех локальных цепочек партнёра; -- `GetBlockchainBlock` — чтение одного конкретного блока партнёра; -- `GetSyncUserProfile` — минимальный профиль пользователя для локального создания runtime-проекции пользователя и `blockchain_state` без обращения в Solana RPC. - -### 4.3 Как сейчас работает periodic sync - -Для каждого сервера из локальной таблицы `sync_servers`: - -1. запрашивается `ListBlockchainHeads`; -2. для каждой удалённой цепочки сравниваются: - - `lastBlockNumber` - - `lastBlockHash` - - локальное состояние; -3. если локальная цепочка слабее, сервер по одному блоку вызывает `GetBlockchainBlock`; -4. каждый скачанный блок локально применяется через существующий `AddBlock`; -5. если у сервера ещё нет локальной записи пользователя/цепочки, перед этим подготавливается локальная runtime-проекция пользователя и `blockchain_state`. -6. если во время replay обнаруживается рассинхрон или на одинаковой высоте удалённая цепочка сильнее, запускается полный resync: - - цепочка помечается in-memory как `resync in progress`; - - создаётся marker-file в `data/`; - - в одной SQL-транзакции очищаются локальные данные цепочки и корректируются чужие счётчики; - - удаляются `.bch` и `.tmp_bch`; - - цепочка подтягивается заново с `0` через `GetBlockchainBlock`. - - обычный `AddBlock` на эту цепочку в этот момент возвращает `chain_resync_in_progress`. - -### 4.4 Как именно работает full resync - -Full resync запускается только тогда, когда: - -- локальная chain отстаёт и обычная докачка хвоста упирается в `bad_prev_hash` или `bad_block_number`; -- либо высота цепочек одинаковая, но удалённая версия сильнее по правилу: - - `lastBlockNumber`; - - `fileSizeBytes`; - - `lastBlockHash`. - -Порядок действий: - -1. Ставится in-memory guard на `blockchainName`. -2. Создаётся marker-file `.resync_pending`. -3. Обычный `AddBlock` на эту chain временно получает `chain_resync_in_progress`. -4. Вызывается атомарный SQL cleanup одной chain: - - уменьшаются чужие `likes_count` и `replies_count`; - - удаляются локальные derived-state записи этой chain; - - удаляются `blocks` и `blockchain_state` этой chain. -5. Удаляются файлы `.bch` и `.tmp_bch`. -6. Локальная chain создаётся заново через `GetSyncUserProfile` или через Solana import, если `sync.importUserProfileFromPartner.enabled=false`. -7. Chain replay-ится с `0` через `GetBlockchainBlock`. -8. Если всё прошло успешно, marker-file удаляется. -9. Если на любом шаге произошёл сбой, marker-file остаётся на диске, и сервер добивает эту chain при следующем старте. - -Важно: - -- full resync не делает умный rollback по одному блоку; -- full resync не трогает DM-таблицы и current users слой; -- висячие cross-chain ссылки считаются допустимым поведением системы. - -### 4.5 Как работает обычный `AddBlock` и его recovery - -Обычная запись блока теперь тоже идёт через временные артефакты: - -1. собирается `.tmp_bch` как полный кандидат на замену основного файла; -2. пишется маленький sidecar `.write_check` с `blockNumber` и `blockHash`; -3. только после этого создаётся пустой marker `.write_pending`; -4. выполняется SQL-транзакция; -5. после `commit` tmp атомарно ставится на место основного `.bch`; -6. marker и sidecar удаляются. - -На старте `BlockchainTmpRecoveryOnStartup` смотрит именно на эту пару: - -- если `write_pending` есть, recovery проверяет sidecar и БД, а затем либо завершает swap, либо чистит временные файлы; -- если `write_pending` нет, а `tmp_bch` или `write_check` остались, это мусор и он удаляется; -- `resync_pending` сюда не относится, это отдельный recovery-поток. - -### 4.6 Startup recovery по marker-file - -При старте сервер идёт в таком порядке: - -1. `BlockchainTmpRecoveryOnStartup` для `*.write_pending` и orphan `*.tmp_bch` / `*.write_check`; -2. `BlockchainResyncRecoveryOnStartup` для `*.resync_pending`; -3. только потом поднимается обычный сервер и запускается `PeriodicBlockchainSyncService`. - -Если marker-file существует: - -- сервер не должен начинать обычную работу поверх этой chain; -- recovery снова выполняет cleanup и replay с нуля; -- если recovery не завершился, marker остаётся, и сервер не переходит к обычному режиму для этой chain. - -### 4.7 Зачем понадобился `GetSyncUserProfile` - -Изначально подготовка локальной цепочки делалась через Solana: - -- из `blockchainName` извлекался `login`; -- сервер вызывал import пользователя из Solana PDA; -- по данным PDA локально создавались runtime-проекция пользователя и `blockchain_state`. - -На практике это упёрлось в ограничение внешнего Solana RPC: при чистом старте и массовой подтяжке чужих цепочек сервер мог получать `HTTP 429`. - -Поэтому добавлен отдельный обходной режим: - -- настройка `sync.importUserProfileFromPartner.enabled=true` -- в этом режиме сервер **не ходит в Solana RPC** для создания локальной цепочки во время sync; -- вместо этого он запрашивает у сервера-партнёра `GetSyncUserProfile` и создаёт локальную запись по данным партнёра. -- если локальная runtime-проекция пользователя уже существует, sync восстанавливает только `blockchain_state` и не трогает user-layer. - -Это временная практическая заплатка, чтобы clean-start sync не зависел от rate limit внешнего Solana endpoint. - -### 4.8 Что делает настройка `sync.importUserProfileFromPartner.enabled` - -- `false` — стандартный режим, подготовка локального пользователя идёт через Solana PDA; -- `true` — sync-режим обхода Solana, локальный пользователь создаётся по server-to-server `GetSyncUserProfile`. - -Настройка влияет именно на этап подготовки отсутствующей локальной цепочки во время periodic sync. - -## 5. Реализованный постоянный server-to-server транспорт - -Этот раздел не меняет текущую семантику DM, settings и blockchain. Он описывает -единый постоянный WSS-транспорт, через который выполняются уже существующие операции. - -### 5.1 Межсерверное соединение - -- Серверы устанавливают постоянное исходящее WebSocket-соединение друг с другом. -- Адрес партнёра определяется по `server_address` из его Solana PDA. -- После подключения отправляется `ServerHello` с `serverLogin`, версией протокола и capabilities. -- На текущем этапе `serverLogin` принимается на доверии; подпись Ed25519 корневым ключом сервера отложена. -- При разрыве выполняется переподключение с jitter/backoff до 60 секунд. -- После 120 секунд отсутствия полезного трафика отправляется WebSocket ping; pong ожидается 15 секунд. -- Один физический канал переиспользуют доставка DM и blockchain. - -### 5.2 Доставка новых данных (push) - -- При получении нового блока сервер может немедленно пушить его всем подключённым партнёрам. -- Партнёр подтверждает приём (ACK). Без ACK — повтор с backoff. -- DM использует отдельное расписание, описанное в `docs/Personal_Messages/Доставка_и_синхронизация_DM.md`. - -### 5.3 Начальная синхронизация (backfill) - -- При первом подключении к партнёру серверы могут обмениваться курсорами состояния блокчейнов. -- Сервер с более полной историей досылает недостающее партнёру. -- DM-history backfill между access-серверами отсутствует. - -### 5.4 Разрешение конфликтов - -- Блоки пользовательского блокчейна: порядок определяется глобальным номером блока. - Конфликтующие ветки (fork) разрешаются по правилам `AddBlock` (см. `docs/Blockchain/README.md`). -- DM: конфликтов нет, `message_key` уникален. - -## 6. Маршрутизация DM между серверами - -При отправке DM от пользователя A к пользователю B: - -1. Клиент A отправляет пару блоков на свой сервер X. -2. Сервер X валидирует и локально сохраняет пару. -3. До ответа клиенту X отправляет входящую копию на единственный - `access_servers[0]` пользователя B через `ReceiveIncomingMessage`. -4. Успешное сохранение на сервере B означает терминальный `delivered`. -5. При неудаче выполняются повторы через 30 секунд, 5 минут, 25 минут и 1 час. -6. После неудачной попытки через час ставится терминальный `failed`. - -Изменения routing B учитываются до терминального состояния, потому что список маршрутов перечитывается на каждой попытке. - -## 7. Безопасность - -- Все блоки подписаны ключами пользователя на клиенте — сервер не может подделать содержимое. -- Серверы не расшифровывают DM-контент; E2EE уже выполняется клиентами. -- При синхронизации каждый блок проходит валидацию подписи на принимающем сервере. -- Межсерверная авторизация DM-операций пока отложена; `sourceServerLogin` временно считается доверенным. - -## 8. Статус реализации - -| Компонент | Статус | -|-----------|--------| -| Регистрация серверной PDA в Solana | ✅ Реализовано | -| Чтение `sync_servers` из PDA | ✅ Реализовано | -| Локальная таблица `sync_servers` | ✅ Реализовано | -| Публичный `ListBlockchainHeads` | ✅ Реализовано | -| Публичный `GetBlockchainBlock` | ✅ Реализовано | -| Публичный `GetSyncUserProfile` | ✅ Реализовано | -| Плановый blockchain sync при старте + каждые 12 часов | ✅ Реализовано | -| Обход Solana RPC через `sync.importUserProfileFromPartner.enabled` | ✅ Реализовано | -| Обычный `AddBlock` через `tmp_bch`/`write_check`/`write_pending` | ✅ Реализовано | -| Межсерверный постоянный WebSocket-канал | ✅ Реализован общий `ServerConnectionPool` | -| Асинхронная доставка DM на единственный access-сервер получателя | ✅ Реализовано | -| Retry DM до 1 часа + UI-state | ✅ Реализовано | -| Репликация DM и настроек между access-серверами | Не используется | -| Push блоков блокчейна партнёрам | ✅ Выполняется через постоянный WSS-пул | -| Periodic backfill отсутствующего хвоста | ✅ Реализовано | -| Разрешение рассинхрона / divergence | ✅ Реализована базовая full-resync схема во время periodic sync | -| Startup recovery по `*.resync_pending` marker-file | ✅ Реализовано | -| Маршрутизация DM только через `access_servers[0]` | ✅ Реализовано | -| Криптографическая server-to-server авторизация DM | Нужна реализация | - -Текущая версия сервера использует постоянный WSS-пул для существующих -server-to-server JSON-операций. Отдельной будущей задачей остаётся -криптографическая авторизация server-to-server вызовов. - -Следующие отдельные шаги после текущего этапа: -- отдельно проверить full-resync и startup-recovery на реальном тестовом прогоне после ручного удаления БД/файлов. - -### 8.1 Практическая проверка на тестовом сервере - -Проверка на `server2.shineup.me` показала, что текущая схема действительно поднимает цепочку при старте: - -- после рестарта сервер сначала проходит `BlockchainTmpRecovery`; -- затем обрабатывает `BlockchainResyncRecovery`; -- после этого сам догружает цепочку `aidartest-001` с `shineup.me`; -- итоговое состояние на тестовом сервере: - - `blockchain_state.last_block_number = 13` - - `blocks` по `aidartest-001` = `14` записей - -Это подтверждает, что startup sync и full-resync flow работают в живом сценарии, а не только в коде. +Поэтому цепочка остаётся проверяемой и переносимой независимо от конкретного storage backend. diff --git a/docs/Personal_Messages/README.md b/docs/Personal_Messages/README.md index 7c92140b..1c7f5f21 100644 --- a/docs/Personal_Messages/README.md +++ b/docs/Personal_Messages/README.md @@ -9,6 +9,8 @@ - `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — доставка на единственный access-сервер, retry-воркер и UI-статусы - `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `` вставок внутри plaintext DM после расшифровки +- `docs/Personal_Messages/Файлы_DM_v2.md` — большие chunked-вложения, голосовые и BitTorrent v2 SHA-256/Merkle metadata +- `docs/API/18_DM_File_Storage_API.md` — HTTP-хранилище зашифрованных файлов DM на access-сервере отправителя Исторический устаревший документ сохранён отдельно: @@ -19,3 +21,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 e58e29d5..5430673b 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -131,6 +131,12 @@ dm_dialog_state хранит: WebSocket push ускоряет отображение, но после переподключения клиент запрашивает историю у своего единственного access-сервера. +UI-правило для открытого диалога: разделитель «Новые сообщения» относится только к +непрочитанным сообщениям, которые уже существовали до открытия экрана чата. Если новое +входящее сообщение приходит, пока этот диалог уже открыт, клиент просто добавляет его в +текущий поток, не создавая новый разделитель «Новые сообщения»; затем обычный механизм +видимого чтения/receipt отмечает его прочитанным. + ## 9. Подтверждения прочтения Read-receipt создаётся как подписанная пара type=3/type=4 и проходит тот же @@ -222,3 +228,38 @@ 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`. + +## 16. Дедупликация файлов и пересылка сообщений (2026-09-11) + +Для HTTP-хранилища DM-файлов действует усиленное правило content-addressed хранения: + +- перед загрузкой каждого ciphertext-объекта официальный UI делает `HEAD /dm-files/{fileId}`; +- если сервер подтверждает объект с тем же `fileId` и размером, `PUT` с байтами повторно не выполняется; +- сервер перед ответом на `HEAD`, `GET` и перед `alreadyExists=true` сам пересчитывает `SHA-256` сохранённого объекта и сверяет его с `fileId`; +- повреждённый или подменённый объект не считается существующим и не выдаётся клиенту; при следующем корректном `PUT` он может быть записан заново. + +Пересылка личного сообщения не вводит новый тип DM и не добавляет признак «переслано». UI создаёт обычное новое контентное сообщение `type=1/2` выбранному собеседнику: + +- для обычного текста переносится отображаемый текст сообщения; +- исходный `` не переносится, поэтому новое сообщение не остаётся ответом на сообщение из старого чата; +- для сообщения с файлами повторно используются существующие ``-описатели, поэтому ciphertext не шифруется и не загружается повторно; +- сервер и получатель видят пересланное сообщение как обычное новое сообщение без служебной пометки об источнике. diff --git a/docs/Personal_Messages/Технические_вставки_DM_v1.md b/docs/Personal_Messages/Технические_вставки_DM_v1.md index 436ca0b1..69739762 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,12 +162,36 @@ fromLogin|toLogin|timeMs|nonce - `Звонок: H:MM:SS` - `Звонил, но недозвонился: ...` - для `reply` скрывает сам блок и показывает только текст ответа; +- для `file` скрывает fallback-текст и показывает карточку вложения с локальным download/decrypt; - если исходное reply-сообщение не найдено, reply-preview не показывается. -## 8. Совместимость +## 9. Совместимость Так как это часть plaintext, а не часть серверного envelope: - сервер не обязан понимать этот формат; - будущие клиенты могут добавлять новые `kind`; - клиенты, которые распознают SHiNE-вставки, должны скрывать неизвестные блоки целиком, если они стоят в начале и корректно закрыты. + +## 10. Расширение `file` v2 + +Клиент обязан продолжать читать `v=1`. Новые chunked-вложения отправляются так: + +```text + +``` + +Дополнительные поля v2: + +- `id` / `url` указывают не на весь файл, а на зашифрованный root manifest; +- `ivp` — случайный 4-byte IV prefix, из которого детерминированно строятся уникальные IV chunks/pages/root; +- `chunk` — plaintext chunk size, сейчас `1048576`; +- `chunks` — число частей; +- `th` — BitTorrent v2 SHA-256 infohash; +- `pr` — BitTorrent v2 pieces root; +- `kind=file|voice`; +- `dur` — длительность voice в миллисекундах, для обычного файла `0`. + +В одном plaintext DM разрешено несколько последовательных `` блоков. Официальный UI собирает их в один список вложений и скрывает fallback-текст. + +Подробный формат chunk encryption, manifest pages и BitTorrent v2 hashing: `Файлы_DM_v2.md`. diff --git a/docs/Personal_Messages/Файлы_DM_v2.md b/docs/Personal_Messages/Файлы_DM_v2.md new file mode 100644 index 00000000..1e3fe64e --- /dev/null +++ b/docs/Personal_Messages/Файлы_DM_v2.md @@ -0,0 +1,124 @@ +# Файлы и голосовые DM v2: chunked AES-GCM + BitTorrent v2 hashes + +## Цели + +DM file v2 убирает ограничение размера исходного файла, не требует держать файл целиком в RAM и сохраняет серверную модель «сервер видит только ciphertext». + +Основные свойства: + +- plaintext режется на куски по `1 MiB`; +- каждый кусок независимо шифруется `AES-256-GCM` одним случайным ключом файла, но с уникальным 96-bit IV; +- каждый ciphertext-кусок хранится как immutable объект `Base58(SHA-256(ciphertext))`; +- список кусков хранится в зашифрованных страницах манифеста по 256 записей; +- корневой манифест тоже зашифрован и content-addressed; +- E2EE DM содержит только ссылку на корневой манифест, AES-key, IV-prefix и пользовательские метаданные; +- один DM может содержать до 10 `` блоков; +- `kind=voice` использует тот же формат хранения и отдельный UI проигрывателя. + +## IV и domain separation + +На один файл создаётся случайный 4-byte `ivPrefix`. + +12-byte IV строится как: + +```text +ivPrefix[4] || uint64_be(token) +``` + +Диапазоны `token` разделены: + +- chunks: `0 .. 2^63-1`; +- manifest pages: `2^63 + pageIndex`; +- root manifest: `0xffffffffffffffff`. + +AAD также содержит домен (`chunk`, `page`, `root`), prefix и индекс. Поэтому перестановка ciphertext-кусков не проходит AES-GCM authentication. + +## Manifest pages + +Каждая страница после расшифровки содержит до 256 записей: + +```json +{ + "v": 2, + "page": 0, + "chunks": [ + { + "i": 0, + "id": "Base58(SHA-256(ciphertext))", + "ps": 1048576, + "es": 1048592, + "ph": "BitTorrent-v2-piece-hash-base64url" + } + ] +} +``` + +Страницы сами AES-GCM зашифрованы и загружаются в `/dm-files/{id}`. + +## Root manifest + +После расшифровки: + +```json +{ + "v": 2, + "scheme": "SHINE-DM-CHUNKED-AES-256-GCM", + "chunkSize": 1048576, + "fileSize": 123456789, + "chunkCount": 118, + "pages": [{"id":"...","count":118,"encryptedSize":12345}], + "torrent": { + "metaVersion": 2, + "blockLength": 16384, + "pieceLength": 1048576, + "piecesRoot": "...", + "infoHash": "..." + } +} +``` + +Имя файла и MIME в manifest не пишутся. Они остаются внутри E2EE DM. + +## BitTorrent v2 совместимость + +Хеширование соответствует BEP 52: + +- базовый hash block: `16 KiB`; +- SHA-256; +- piece length: `1 MiB`; +- Merkle padding leaf = 32 zero bytes; +- `pieces root` вычисляется по правилам BitTorrent v2; +- `infoHash` = `SHA-256(bencode(info dictionary))`; +- piece-layer hash каждого 1-MiB куска хранится в зашифрованной manifest page. + +Из `name`, `size`, `piecesRoot` и списка `ph` можно построить tracker-less `.torrent` v2 без повторного хеширования исходного файла. + +Это совместимость метаданных и проверки контента. HTTP `/dm-files` пока не является BitTorrent peer transport: для настоящего P2P потребуется отдельный seeding/peer слой. + +## Скачивание + +Получатель: + +1. получает и проверяет ciphertext root manifest по Base58(SHA-256); +2. расшифровывает root manifest; +3. по очереди получает manifest pages; +4. получает каждый chunk с сервера отправителя; +5. проверяет content address ciphertext; +6. расшифровывает chunk локально; +7. проверяет BitTorrent-v2 piece hash; +8. пишет plaintext на диск; +9. в конце проверяет итоговый `pieces root`. + +В браузерах с File System Access API plaintext пишется на диск по частям и целиком в RAM не собирается. В остальных браузерах остаётся Blob fallback без искусственного лимита размера, но фактический предел зависит от памяти браузера. + +## Голосовые + +`MediaRecorder` пишет `Opus/WebM`, `Opus/Ogg` или поддерживаемый браузером audio MIME. После завершения запись проходит тот же DM file v2 pipeline. + +Технический блок отличается полями: + +```text +kind=voice;dur= +``` + +Получатель видит player с Play/Pause, прогрессом и длительностью. Аудио расшифровывается только на клиенте. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index a5842884..88e170d5 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -264,6 +264,12 @@ ReadReceiptBody_v1_0 - если подтверждение прочтения приходит в другом порядке, сервер сохраняет максимальный watermark и не откатывает счётчик назад. - в списке диалогов сервер может отдавать последний signed block как `lastMessageBlobB64` без попытки извлечь plaintext preview. + +UI-примечание (байтовый формат не меняет): разделитель «Новые сообщения» создаётся только +для непрочитанного хвоста, который существовал до открытия диалога. Входящий `type=1`, +полученный при уже открытом соответствующем чате, отображается сразу без создания нового +разделителя; это не изменяет signed-контейнер и не вводит нового поля протокола. + ## 9. Контент типов `5/6` Типы: @@ -365,3 +371,25 @@ ReadReceiptBody_v1_0 Контейнер `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 и правила парности входящей/исходящей копий остаются прежними. + +## 16. Примечание о пересылке сообщений (2026-09-11) + +Пересылка не меняет байтовый формат `SHiNE_DM` и не добавляет отдельный `messageType` или флаг forward/repost. + +Клиент формирует новый обычный plaintext для `type=1/2`: + +- текстовая часть копируется как новое сообщение; +- `` исходного сообщения удаляется; +- валидные `` могут быть скопированы без изменения, чтобы новое сообщение ссылалось на тот же уже загруженный зашифрованный объект; +- информация о том, из какого чата или сообщения выполнена пересылка, в контейнер не добавляется. + +Таким образом подпись, `baseKey`, шифрование и структура контейнера остаются полностью прежними: меняется только содержимое нового plaintext перед стандартной отправкой. diff --git a/docs/Solana/user_pda/README.md b/docs/Solana/user_pda/README.md index 877a827f..aa1b07b7 100644 --- a/docs/Solana/user_pda/README.md +++ b/docs/Solana/user_pda/README.md @@ -94,16 +94,17 @@ UserPdaRecordV1 | `40` | `AccessServersBlock` | Серверы доступа/relay. | | `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. | | `70` | `TrustedStateBlock` | Счетчик trusted-связей. | +| `100` | `ArchiveHeadBlock` | Legacy/reserved. Новый per-block ANS-104 transport не использует это поле для пользовательской истории. | | `255` | `ReservedBlock` | Зарезервировано, пока не используется. | Правила: - неизвестный `block_type` в `format_major = 1` считается ошибкой; - обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`; -- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`; +- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`; - каждый обязательный блок должен встречаться ровно один раз; - порядок блоков в записи фиксируется для простоты проверки: - `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`. + `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`, `ArchiveHead`. ## 6. RecoveryKeyBlock @@ -359,6 +360,28 @@ TrustedStateBlock Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат. +## 15.1. ArchiveHeadBlock + +Необязательный блок текущей головы серверного архива. Он используется archive-capable сервером и хранится в том же User PDA. + +```text +ArchiveHeadBlock +- block_type: u8 = 100 +- block_version: u8 = 0 +- archive_tx_id: [u8; 32] +- archive_hash: [u8; 32] +``` + +Семантика: + +- `archive_tx_id` — legacy/reserved поле старой archive-head схемы; новый per-block ANS-104 transport его не обновляет; +- `archive_hash` — SHA-256 большого archive block по правилам `docs/Archive/01_PROTOCOL_v1.0.md`; +- отсутствие block `100` означает, что аккаунт ещё не объявлял archive head; +- обычный legacy `update_user_pda`, в instruction которого archive extension отсутствует, **обязан сохранить существующий ArchiveHeadBlock без изменений**; +- расширенный `update_user_pda` может заменить archive head или явно очистить его; отдельной Solana instruction для архива нет. + +`ArchiveHeadBlock` входит в unsigned bytes User PDA и тем самым покрывается обычной root-подписью записи. + ## 16. Подпись user_pda Подписывается не вся PDA целиком, а unsigned-часть записи: @@ -392,6 +415,7 @@ Solana-программа проверяет подпись через встр - обязательные блоки присутствуют; - создается минимум один `BlockchainRecord`; - новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`; +- `ArchiveHeadBlock` при регистрации не обязателен; обычный пользователь/сервер может начать публиковать архив позже; - стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит; - `used_bytes <= paid_limit_bytes`; - пользователь платит регистрационную комиссию; @@ -408,6 +432,7 @@ Solana-программа проверяет подпись через встр - `prev_record_hash` равен хэшу unsigned-части предыдущей записи; - `updated_at_ms` обновляется; - unsigned-часть новой записи подписана `root_key`; +- если archive extension в instruction отсутствует (legacy client), старый `ArchiveHeadBlock` сохраняется; если extension присутствует, применяется переданное `archive_head_update`; - лимиты блокчейнов могут только увеличиваться; - занятый размер и номер последнего блока не могут уменьшаться; - при увеличении оплаченного лимита пользователь доплачивает комиссию; diff --git a/docs/Solana_Architecture/details/shine_users.md b/docs/Solana_Architecture/details/shine_users.md index aa107cf8..cc55f570 100644 --- a/docs/Solana_Architecture/details/shine_users.md +++ b/docs/Solana_Architecture/details/shine_users.md @@ -135,3 +135,23 @@ - economy-настройки меняет DAO-authority; - upgrade-authority программы после проверки передается DAO; - пользовательские операции `create_user_pda` и `update_user_pda` остаются доступными обычным пользователям при корректных подписях и оплате. + +## ArchiveHeadBlock (legacy/reserved) + +Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`): + +```text +archive_tx_id [32] +archive_hash [32] +``` + +Поле `ArchiveHeadBlock` осталось в Solana/PDA как legacy/reserved для совместимости формата PDA. Новый transport пользовательских блоков не использует server-level SHINE-ARCHIVE или archive head: каждый пользовательский block публикуется как ANS-104 DataItem внутри стандартных bundles. + +Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим: + +- legacy payload без archive extension сохраняет старый block `100`; +- новый payload может заменить/очистить archive head; +- итоговая полная User PDA запись, включая block `100`, покрывается обычной root-подписью. + +Это позволяет обычным старым клиентским обновлениям профиля не стирать archive head серверного publisher-а. + diff --git a/docs/UI-Design/DESIGN.md b/docs/UI-Design/DESIGN.md new file mode 100644 index 00000000..8ef6eca7 --- /dev/null +++ b/docs/UI-Design/DESIGN.md @@ -0,0 +1,290 @@ +# SHiNE — дизайн приложения + +Версия 0.2 · 22 сентября 2026. + +Статус: первый перенос списка каналов, ленты, ветки и общего редактора выполнен в рабочем дереве. Состав изменений, автоматические проверки и оставшаяся визуальная приёмка описаны в [IMPLEMENTATION.md](IMPLEMENTATION.md). Точные размеры и поведение на устройствах остаются целями приёмки, а не утверждением о выполненном тестировании на телефонах. + +Этот файл — актуальный источник решений по дизайну. Галерея `channels-v1` остаётся историей четырёх исходных вариантов: она ещё не показывает итоговое сочетание двух тем, компактной шапки и полноэкранного ответа. При расхождении руководствоваться этим документом. + +## 1. Задача и границы + +Сделать общение понятным с первого открытия. Начинаем со списка каналов, ленты канала, ветки обсуждения и мобильного редактора ответа. Затем распространяем выбранную систему на личные сообщения, связи, уведомления, профиль, поиск, создание каналов и настройки. + +Основа — предоставленные пользователем скриншоты Threads и SHiNE. Заимствуем знакомые расположения и сценарии, сохраняя особенности продукта. Этот документ не утверждает новые возможности API и не меняет правила записи, реакции или блокчейна. + +Исходные направления представлены в [HTML-галерее](channels-v1/index.html). Выбрана одна система интерфейса с двумя темами: композиция Ночного SHiNE, ночная палитра из него же, дневная палитра из Тёплого SHiNE. Шапка канала компактная по образцу Компактного клуба; редактор ответа полноэкранный, как у Компактного клуба / Чистого монохрома. + +Тема меняет цвета, но не геометрию, навигацию, типографику или порядок действий. Из Тёплого SHiNE берём палитру, а не карточную компоновку, увеличенные заголовки Georgia и редактор-лист. + +## 2. Принципы для всего приложения + +1. Содержимое важнее оформления: текст без свечения, минимум декоративных рамок, спокойные поверхности. +2. Один акцентный цвет обозначает активный раздел, главное действие и выбранное состояние. +3. Главное действие доступно без прокрутки до конца. Его содержание зависит от роли и текущего экрана. +4. Всегда видно, где пользователь находится и кому отвечает. Возврат сохраняет позицию списка или ленты. +5. Редкие и технические действия помещаются в меню, но остаются доступными. +6. Пустота, загрузка и ошибка — полноценные состояния. Ошибка не уничтожает введённый текст. +7. Метки, иконки и расположение действий повторяются во всех разделах. +8. Не выдавать отсутствующую функцию за существующую. В частности, не добавлять репост, GIF-поиск или сортировку по популярности только потому, что они есть в референсе. + +## 3. Принятые решения + +| Решение пользователя | Правило для реализации | +| --- | --- | +| Основа — Ночной SHiNE | Плоская широкая лента, сдержанные поверхности, единые линейные иконки | +| Дневной и ночной режим сразу | Оба режима обязательны для каждого переносимого компонента и состояния | +| Дневные цвета — из Тёплого | Кремовые поверхности, тёмный тёплый текст, янтарно-коричневый акцент | +| Шапка сообщений — компактнее, как в Клубе | Панель 56 px, без большого описания и декоративного баннера перед лентой | +| Ответ — как у Клуба / Монохрома | Полноэкранный мобильный редактор, короткий контекст, основное место отдано вводу | +| Лайки — общее количество | Один счётчик. Типы лайков и их статистика не входят в первый перенос | +| Статистика позднее, возможно долгим тапом | Возможное будущее взаимодействие, не обязательство реализовать жест сейчас | +| Оглавление используется редко | Только в меню канала, без отдельной кнопки в шапке или под описанием | +| Плотность мокапов подходит | Сохраняем ритм Ночного SHiNE; уплотняем шапку, не всю ленту до варианта Клуба | + +Эти решения приняты 22.09.2026 в обсуждении с пользователем. Повторно выбирать направление не требуется. + +## 4. Базовые параметры + +Ниже единые размеры для обеих тем. Референс плотности — Ночной SHiNE. Не переносить мелкие служебные подписи галереи буквально: текст остаётся читаемым на телефоне. Размеры задавать через общие токены с поддержкой системного увеличения шрифта; фиксированные высоты ниже являются минимальными, если тексту требуется больше места. + +| Параметр | Целевое значение | +| --- | --- | +| Основной текст публикации / ответа | 15 px / 23 px; редактор ввода 16 px / 24 px | +| Имя автора | 14 px / 20 px, вес 600 | +| Вторичная информация | 12 px / 18 px | +| Заголовок корневого экрана | 24 px / 30 px, вес 600 | +| Название канала в компактной панели | 17 px / 22 px, вес 600 | +| Заголовок обычной модальной панели / редактора | 16 px / 24 px, вес 600 | +| Шкала отступов | 4 / 8 / 12 / 16 / 24 / 32 px | +| Горизонтальные поля телефона | 16 px; между аватаром и текстом 10–12 px | +| Вертикальный ритм | Публикация: 16 px сверху, 8 px снизу; абзацы: 8–12 px; строка канала: 14 px сверху и снизу | +| Аватар | 36 px в ленте; 48 px в списке; 32 px у ответа; 40 px в редакторе | +| Область нажатия | Не менее 44 × 44 px для всех действий, включая меню, закрытие, лайки и вкладки | +| Иконки | Единая линейная система, 20–24 px, одинаковая толщина штриха | +| Скругления | Поле/файл/кнопка 12 px; медиа 14 px; меню-лист 20 px; полноэкранный ответ без скругления внешнего края | +| Контраст текста | Цель WCAG AA; проверить утверждённую палитру | +| Анимация | 150–200 мс, отключение при reduced motion | + +### 4.1. Семантические цвета двух тем + +Использовать роли, а не прямые цвета внутри страниц. Имена ниже описывают контракт дизайна; при реализации сопоставить их существующей системе переменных, не создавать параллельную систему токенов. Основная палитра взята из выбранных мокапов; статусные цвета — дополнение спецификации. + +| Роль | Ночь | День | Где используется | +| --- | --- | --- | --- | +| `background` | `#101B20` | `#FAF7F0` | Основной фон, шапка и навигация | +| `surface` | `#17272D` | `#FFFDF8` | Поле поиска, меню, карточка файла | +| `surface-selected` | `#213B3A` | `#EFE6D6` | Выбранная вкладка, лёгкое выделение | +| `text-primary` | `#E8F2F1` | `#302C25` | Основной текст | +| `text-secondary` | `#98ADAF` | `#706658` | Время, авторские идентификаторы, подсказки | +| `border-subtle` | `#2A3B40` | `#E6DFD1` | Декоративное разделение строк | +| `border-control` | `#71888A` | `#978B79` | Граница поля, если нужна для распознавания контроля | +| `accent` | `#94E1CE` | `#93511E` | Главное действие, ссылки, активная навигация | +| `on-accent` | `#102C27` | `#FFFAF3` | Текст на залитой акцентом кнопке | +| `focus-ring` | `#94E1CE` | `#93511E` | Контур клавиатурного фокуса | +| `reaction-active` | `#F29AAB` | `#A43350` | Сердце выбранного лайка, не системная ошибка | +| `danger` | `#FFABA8` | `#B13135` | Ошибка, подтверждение удаления | +| `success` | `#94E1CE` | `#346A48` | Успешное действие, с текстовым пояснением | +| `warning` | `#E6C184` | `#845B14` | Предупреждение, с текстовым пояснением | +| `scrim` | `#00000099` | `#302C2566` | Затемнение под меню/диалогом | + +Шрифт системный sans-serif в обеих темах: `-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`. Georgia из тёплого концепта не переносить. Не использовать свечение текста и цветные тени кнопок. Допустима лёгкая тень у всплывающего меню, но не у каждой публикации. + +Небольшая коррекция исходной дневной палитры: вторичный текст затемнён с `#7B7264` до `#706658`. Расчёт контраста исходного цвета на основном фоне дал 4.43:1, на выбранной поверхности — 3.83:1. Новый цвет даёт соответственно 5.26:1 и 4.55:1, сохраняя тёплый характер. Это уточнение для читаемости, а не смена выбранной палитры. + +`border-subtle` не служит единственным указателем поля или выбранного состояния. Контраст проверять для конкретных пар: обычный текст ≥ 4.5:1; значимые иконки/границы и фокус ≥ 3:1. Не снижать контраст вторичного текста общей прозрачностью контейнера. Disabled обозначать одновременно неактивностью и стилем; ожидаемое действие пояснять при необходимости. + +### 4.2. Выбор темы + +Рабочее решение для реализации: настройка «Оформление» с пунктами «Как на устройстве», «Дневное», «Ночное». По умолчанию — как на устройстве. Явный выбор сохраняется локально и имеет приоритет; изменение системной темы применяется только в режиме «Как на устройстве». Это проектное допущение, пользователь отдельно не выбирал способ переключения. + +Обновление темы не перезагружает экран, не закрывает редактор и не сбрасывает черновик или прокрутку. Выбранный режим применяется до первой видимой отрисовки, без вспышки противоположного фона. Все модальные окна, меню, системные поля и состояния используют ту же тему. Фотографии и аватары не инвертируются. Не нужна постоянная кнопка солнца/луны в тесной шапке канала. + +## 5. Список каналов + +Верх: заголовок «Каналы», видимая кнопка создания, поиск. Фильтры «Все», «Подписки», «Мои» вынесены из выпадающего заголовка на поверхность. + +Строка: аватар, название, автор/идентификатор при необходимости, однострочное превью, время, число непрочитанных. Длинное название сокращается в строке, полное доступно внутри канала. На карточке не нужно одновременно показывать описание, технический путь, общее число сообщений и длинное превью. + +Поиск в прототипе фильтрует примеры по названию и автору. В текущем приложении серверный поиск начинается с логина/начала логина автора: полноценный глобальный поиск по названию здесь является предложением, а не обещанием текущего API. При реализации можно сохранить существующую семантику с подсказкой «Найти по @автору». + +Нижняя навигация сохраняет пять существующих разделов: «Личные», «Каналы», «Связи», «Уведомления», «Профиль». Иконки приводятся к одной системе. Высота рабочей зоны 64 px плюс нижний safe area; иконка 22 px, подпись 11 px. Активный пункт отмечается акцентом и мягкой подложкой под иконкой. При крупном системном шрифте панель может увеличивать высоту; нельзя уменьшать шрифт, скрывать смысловые подписи или обрезать «Уведомления» ради фиксированной высоты. + +Не выделять первую строку автоматически только потому, что она первая. Непрочитанное отмечается счётчиком и более заметным превью, выбранное — фактическим состоянием выбора. Просмотр канала и подписка — разные действия; открытие строки не подписывает пользователя автоматически. + +## 6. Канал и сообщения + +### 6.1. Компактная шапка + +Верхняя панель высотой 56 px плюс верхний safe area: возврат 44 px, растягивающийся блок названия, уведомления 44 px, меню 44 px. Внутри блока — название 17/22 px и вторичная строка `@автор` 12/16 px. Название в одну строку с многоточием; полное название, идентификатор и описание доступны в «О канале». На очень узком экране и при крупном шрифте уведомления переносятся в меню, чтобы сохранить место названию. Название канала открывает информацию о канале. + +Под панелью сразу начинается лента. Убрать большой блок описания, декоративный градиентный баннер и постоянно видимый статус «Вы подписаны». Описание и управление подпиской — в меню/информации о канале. Для неподписанного читателя допустима компактная строка «Подписаться» над лентой; после подписки она исчезает. Метка «Новые сообщения» занимает одну строку и появляется только при наличии непрочитанного. + +Порядок меню канала: «О канале», «Оглавление», настройка уведомлений, действие подписки/отписки, затем доступные владельцу действия. Пункты показываются по реальным правам. Оглавление не дублируется в шапке, под описанием или в строке действий публикации. При пустом оглавлении показать «В этом канале пока нет оглавления»; создание доступно владельцу только через существующий сценарий. + +### 6.2. Публикация + +Публикация: автор, время, меню, текст, вложение и действия. Порядок действий — лайк, открыть обсуждение, поделиться; явное «Ответить» открывает редактор. Это разделяет чтение ветки и написание ответа, сейчас они завязаны на разные области карточки. + +У лайка сейчас только общий счётчик `likesCount`: не суммировать его повторно с подкатегориями. Один тап ставит/снимает лайк; выбранное состояние — заполненное сердце и `reaction-active`. Повторное действие во время запроса блокируется, при ошибке состояние и число восстанавливаются с коротким пояснением. Нулевой счётчик можно показывать как `0`, одинаково во всех разделах. У кнопки есть доступное название и состояние, например «Нравится, 24» и `aria-pressed`. + +Статистику типов не показывать ни на панели, ни в меню первого переноса. Долгий тап по лайкам — отложенная идея пользователя; порог жеста, содержимое и доступную альтернативу согласовать позднее. Если жест появится, ему понадобится явный пункт меню для клавиатуры и пользователей, которые не знают о долгом тапе. Сейчас не добавлять неработающую подсказку или заглушку для этого жеста. + +«Данные блокчейна», доступная история, редактирование и удаление помещаются в меню. Редактирование и удаление доступны только для собственных сообщений; удаление требует понятного подтверждения с описанием реального эффекта существующего протокола. Отдельные оценки не возвращаются на панель действий: в коде они намеренно скрыты. + +Кнопка публикации закреплена снизу у владельца. У читателя в ленте нет поля, которое обещало бы писать непосредственно в чужой канал. Ответ доступен под публикацией, а на экране ветки — также в нижнем поле. Для неподписанного пользователя нужна заметная подписка, для гостя — корректное приглашение войти перед действием. + +Вложения: компактная карточка файла с названием, типом и размером; изображения — в ширину текста с ограничением высоты. У галереи нужен индикатор количества. Реальные превью, загрузка и ошибки файлов проектируются отдельно; изображение в мокапе — встроенная SVG-иллюстрация без внешних ресурсов. + +## 7. Ветка обсуждения + +Исходное сообщение остаётся наверху, ниже — счётчик и ответы в последовательном порядке. Не предлагать «Популярные» до подтверждения алгоритма и источника данных. + +Для вложенных ответов показывать «В ответ …» и короткий контекст. Не уменьшать ширину текстовой колонки бесконечными отступами: после одного уровня показывать связь подписью или открывать подветку, используя имеющуюся маршрутизацию. Тонкая линия допустима только для обозначения реальной связи, не вокруг каждого комментария. Плотность ответов сохраняется близкой к Ночному SHiNE; выбор полноэкранного редактора из Клуба не означает переноса всей его плотности. + +Возврат ведёт в тот же канал и на прежнюю позицию. После отправки новый ответ появляется в ветке; при ошибке остаются текст и вложения. Длинные ветки потребуют отдельной проверки пагинации, состояния загрузки и перехода к конкретному ответу. + +## 8. Мобильный редактор ответа + +Полноэкранный мобильный редактор в обеих темах — принятое решение. Он закрывает нижнюю навигацию и занимает доступную область приложения; не является маленькой центральной модалкой или частично открытым листом. Нет ручки перетаскивания, переключателя разворачивания и декоративного верхнего отступа. + +Верхняя панель 56 px: закрытие 44 px слева, заголовок «Ответ» по центру; место справа не заполнять лишними действиями. Ниже — короткий контекст исходного сообщения, собственный аватар, поле и вложения. Нижняя панель: «Прикрепить», счётчик и кнопка «Ответить». Для нового сообщения тот же редактор с заголовком «Новое сообщение» и кнопкой «Опубликовать», без цитаты. + +При первом открытии показать адресата и цитату максимум в две строки плюс «Показать целиком» при обрезке. Не повторять весь исходный пост с изображением в редакторе. Для ответа на файл без текста показать название/тип вложения. Раскрытая цитата находится в прокручиваемой области и не вытесняет навсегда поле. Адресат берётся из выбранного сообщения, а не всегда из автора канала. + +Поле ввода занимает всё оставшееся свободное место, растёт вместе с текстом в общей прокручиваемой области. Шапка и нижняя панель остаются видимыми. При открытии по нажатию фокус переходит в поле; курсор при восстановлении черновика остаётся в тексте. Системная клавиатура не должна перекрывать отправку: учитывать visual viewport, поворот устройства и безопасные отступы. Не рисовать свою клавиатуру в приложении — иллюстрация есть только в галерее. + +Крестик, системный возврат и Escape закрывают редактор с одинаковой политикой черновика. Возврат восстанавливает место в обсуждении и фокус инициирующей кнопки. На desktop редактор может быть центрированной панелью шириной до 640 px и высотой до доступного viewport с теми же тремя зонами; на телефоне всегда полный экран. + +Enter создаёт новую строку. Отправка — по явной кнопке; Ctrl/Cmd+Enter можно поддержать как дополнительное desktop-действие с подсказкой. Не отправлять во время IME/composition. В текущем редакторе есть обработчик plain Enter, поэтому это намеренное изменение поведения, которое нужно отдельно учитывать при переносе. + +Состояния отправки: + +- Пустой текст без вложений: кнопка неактивна. +- Есть текст или вложение: отправка доступна; лимит текста 2000 символов сохранён из текущего редактора ответа. +- Загрузка вложения: прогресс, возможность отмены, запрет отправлять незавершённое вложение. +- Отправка: индикатор, блокировка повторного нажатия. +- Ошибка: понятная причина, повтор, сохранённый текст. +- Успех: новый ответ и восстановленный контекст обсуждения. +- Закрытие: черновик остаётся; очистка по отдельному явному действию, не по Escape. + +В исходной HTML-галерее воспроизведены пустое/заполненное поле, лимит, выбранное вложение, закрытие с черновиком и локальная успешная отправка. Загрузка/ошибка сети и прогресс — требования будущей реализации. Счётчик 2000 относится к ответу согласно просмотренному коду; лимиты публикаций и вложений брать из соответствующих существующих валидаторов, не вводить общий лимит для всех сообщений только по этому документу. + +Черновик в приложении изолировать ключом «аккаунт + канал + идентификатор исходного сообщения + режим редактора». Никогда не переносить текст ответа на другой пост молча. В первом переносе достаточно сохранения при закрытии/возврате в рамках текущей сессии интерфейса. Переживание перезагрузки/закрытия приложения не обещать, пока не выбраны хранение и политика очистки. При выходе из аккаунта его черновики не показываются другому пользователю. + +## 9. Что подтверждено кодом + +| Возможность | Где прочитано | Решение макета | +| --- | --- | --- | +| Все / свои / подписки; поиск; создание | `shine-UI/js/pages/channels-list.js`, `render`, `openChannelFinderModal` | Показать поиск и фильтры явно | +| Непрочитанные, превью, автор и время | Там же, построение строк списка | Сохранить иерархию без перегрузки | +| Публикация владельцем | `shine-UI/js/pages/channel-view.js`, ветка `isOwnChannel` | Закреплённое действие только для роли владельца | +| Подписка и оглавление | `channel-view.js` | Сделать доступными в информации о канале | +| Лайки трёх категорий, ответ, отправка ссылки | `channel-view.js`, построение действий сообщения | Только общий счётчик; детализация отложена; ветка отдельно от ответа | +| Технические данные и редактирование | `channel-view.js` | Перенести в меню, учитывать авторство | +| Оценки временно скрыты | Комментарий возле действий сообщения в `channel-view.js` | Не возвращать без решения продукта | +| Ответы и вложения; 2000 символов | `openReplyModal` в `channel-view.js` и `channel-thread-view.js` | Мобильный редактор с контекстом | + +Просмотр кода был целевым, не полным аудитом. Права всех типов каналов, статусы пользователей, истории, специальные подтипы и поведение гостя нужно проверить перед реализацией. Переключатель роли в галерее предназначен для оценки размещения действий, не моделирует авторизацию. + +## 10. Состояния для следующей итерации + +| Область | Нужные дополнительные макеты | +| --- | --- | +| Список | Пустые подписки; первый запуск; загрузка; ошибка; нет результатов | +| Канал | Гость; неподписанный; владелец; пустой канал; недоступный канал; очень длинное название | +| Сообщение | Длинный текст; несколько изображений; файл без текста; удалённое; изменённое; специальный подтип | +| Ветка | Нет ответов; много ответов; вложенная ветка; переход по ссылке | +| Ответ | Длинная цитата; ошибка отправки; загрузка файла; черновик; клавиатура; возврат фокуса | +| Доступность | Клавиатура; screen reader; крупный шрифт; контраст; reduced motion | + +## 11. Общие компоненты для единообразия + +| Компонент | Единое правило | +| --- | --- | +| TopBar | Общие высота, поля, возврат и меню. Большой заголовок только у корневого экрана, компактный у вложенного | +| Primary button | Заливка `accent`, текст `on-accent`, высота 44 px, радиус 12 px; одно главное действие в области | +| Secondary button | Поверхность/контур, без конкуренции с primary; тот же размер текста и зона нажатия | +| Icon button | Иконка 22 px внутри зоны 44 px, доступное название; одинаковые pressed/focus/disabled | +| Field | Метка по смыслу, текст 16 px, вспомогательная подпись и ошибка под полем; placeholder не заменяет метку | +| Search | Небольшое поле с лупой; очистка видна при непустом запросе; отдельно загрузка, отсутствие результатов и ошибка | +| Tabs | Одинаковый selected-стиль, минимум 44 px по высоте; не создавать страницу со своей системой вкладок | +| Avatar | Круглый, устойчивый размер; fallback с инициалами; выбранная тема не перекрашивает фотографию | +| List row | Аватар/иконка → основной и вторичный текст → время/счётчик; вся строка открывает объект | +| Overflow menu | Те же отступы и строки 44 px; на телефоне допустим общий лист, на desktop привязка к кнопке | +| Attachment | Единая карточка имени/типа/размера, отдельные состояния загрузки и ошибки; медиа не растягивает страницу по ширине | +| Empty state | Короткий заголовок, причина/следующий шаг, одно уместное действие; без большого декоративного баннера | +| Inline error | Возле неудавшегося действия, понятный текст и повтор; технические подробности раскрываются отдельно | +| Toast | Короткое подтверждение без перехвата фокуса; не единственный способ сообщить о блокирующей ошибке | +| Confirmation | Называет действие и объект; основной акцент для обычного действия, `danger` для удаления | +| Fullscreen editor | Один визуальный контракт публикации/ответа/редактирования с вариантами заголовка и контекста | + +Каждый компонент определяет normal, hover для указателя, pressed, focus-visible, disabled, loading и error, если они применимы. Selected и pressed различать: selected сохраняется после нажатия, pressed — краткий отклик. Выбор и ошибку не кодировать только цветом. + +Для клавиатуры соблюдать логичный порядок фокуса; у диалогов — семантика dialog, фокус внутри и возврат инициатору. Screen reader получает названия и состояния переключателей, общий счётчик лайков и информацию об отправке. Обновление ленты не должно каждый раз перехватывать фокус или зачитывать весь экран. + +### 11.1. Общая адаптация + +Телефоны 320–430 CSS px: одна колонка, боковые поля 16 px, никакого горизонтального скролла страницы. Горизонтальная прокрутка допустима только внутри явно обозначенной галереи вложений. Длинные слова/ссылки переносятся; имена и заголовки сокращаются только там, где предусмотрен доступ к полному тексту. + +При ширине 600 px и выше текстовая колонка не шире 640 px, центрируется; верхняя панель, лента и composer выровнены по её краям. Desktop-навигация использует существующий shell. Новую многоколоночную архитектуру приложения этот документ не требует. + +Safe area учитывается ровно один раз владельцем оболочки. При открытом редакторе нет двойной нижней панели. При увеличении текста до 200% допускается рост высот; кнопки не накладываются, действия не исчезают. Анимация открытия 150–200 мс; при reduced motion переход без движения. + +### 11.2. Применение к остальным разделам + +| Раздел | Что наследует | Что требует отдельного проектирования | +| --- | --- | --- | +| Личные сообщения | Палитры, текст, аватары, строки списка, вложения, поля и меню | Геометрия диалога, входящие/исходящие, статусы доставки/прочтения, звонки | +| Связи | Строки людей, профильные аватары, кнопки, пустые состояния | Запросы, типы отношений и правила доступности | +| Уведомления | Строки, типографика, счётчики, непрочитанное | Группировка, переход к объекту, состояния удалённого объекта | +| Профиль | TopBar, разделители, поля, кнопки и обе темы | Состав профиля, редактирование, настройки оформления | +| Вход/регистрация | Цвета, поля, ошибки и focus-стили | Чувствительные сценарии, последовательность шагов, тексты восстановления | +| Создание/настройка канала | Общие формы, редакторы, меню и подтверждения | Права, типы канала и специфические настройки | + +Наследование визуальной системы не означает изменения протоколов или применения ленты каналов к личному диалогу. Каждый следующий раздел добавляет свои сценарии в отдельный документ со ссылкой на эту основу, а не копирует собственные палитры и наборы кнопок. + +## 12. Допущения и отложенные решения + +Блокирующих вопросов для документа нет. Рабочие решения: тема по системе с ручным выбором, размеры из раздела 4, сохранение черновика внутри текущей сессии, единый редактор с адаптацией для desktop. + +До переноса проверить по коду матрицу прав всех типов каналов, доступность ответа для гостя/неподписанного, ограничения вложений и существующие переменные темы. Если для предложения нужны новые серверные данные, явно отметить зависимость и сохранить доступный сценарий; не расширять API автоматически. + +Позднее: статистика типов лайков и долгий тап; глобальный поиск по названию; постоянные черновики; подробные сценарии остальных разделов. Эти пункты не являются задачами текущего переноса. + +## 13. Перенос в приложение и критерии готовности + +На следующем этапе использовать существующие AppShell/TopBar/Dropdown/Avatar/Composer. Не копировать автономный JS из мокапа в production: он работает на демонстрационных данных и не реализует lifecycle приложения. + +Последовательность: сопоставить текущие токены и владельцев компонентов → подготовить обе палитры → реализовать общие варианты компонентов → перенести список/канал/ветку/ответ → закрыть состояния из раздела 10 → проверить устройства и темы → расширять на другие разделы по отдельным задачам. Перед реализацией удобно обновить автономный мокап выбранного сочетания; исходную галерею сохранять как историю выбора. + +Матрица приёмки для первого переноса: + +| Проверка | Ожидаемый результат | +| --- | --- | +| День ↔ ночь на каждом экране | Геометрия совпадает; нет чужих тёмных/светлых элементов и вспышки при старте | +| Переключение темы с открытым ответом | Текст, вложения, адресат, фокус и позиция не сбрасываются | +| Шапка канала | Компактная; нет баннера описания и кнопки оглавления вне меню | +| Роль читателя/владельца/гостя | Только доступные действия; нет ложного обещания писать в чужой канал | +| Лайки | Один общий счётчик; постановка/снятие; защита от повторного запроса; восстановление после ошибки | +| Оглавление | Доступно через меню, включая пустое состояние | +| Ответ на пост и ответ на комментарий | Верный адресат и цитата; полноэкранный редактор; отправка видна над клавиатурой | +| Длинный ответ и Enter | Перенос строки без отправки; поле и курсор доступны при прокрутке | +| Черновик | Закрытие/возврат сохраняет текст в текущей сессии; другой адресат не получает чужой текст | +| Вложения | Текст или вложение; прогресс/ошибка; отправка незавершённого файла недоступна | +| Навигация | Возврат сохраняет позицию; отправленный ответ виден; обновление не пересоздаёт страницу целиком | +| Загрузка/ошибка/пустота | Понятные отдельные состояния; введённые данные не теряются | +| 320 / 390 / 430 px, большой экран, 200% текст | Нет наложений и горизонтальной прокрутки страницы; все действия достижимы | +| Android / iOS, экранная клавиатура, поворот | Поле и отправка доступны; safe area не дублируется | +| Клавиатура / screen reader / reduced motion | Фокус видим и возвращается; семантика доступна; движение отключается | + +Все четыре экрана проверяются в обеих темах, включая меню, редактор, ошибку и пустое состояние. Тесты поведения не заменяют визуальную проверку на телефоне. + +Документ служит общим справочником приложения. Новые разделы описывают сценарии, владельцев компонентов, состояния и принятые решения, сохраняя принципы разделов 2, 4 и 11. Обнаруженные при реализации ограничения записывать как явные отклонения с причиной, а не незаметно создавать новый стиль страницы. + +## 14. Журнал решений + +| Дата | Версия | Решение | +| --- | --- | --- | +| 22.09.2026 | 0.1 | Четыре самостоятельных HTML-направления для обсуждения | +| 22.09.2026 | 0.2 | Пользователь выбрал Ночной SHiNE, дневную тёплую палитру, компактную шапку, полноэкранный ответ; общее число лайков, оглавление в меню; плотность принята | +| 22.09.2026 | 0.2 | Специфицированы семантические цвета двух тем, общие компоненты, состояния, адаптация, правила расширения и матрица приёмки. Это документация будущего переноса, не выполненное изменение приложения | diff --git a/docs/UI-Design/IMPLEMENTATION.md b/docs/UI-Design/IMPLEMENTATION.md new file mode 100644 index 00000000..dd69ed92 --- /dev/null +++ b/docs/UI-Design/IMPLEMENTATION.md @@ -0,0 +1,46 @@ +# Первый перенос дизайна каналов + +22 сентября 2026. Изменения в рабочем дереве; без коммита и деплоя. Основа — [DESIGN.md](DESIGN.md), версия 0.2. + +## Реализовано + +- Ночная и дневная семантические палитры; системный режим по умолчанию, ручной выбор в настройках, применение до первой отрисовки. Смена темы не перемонтирует страницу. +- Список каналов: плоские строки, превью, время и непрочитанное; видимые фильтры «Все / Подписки / Мои», создание и поиск. Поле фильтрует загруженный список; «По @автору» открывает существующий серверный поиск. +- Лента: компактная шапка, оглавление в меню, плоские публикации, общий лайк без промежуточного диалога, отдельные обсуждение и ответ. Технические данные, история и собственные редактирование/удаление — в меню. Удаление подтверждается с предупреждением о сохранении истории в блокчейне. +- Ветка: единый вид публикаций и ответов, контекст вложенного ответа без растущих горизонтальных отступов. Порядок данных и существующие пределы загрузки не менялись. +- Общий редактор публикации/ответа/редактирования: полный мобильный экран, desktop-панель до 640 px, три зоны, VisualViewport и safe area. Контекст ответа, растущее поле, вложения, счётчик, отправка, ошибка и очистка черновика. Enter — новая строка, Ctrl/Cmd+Enter — отправка, IME защищён. +- Черновики в памяти текущей вкладки, изолированные по аккаунту, цели и режиму. Для ответа цель — полная ссылка на сообщение (blockchain/номер/hash); один и тот же ответ восстанавливается из ленты и ветки. Тип новой публикации сохраняется вместе с текстом. После перезагрузки черновики не обещаются. +- Закреплённое действие публикации у владельца; закреплённый ответ в ветке. Гостевые действия обозначают необходимость входа. Позиции чтения сохраняются внутри текущей вкладки отдельно по аккаунту и экрану. +- Общие TopBar, Dropdown, Tabs, Avatar и Toolbar приведены к выбранному направлению. Стили общей галереи вложений перенесены из `features/chat.css` к владельцу `components/attachments.css`; логика DM и формат вложений не менялись. + +## Проверено автоматически + +Проверка: `shine-UI/channel-design-check.mjs`. Она исполняет настоящий DOM producer страниц и компонентов в JSDOM; транспорт/API изолированы, тест ничего не публикует. + +- Редактор: пустое состояние, Enter и Ctrl+Enter, IME, фокус/Tab/возврат инициатору, восстановление черновика, разделение аккаунтов, очистка после успеха, сохранение после ошибки, вложение, системный возврат и гостевой вход. +- Карточки канала/ветки: текст, общий лайк, контекст ответа, меню по авторству, повторный cleanup. +- Список/канал/ветка: успешная пустая загрузка, сохранение корневого элемента при refresh, идемпотентный cleanup, отсутствие DOM-изменений от позднего ответа API после dispose. +- Темы, сохранение позиции чтения, навигация Dropdown клавишами и возврат фокуса. +- `node --check` изменённых production JS, разрешение относительных импортов всех 135 JS, парсинг 17 изменённых CSS, отсутствие новых повторов селекторов внутри одного CSS-контекста и роста `!important`, корректность CSS manifest, `git diff --check`. +- Расчёт контраста токенов: основной текст/фон 15.34:1 ночью и 12.98:1 днём; вторичный текст/фон 7.45:1 и 5.26:1; вторичный текст/поверхность 6.55:1 и 5.54:1; текст основной кнопки/акцент 9.86:1 и 5.88:1. Это расчёт палитры, не полный аудит итогового CSS-cascade. + +Воспроизводимый запуск из корня продукта (Node 24): + +```bash +check_dir=$(mktemp -d) +npm install --prefix "$check_dir" --no-audit --no-fund --ignore-scripts jsdom postcss +SHINE_UI_TEST_DEPS="$check_dir/node_modules" node --experimental-vm-modules shine-UI/channel-design-check.mjs +git diff --check +``` + +Тестовые зависимости устанавливаются вне репозитория, в runtime приложения не добавляются. + +## Границы и оставшаяся приёмка + +API, формат блоков и операции записи не изменены. Категории лайков не выведены в интерфейс. Глобальный поиск по названию и фиктивный переключатель уведомлений канала не добавлены: рабочий серверный контракт для них не введён. + +Используется существующий менеджер загрузок Arweave с его статусами/ошибками. Пока менеджер открыт, редактор блокирует отправку незавершённого вложения. Закрытие редактора закрывает менеджер, но не гарантирует отмену уже переданного транспортному слою upload-запроса; новая транспортная отмена и процент прогресса не заявляются. + +Подключённого браузера в данной сессии нет: встроенный browser-инструмент вернул пустой список. Поэтому **визуальная приёмка не выполнена**. DOM-тесты не подтверждают реальные размеры, CSS-cascade, экранную клавиатуру, screen reader или сетевую публикацию. До выпуска необходимо пройти матрицу раздела 13 DESIGN.md: обе темы, 320/390/430 px и desktop, 200% текст, длинные сообщения/имена, вложения, Android/iOS, поворот, safe area, ошибки реального сервера и восстановление фокуса после фактического обновления ленты. Также проверить соседние экраны, использующие изменённые общие компоненты. + +Локальный запуск приложения по правилам проекта: `./gradlew startLocal`. Production не затрагивался. diff --git a/docs/UI-Design/README.md b/docs/UI-Design/README.md new file mode 100644 index 00000000..acc40aa4 --- /dev/null +++ b/docs/UI-Design/README.md @@ -0,0 +1,41 @@ +# Дизайн SHiNE + +Статус: направление выбрано, спецификация 0.2 от 22.09.2026. Основа — Ночной SHiNE; дневная тема — тёплая палитра; компактная шапка канала; полноэкранный ответ. У лайков только общий счётчик, оглавление в меню. + +- [Общий дизайн-документ](DESIGN.md) — актуальная спецификация выбранного стиля, обе темы, общие компоненты, сценарии и критерии приёмки. +- [Открыть исходную HTML-галерею](channels-v1/index.html) — четыре варианта, четыре экрана в каждом; история выбора. + +Галерея сохранена без изменений и ещё не отражает итоговое сочетание решений. Для последующего переноса источником истины служит `DESIGN.md`, а не детали одного старого варианта. Код приложения не менялся. + +## Как посмотреть + +Откройте `channels-v1/index.html` в обычном браузере. Сервер, сборка, вход в аккаунт и интернет не нужны. Стили и JavaScript лежат рядом, поэтому при передаче макетов сохраняйте всю папку `channels-v1`. + +Вверху переключаются экраны всех вариантов одновременно. Стрелка возле названия открывает один вариант. На узком экране галерея выстраивается вертикально. + +Отдельные варианты: + +1. [Чистый монохром](channels-v1/index.html?variant=mono). +2. [Тёплый SHiNE](channels-v1/index.html?variant=warm). +3. [Ночной SHiNE](channels-v1/index.html?variant=night). +4. [Компактный клуб](channels-v1/index.html?variant=club). + +## Что попробовать + +1. Нажать на канал и открыть обсуждение через значок комментариев. +2. Ответить на исходное сообщение или конкретному собеседнику. +3. Набрать ответ, закрыть редактор, открыть его снова: черновик сохраняется в памяти страницы. +4. Прикрепить файл, удалить вложение, отправить текст или только вложение. +5. Включить «Макет клавиатуры», чтобы оценить оставшееся место. Это иллюстрация, настоящая клавиатура телефона работает отдельно. +6. Переключить роль на владельца: в канале появляется постоянная кнопка публикации. +7. Проверить поиск и фильтры списка; открыть оглавление и меню сообщения. + +Все действия демонстрационные. Выбранный файл не читается и никуда не загружается: отображается только имя. После перезагрузки страницы демонстрационные сообщения, реакции и черновики исчезают. Уведомления, подписка и поиск не обращаются к API. Другие разделы нижней навигации показывают пояснение о границах макета. + +## Границы + +Макеты намеренно изолированы от `shine-UI`: они не импортируют приложение и не участвуют в его сборке. Здесь допускаются экспериментальные компоненты; при переносе утверждённого дизайна нужно использовать существующие владельцы AppShell, TopBar, Dropdown, Avatar и Composer. + +## Проверка + +Результаты проверки и ограничения приведены в [VALIDATION.md](VALIDATION.md). Визуальная проверка в браузере в текущей сессии недоступна: подключённых браузеров нет. Это рабочие HTML-макеты для обсуждения, не проверенная на устройствах реализация приложения. diff --git a/docs/UI-Design/VALIDATION.md b/docs/UI-Design/VALIDATION.md new file mode 100644 index 00000000..8a548db4 --- /dev/null +++ b/docs/UI-Design/VALIDATION.md @@ -0,0 +1,35 @@ +# Проверка HTML-мокапов + +Дата: 22.09.2026. + +## Выполнено + +- `node --check channels-v1/mockups.js` — синтаксис JavaScript корректен. +- CSS разобран PostCSS без ошибок синтаксиса. +- В изолированном jsdom проверены все четыре направления: начальная отрисовка, поиск и отсутствие результатов, фильтр «Мои», открытие канала, роли читателя/владельца, переключение лайка, открытие ветки, ответ конкретному участнику, неактивная пустая отправка, сохранение черновика при закрытии, успешная отправка, независимый контекст второй публикации. +- Проверено, что HTML-подобный текст ответа отображается как текст, без создания пользовательских HTML-элементов. +- Проверены выбор/удаление вложения и возможность отправить вложение без текста, переключение макета клавиатуры, Escape и снятие `inert` с основного содержимого. +- Проверены публикация владельца и её сохранение при переключении роли, отдельное открытие каждого из четырёх вариантов через `?variant=`. +- После последних изменений повторены синтаксическая проверка и проверки взаимодействий — успешно. +- Git показывает только новую папку `docs/UI-Design/`. Существующие файлы приложения не изменялись. + +Инструменты DOM-проверки и временный сценарий установлены в `/tmp/shine-ui-review-gporxy`, зависимости приложения не менялись. + +## Ограничения проверки + +Подключённый Browser недоступен: runtime сообщил `No browser is available`, список браузеров пуст. Скриншоты не получены. jsdom проверяет структуру и поведение, но не рассчитывает реальную геометрию и не заменяет визуальную проверку браузером. + +Перед переносом в приложение обязательна проверка в реальном браузере: ширины 320/390/430 px, увеличение текста, длинные названия, прокрутка и восстановление позиции, экранная клавиатура Android/iOS, фокус и screen reader, контраст, состояния загрузки и ошибки. Эмуляция клавиатуры в галерее показывает только занятое ею место. + +Никакие серверные операции, публикации, загрузки файлов или изменения production не выполнялись. + +## Уточнение документа 0.2 + +22.09.2026, после выбора пользователем направления: + +- Обновлены `DESIGN.md` и `README.md`; HTML-галерея оставлена историческим референсом, это явно отмечено в обоих документах. +- Убраны устаревшие вопросы о выборе темы/редактора и указания показывать статистику лайков или оглавление вне меню. +- Добавлены единые компоненты, цвета двух тем, компактная шапка, полноэкранный редактор, состояния и матрица приёмки будущего переноса. +- Локально рассчитан контраст основных пар цветов. Основной текст/фон: ночь 15.34:1, день 12.98:1. Вторичный текст/фон: ночь 7.45:1, день после коррекции 5.26:1. Вторичный текст/выбранная поверхность: ночь 5.10:1, день 4.55:1. Текст акцентной кнопки: ночь 9.86:1, день 5.88:1. Граница контрола/поверхность: ночь 4.10:1, день 3.29:1. +- Эти числа относятся к непрозрачным цветам токенов. Проверка реальных CSS-состояний, прозрачностей, наложений, всех статусных цветов и геометрии потребуется при реализации; полного аудита доступности пока нет. +- Изменения документа не означают обновления приложения или визуальной проверки итогового сочетания. diff --git a/docs/UI-Design/channels-v1/index.html b/docs/UI-Design/channels-v1/index.html new file mode 100644 index 00000000..f74d5db0 --- /dev/null +++ b/docs/UI-Design/channels-v1/index.html @@ -0,0 +1,34 @@ + + + + + + SHiNE — четыре направления UI + + + + +
+ ✳ SHiNEDESIGN STUDY / 01 + Концепции · сентябрь 2026 +
+
+
+
КАНАЛЫ / СООБЩЕНИЯ / ОТВЕТЫ

Ближе к разговору.

Четыре характера. Один привычный путь от канала к ответу.
Выберите экран и сравните — или нажмите на элементы внутри макета.

+
01—04

Меньше визуального шума.
Больше места людям и мыслям.

+
+
+
+ + + + +
+
Все варианты ↗
+
+ +
С ЧЕГО НАЧАТЬ ОБСУЖДЕНИЕ

Сначала удобство. Затем характер.

01 / ЧитаемостьГде комфортнее читать длинный пост и замечать новый ответ?

02 / ПлотностьНужны просторные сообщения или больше содержимого на одном экране?

03 / Окно ответаПолный экран или лист поверх ветки? Проверьте с макетом клавиатуры.

Демонстрационные данные. Отправка и реакции работают только внутри этой страницы. Поиск фильтрует примеры. Другие разделы приложения не проектировались. Дизайн-документ ↗

+
+
SHiNE / пространство для общенияРабочий материал · не финальный дизайн
+ + diff --git a/docs/UI-Design/channels-v1/mockups.css b/docs/UI-Design/channels-v1/mockups.css new file mode 100644 index 00000000..c55409c9 --- /dev/null +++ b/docs/UI-Design/channels-v1/mockups.css @@ -0,0 +1,212 @@ +* { box-sizing: border-box; } +:root { color-scheme: light; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #242622; background: #f1f0eb; } +body { margin: 0; } +button, input, textarea, select { font: inherit; } +button, a, select { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +button:disabled { cursor: default; opacity: .4; } +button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid #728de3; outline-offset: 3px; } +a { color: inherit; } +.studio-header { margin: 0 42px; padding: 26px 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #d5d6ce; gap: 16px; } +.brand { display: flex; align-items: center; gap: 9px; font-size: 25px; font-weight: 760; text-decoration: none; letter-spacing: -1px; } +.brand-spark { font-size: 36px; font-weight: 400; } +.brand-caption { margin-left: 22px; font-size: 10px; letter-spacing: 2px; font-weight: 600; } +.draft-label { font-size: 12px; color: #63675f; } +main { padding: 0 42px; max-width: 1900px; margin: auto; } +.intro { display: flex; justify-content: space-between; align-items: end; padding: 48px 0 34px; gap: 30px; } +.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: 1.8px; } +h1 { font-size: clamp(34px, 4vw, 60px); line-height: 1.1; letter-spacing: -2.8px; font-weight: 550; margin: 16px 0; } +.intro p { color: #62665d; font-size: 14px; line-height: 1.7; margin: 0; } +.intro-note { border-left: 1px solid #c6c8bd; padding-left: 24px; min-width: 250px; } +.intro-note > span { font-size: 38px; letter-spacing: -2px; } +.intro-note p { font-size: 12px; margin-top: 10px; } +.review-bar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; padding: 16px 0; border-top: 1px solid #d5d6ce; border-bottom: 1px solid #d5d6ce; } +.screen-switch { display: flex; flex-wrap: wrap; gap: 4px; } +.review-button { border: 0; padding: 11px 14px; background: transparent; border-radius: 7px; font-size: 12px; color: #62665d; min-height: 42px; } +.review-button.selected { background: #292d27; color: white; } +.review-options { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; font-size: 12px; color: #62665d; } +.review-options label { display: flex; align-items: center; gap: 6px; } +.review-options select { padding: 8px; border: 1px solid #d5d6ce; border-radius: 7px; background: transparent; color: #242622; } +.compare-link { display: none; } +.gallery { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; padding: 30px 0 36px; align-items: start; } +.concept { min-width: 0; } +.concept-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.concept-number { font-size: 11px; border: 1px solid #c9cdc2; border-radius: 50%; width: 27px; height: 27px; display: grid; place-items: center; } +.concept-heading h2 { font-size: 17px; font-weight: 600; margin: 0; letter-spacing: -.5px; } +.open-concept { margin-left: auto; padding: 10px; text-decoration: none; font-size: 18px; } +.concept-description { font-size: 12px; line-height: 1.6; color: #6b7065; min-height: 40px; margin: 0 0 18px; } +.phone { --bg: #111212; --surface: #1c1d1d; --text: #f3f3f1; --muted: #a0a2a0; --line: #2c2d2d; --accent: #f3f3f1; --on-accent: #161717; --soft: #252727; position: relative; display: flex; flex-direction: column; height: 760px; width: 100%; max-width: 410px; margin: auto; color: var(--text); background: var(--bg); border: 1px solid #bfc2b8; border-radius: 32px; overflow: hidden; box-shadow: 0 14px 35px #252d2210; font-size: 14px; line-height: 1.45; isolation: isolate; } +.phone[data-theme="warm"] { --bg: #faf7f0; --surface: #fffdf8; --text: #302c25; --muted: #7b7264; --line: #e6dfd1; --accent: #93511e; --on-accent: #fffaf3; --soft: #efe6d6; } +.phone[data-theme="night"] { --bg: #101b20; --surface: #17272d; --text: #e8f2f1; --muted: #98adaf; --line: #2a3b40; --accent: #94e1ce; --on-accent: #102c27; --soft: #213b3a; } +.phone[data-theme="club"] { --bg: #f9fafb; --surface: #fff; --text: #202b3c; --muted: #6b7688; --line: #e2e7ee; --accent: #4c5caa; --on-accent: #fff; --soft: #eaeefa; } +.status-bar { display: flex; justify-content: space-between; padding: 15px 24px 6px; font-size: 11px; font-weight: 700; flex: 0 0 38px; } +.status-icons { display: flex; gap: 5px; align-items: center; font-size: 10px; } +.icon { height: 21px; width: 21px; display: inline-block; vertical-align: middle; flex-shrink: 0; } +.status-icons .icon { height: 16px; width: 16px; } +.phone button { color: inherit; } +.icon-btn { background: transparent; border: 0; width: 44px; height: 44px; flex: 0 0 44px; display: inline-grid; place-items: center; border-radius: 50%; padding: 0; } +.icon-btn:hover { background: var(--soft); } +.app-header { display: flex; align-items: center; padding: 4px 12px 10px; gap: 6px; min-height: 66px; border-bottom: 1px solid var(--line); } +.header-text { flex: 1; min-width: 0; } +.header-text h3 { font-size: 17px; letter-spacing: -.4px; margin: 0; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.header-text small { color: var(--muted); font-size: 10px; } +.list-header { padding-left: 22px; } +.list-header h3 { font-size: 25px; letter-spacing: -.9px; } +.mini-brand { color: var(--accent); font-size: 26px; line-height: 1; margin-right: 3px; } +.view { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--line) transparent; overscroll-behavior: contain; } +.search-field { margin: 15px 18px 10px; display: flex; gap: 9px; align-items: center; background: var(--surface); border-radius: 12px; padding: 0 12px; border: 1px solid var(--line); color: var(--muted); } +.search-field input { width: 100%; min-width: 0; border: 0; background: transparent; height: 42px; outline-offset: 0; color: var(--text); font-size: 13px; } +.search-field input::placeholder { color: var(--muted); } +.filter-tabs { display: flex; padding: 0 18px 12px; gap: 7px; } +.filter { font-size: 11px; padding: 0 11px; min-height: 36px; background: transparent; border: 1px solid transparent; border-radius: 20px; color: var(--muted); } +.filter[aria-pressed="true"] { color: var(--text); background: var(--soft); } +.section-label { padding: 10px 20px 7px; display: flex; justify-content: space-between; text-transform: uppercase; letter-spacing: 1.3px; font-size: 9px; font-weight: 700; color: var(--muted); } +.channel-row { display: flex; width: 100%; align-items: center; gap: 12px; padding: 17px 18px; border: 0; border-bottom: 1px solid var(--line); background: transparent; text-align: left; } +.channel-row:hover { background: var(--soft); } +.avatar { width: 40px; height: 40px; border-radius: 50%; background: #bdc5a8; color: #283124; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; font-size: 13px; font-weight: 650; letter-spacing: -.5px; } +.avatar.city { background: #ded3bb; color: #635039; } +.avatar.blue { background: #bac5d9; color: #354663; } +.avatar.rose { background: #d9bcbb; color: #704444; } +.avatar.green { background: #b9cdc0; color: #345747; } +.avatar.violet { background: #cbc2d9; color: #56476c; } +.avatar.self { background: var(--soft); color: var(--accent); } +.channel-row .avatar { width: 48px; height: 48px; font-size: 23px; } +.channel-copy { min-width: 0; flex: 1; } +.channel-copy strong { display: block; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.channel-copy small { display: block; color: var(--muted); font-size: 10px; margin: 2px 0; } +.channel-copy p { margin: 3px 0 0; font-size: 12px; color: var(--muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.channel-tail { display: flex; align-items: end; flex-direction: column; gap: 11px; align-self: stretch; padding-top: 2px; color: var(--muted); font-size: 10px; } +.badge { min-width: 20px; height: 20px; background: var(--accent); color: var(--on-accent); padding: 2px 6px; border-radius: 10px; text-align: center; font-size: 10px; font-weight: 700; } +.list-tip { padding: 24px 24px 16px; color: var(--muted); font-size: 11px; text-align: center; } +.bottom-nav { border-top: 1px solid var(--line); display: flex; justify-content: space-around; flex: 0 0 73px; align-items: start; padding: 9px 6px 12px; background: var(--bg); } +.nav-item { border: 0; background: transparent; display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 8px; width: 20%; min-height: 44px; opacity: .65; } +.nav-item[aria-current="page"] { opacity: 1; color: var(--accent); } +.nav-item[aria-current="page"] .icon { background: var(--soft); border-radius: 7px; box-shadow: 0 0 0 5px var(--soft); } +.channel-intro { margin: 0; padding: 18px 20px 14px; border-bottom: 1px solid var(--line); } +.channel-intro p { margin: 0 0 12px; color: var(--muted); font-size: 12px; line-height: 1.6; } +.channel-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.text-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; padding: 0 4px; min-height: 36px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 600; } +.text-button .icon { width: 16px; height: 16px; } +.pill { border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 8px 11px; font-size: 10px; min-height: 36px; } +.unread-line { display: flex; align-items: center; gap: 10px; color: var(--accent); font-size: 9px; padding: 13px 20px 0; } +.unread-line::before, .unread-line::after { content: ""; height: 1px; flex: 1; background: var(--line); } +.post { padding: 18px 18px 10px; border-bottom: 1px solid var(--line); } +.post-header { display: flex; align-items: center; gap: 9px; } +.post-header .avatar { width: 34px; height: 34px; font-size: 11px; } +.author { min-width: 0; flex: 1; font-size: 12px; } +.author strong { font-weight: 650; } +.author small { color: var(--muted); font-size: 10px; display: block; } +.post .more { width: 36px; height: 36px; flex-basis: 36px; color: var(--muted); } +.post p { font-size: 14px; line-height: 1.6; margin: 10px 0 12px; overflow-wrap: anywhere; } +.post-title { font-weight: 600; } +.landscape { height: 142px; overflow: hidden; border-radius: 12px; position: relative; background: #c9d6cf; margin: 10px 0 8px; } +.landscape svg { width: 100%; height: 100%; display: block; } +.media-caption { position: absolute; bottom: 10px; left: 12px; font-size: 9px; letter-spacing: 1px; color: #fff; text-transform: uppercase; } +.post-actions { display: flex; gap: 14px; align-items: center; color: var(--muted); } +.action { border: 0; background: transparent; min-height: 44px; display: flex; align-items: center; gap: 6px; padding: 0 2px; font-size: 11px; color: var(--muted); } +.action[aria-pressed="true"] { color: #dc7477; } +.action[aria-pressed="true"] .icon { fill: currentColor; } +.reply-link { margin-left: auto; font-size: 10px; } +.attachment { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); background: var(--surface); border-radius: 12px; padding: 12px; margin: 10px 0; } +.attachment > .icon { color: var(--accent); } +.attachment strong { font-size: 11px; display: block; } +.attachment small { color: var(--muted); display: block; font-size: 10px; } +.composer-bar { display: flex; align-items: center; padding: 10px 16px; gap: 10px; border-top: 1px solid var(--line); background: var(--bg); flex: 0 0 auto; } +.composer-trigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 46px; padding: 10px 14px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); border-radius: 24px; text-align: left; font-size: 12px; } +.composer-trigger.primary { background: var(--accent); color: var(--on-accent); border-color: transparent; justify-content: center; font-weight: 650; } +.thread-heading { padding: 13px 20px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; border-bottom: 1px solid var(--line); } +.thread-heading strong { color: var(--text); } +.reply { display: flex; padding: 16px 18px 0; gap: 10px; } +.reply-body { flex: 1; min-width: 0; padding-bottom: 9px; border-bottom: 1px solid var(--line); } +.reply .avatar { width: 32px; height: 32px; font-size: 10px; } +.reply p { font-size: 13px; margin: 6px 0 2px; line-height: 1.55; overflow-wrap: anywhere; } +.reply small { color: var(--muted); font-size: 10px; } +.reply strong { font-size: 12px; } +.reply .post-actions { gap: 16px; } +.reply-context { color: var(--accent); font-size: 10px; } +.empty { padding: 40px 25px; text-align: center; color: var(--muted); font-size: 13px; } +.overlay { position: absolute; inset: 38px 0 0; z-index: 5; background: #0009; display: flex; flex-direction: column; justify-content: end; } +.sheet { display: flex; flex-direction: column; background: var(--bg); max-height: 100%; min-height: 78%; border-radius: 24px 24px 0 0; overflow: hidden; box-shadow: 0 -8px 40px #0002; } +.sheet.full { height: 100%; border-radius: 0; } +.sheet-handle { width: 32px; height: 4px; border-radius: 4px; background: var(--line); align-self: center; margin-top: 9px; } +.sheet-header { display: flex; align-items: center; padding: 7px 12px; border-bottom: 1px solid var(--line); gap: 6px; } +.sheet-header h4 { margin: 0; flex: 1; text-align: center; font-size: 15px; } +.sheet-header .expand { color: var(--muted); } +.editor-scroll { overflow-y: auto; flex: 1; min-height: 0; padding: 18px; } +.source-preview { display: flex; gap: 10px; padding-bottom: 20px; position: relative; } +.source-preview::after { content: ""; position: absolute; left: 19px; top: 46px; bottom: 3px; width: 1px; background: var(--line); } +.source-preview p { font-size: 12px; color: var(--muted); margin: 5px 0; line-height: 1.5; } +.source-preview strong { font-size: 12px; } +.source-preview small { color: var(--muted); font-size: 10px; } +.editor-row { display: flex; gap: 10px; } +.editor-main { flex: 1; min-width: 0; } +.editor-main strong { display: block; font-size: 12px; margin: 2px 0; } +.editor-main > small { color: var(--muted); display: block; font-size: 10px; margin: 3px 0 10px; } +.editor-input { resize: none; border: 0; width: 100%; min-height: 115px; background: transparent; color: var(--text); font-size: 15px; line-height: 1.6; padding: 3px 0; } +.editor-input::placeholder { color: var(--muted); } +.editor-input:focus-visible { outline: none; box-shadow: 0 2px 0 var(--accent); } +.editor-footer { display: flex; align-items: center; gap: 4px; padding: 11px 16px 16px; border-top: 1px solid var(--line); background: var(--bg); } +.char-count { color: var(--muted); font-size: 10px; margin-left: auto; margin-right: 10px; } +.submit { border: 0; border-radius: 22px; padding: 12px 18px; background: var(--accent); color: var(--on-accent); font-size: 12px; font-weight: 650; min-height: 44px; } +.phone .submit { color: var(--on-accent); } +.editor-attachment { margin-top: 10px; display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: var(--soft); border-radius: 10px; font-size: 10px; } +.editor-attachment span { flex: 1; overflow-wrap: anywhere; } +.keyboard { flex: 0 0 auto; background: var(--surface); padding: 10px 4px 18px; border-top: 1px solid var(--line); } +.keyboard-label { font-size: 9px; text-align: center; color: var(--muted); margin-bottom: 10px; } +.keys { display: flex; justify-content: center; gap: 3px; margin: 5px 0; } +.keys span { display: grid; place-items: center; height: 31px; flex: 1; max-width: 29px; border-radius: 5px; font-size: 13px; color: var(--text); background: var(--soft); box-shadow: 0 1px 0 #0003; } +.keys .space { max-width: 150px; flex: 5; font-size: 10px; } +.keys .wide { max-width: 45px; flex: 1.7; font-size: 11px; } +.menu-sheet { min-height: 0; padding-bottom: 24px; } +.menu-content { padding: 10px 20px; } +.menu-action { border: 0; background: transparent; width: 100%; text-align: left; display: flex; gap: 12px; align-items: center; min-height: 47px; font-size: 13px; border-bottom: 1px solid var(--line); } +.menu-content p { color: var(--muted); font-size: 12px; line-height: 1.6; } +.toast { position: absolute; bottom: 86px; left: 18px; right: 18px; padding: 12px 15px; border-radius: 12px; background: var(--text); color: var(--bg); z-index: 10; font-size: 12px; box-shadow: 0 4px 20px #0003; } +.concept-notes { margin-top: 19px; font-size: 12px; color: #666d61; line-height: 1.65; } +.concept-notes strong { color: #33392f; font-weight: 600; } +.swatches { display: flex; gap: 5px; margin-bottom: 12px; } +.swatches span { width: 19px; height: 19px; border-radius: 50%; border: 1px solid #0002; } +.concept-tag { display: inline-block; border: 1px solid #c9cdc2; padding: 3px 7px; border-radius: 4px; font-size: 9px; letter-spacing: .3px; margin-bottom: 8px; } +.decision-note { padding: 36px 0; border-top: 1px solid #d5d6ce; } +.decision-note h2 { font-weight: 500; letter-spacing: -.7px; font-size: 27px; margin: 13px 0; } +.decision-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 38px; } +.decision-grid p { font-size: 13px; line-height: 1.7; color: #62665d; } +.decision-grid b { display: block; font-weight: 600; color: #33392f; margin-bottom: 6px; } +.disclaimer { font-size: 11px; color: #62665d; line-height: 1.7; margin-top: 24px; } +.studio-footer { display: flex; justify-content: space-between; margin: 0 42px; padding: 22px 0; border-top: 1px solid #d5d6ce; color: #73796b; font-size: 10px; gap: 15px; } +/* Варианты отличаются также плотностью, иерархией и формой редактора. */ +.phone[data-theme="mono"] .post { padding-left: 18px; } +.phone[data-theme="mono"] .post-content { margin-left: 43px; } +.phone[data-theme="mono"] .landscape { height: 128px; } +.phone[data-theme="warm"] .channel-row { margin: 0 12px 8px; width: calc(100% - 24px); border: 1px solid var(--line); border-radius: 16px; padding: 14px 12px; background: var(--surface); } +.phone[data-theme="warm"] .channel-intro { background: var(--soft); margin: 14px 16px 0; border: 0; border-radius: 16px; } +.phone[data-theme="warm"] .post { margin: 14px 12px; padding: 15px 14px 7px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); } +.phone[data-theme="warm"] .post-title { font-family: Georgia, serif; font-size: 20px; line-height: 1.3; } +.phone[data-theme="warm"] .reply .avatar { border-radius: 12px; } +.phone[data-theme="night"] .list-header { border-bottom: 0; padding-top: 12px; } +.phone[data-theme="night"] .channel-intro { background: linear-gradient(125deg, #1d3937, #16272e); border-bottom: 1px solid #36544f; } +.phone[data-theme="night"] .channel-row { border-bottom-color: #21343b; } +.phone[data-theme="night"] .channel-row:first-child { background: #172b2e; box-shadow: inset 3px 0 var(--accent); } +.phone[data-theme="night"] .composer-trigger { border-radius: 13px; } +.phone[data-theme="night"] .post-title { font-size: 17px; font-weight: 550; } +.phone[data-theme="club"] .channel-row { padding: 12px 16px; gap: 10px; } +.phone[data-theme="club"] .channel-row .avatar { width: 42px; height: 42px; border-radius: 13px; } +.phone[data-theme="club"] .channel-copy small { display: none; } +.phone[data-theme="club"] .filter-tabs { border-bottom: 1px solid var(--line); padding-bottom: 0; gap: 12px; } +.phone[data-theme="club"] .filter { border-radius: 0; padding: 0 1px; } +.phone[data-theme="club"] .filter[aria-pressed="true"] { background: transparent; color: var(--accent); border-bottom: 2px solid var(--accent); } +.phone[data-theme="club"] .post { padding: 12px 16px 5px; } +.phone[data-theme="club"] .post p { font-size: 13px; margin: 7px 0; } +.phone[data-theme="club"] .landscape { height: 108px; } +.phone[data-theme="club"] .channel-intro { padding: 12px 16px 8px; } +.phone[data-theme="club"] .channel-intro p { margin-bottom: 5px; } +.phone[data-theme="club"] .reply { padding-top: 12px; } +.phone[data-theme="club"] .reply-body { border-left: 2px solid var(--line); border-bottom: 0; padding-left: 12px; } +.phone[data-theme="club"] .composer-trigger { border-radius: 10px; } +.gallery.single { display: block; max-width: 410px; margin: auto; } +.single .concept-description { min-height: 0; } +body.focused .compare-link { display: inline; } +@media (max-width: 1490px) { .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 900px; margin: auto; gap: 40px; } .concept { max-width: 410px; width: 100%; justify-self: center; } } +@media (max-width: 760px) { .studio-header { margin: 0 20px; padding: 18px 0; } main { padding: 0 20px; } .brand-caption, .draft-label, .intro-note { display: none; } .intro { padding: 30px 0 24px; } h1 { letter-spacing: -1.5px; } .intro p { font-size: 13px; } .gallery { grid-template-columns: minmax(0, 1fr); gap: 32px; } .screen-switch { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } .review-button { padding: 10px; } .review-options { gap: 12px; } .phone { height: 760px; border-radius: 25px; } .decision-grid { grid-template-columns: 1fr; gap: 0; } .studio-footer { margin: 0 20px; } } +@media (max-width: 380px) { main { padding: 0 10px; } .post-actions { gap: 10px; } .phone { font-size: 13px; } .channel-row { padding-left: 12px; padding-right: 12px; gap: 8px; } .header-text h3 { font-size: 15px; } .concept-heading h2 { font-size: 16px; } } +@media (prefers-reduced-motion: no-preference) { .sheet { animation: appear .18s ease-out; } @keyframes appear { from { transform: translateY(22px); opacity: .5; } to { transform: translateY(0); opacity: 1; } } } diff --git a/docs/UI-Design/channels-v1/mockups.js b/docs/UI-Design/channels-v1/mockups.js new file mode 100644 index 00000000..8c97645d --- /dev/null +++ b/docs/UI-Design/channels-v1/mockups.js @@ -0,0 +1,246 @@ +/* Изолированный прототип: без API, импортов приложения и постоянного хранилища. */ +'use strict'; +const icons = { + back: '', + close: '', + more: '', + search: '', + plus: '', + heart: '', + chat: '', + share: '', + channels: '', + people: '', + bell: '', + user: '', + book: '', + file: '', + attach: '', + image: '', + expand: '', + check: '', + edit: '', + wifi: '', + battery: '', +}; +const icon = name => ``; +const escapeText = value => String(value).replace(/[&<>"']/g, c => ({'&':'&', '<':'<', '>':'>', '"':'"', "'":'''}[c])); +const button = (action, label, name, extra = '') => ``; +const concepts = [ + { id:'mono', number:'01', title:'Чистый монохром', description:'Контент на первом плане. Знакомый ритм Threads.', colors:['#111212','#2c2d2d','#f3f3f1'], tag:'МАКСИМАЛЬНО ПРИВЫЧНО', note:'Плоская лента, аватар на полях и полноэкранный ответ. Самая нейтральная отправная точка.' }, + { id:'warm', number:'02', title:'Тёплый SHiNE', description:'Бумажные оттенки. Больше воздуха и мягкости.', colors:['#faf7f0','#efe6d6','#93511e'], tag:'МЯГКИЙ И ЛИЧНЫЙ', note:'Карточки отделяют разговоры, тёплый акцент выделяет действия. Ответ открывается округлым листом снизу.' }, + { id:'night', number:'03', title:'Ночной SHiNE', description:'Глубокий синий. Спокойный свет в деталях.', colors:['#101b20','#213b3a','#94e1ce'], tag:'ХАРАКТЕР SHiNE', note:'Широкий текст и выразительная шапка канала. Мой кандидат на развитие: узнаваемый, но сдержанный.' }, + { id:'club', number:'04', title:'Компактный клуб', description:'Больше разговоров на одном экране.', colors:['#f9fafb','#eaeefa','#4c5caa'], tag:'ПЛОТНО И СОБРАННО', note:'Компактные строки, подчёркнутые вкладки и линии ответов. Редактор на весь экран, без потери контекста.' }, +]; +const channels = [ + { title:'Город и люди', owner:'anna', slug:'city', symbol:'⌂', color:'city', preview:'Маленькие открытия по дороге домой', time:'12 мин', unread:3, following:true }, + { title:'Дизайн каждый день', owner:'mikhail', slug:'design', symbol:'Aa', color:'blue', preview:'Как сделать сложное простым?', time:'38 мин', unread:7, following:true }, + { title:'Тихие маршруты', owner:'lena', slug:'walks', symbol:'↗', color:'green', preview:'Сохранили маршрут на выходные', time:'1 ч', unread:2, following:true }, + { title:'Книжная полка', owner:'olga', slug:'books', symbol:'≋', color:'rose', preview:'Что сейчас читаете?', time:'2 ч', unread:0, following:true }, + { title:'SHiNE · новости', owner:'shine', slug:'news', symbol:'✳', color:'violet', preview:'Место, где начинаются разговоры', time:'вчера', unread:0, following:false }, + { title:'Мои заметки', owner:'you', slug:'notes', symbol:'✎', color:'blue', preview:'Идея для следующей встречи', time:'вчера', unread:0, following:false }, +]; +let role = 'reader'; +let showKeyboard = false; +const states = new Map(); +const mainText = 'Иногда лучший маршрут — чуть длиннее обычного. Сегодня свернула к реке и нашла это место. А где вы перезагружаетесь после рабочего дня?'; +const secondText = 'Собрала наши любимые места в один список. Добавляйте свои находки в ответах!'; +const landscape = () => ``; +function header(s) { + if (s.screen === 'list') return `
✳

Каналы

${button('new-channel','Создать канал','plus')}
`; + return `
${button('back','Назад','back')}

${s.screen === 'thread' ? 'Обсуждение' : escapeText(s.channel.title)}

${s.screen === 'thread' ? escapeText(s.channel.title) : '@'+escapeText(s.channel.owner)+' / '+escapeText(s.channel.slug)}
${button('notifications',s.muted ? 'Включить уведомления' : 'Настроить уведомления','bell')}${button('channel-menu','Меню канала','more')}
`; +} +function nav() { + return ``; +} +function rows(s) { + const filtered = channels.filter(c => (s.filter!=='mine'||c.owner==='you') && (s.filter!=='following'||c.following) && `${c.title} ${c.owner}`.toLowerCase().includes(s.query.toLowerCase())); + return filtered.length ? filtered.map(c=>``).join('') : '

Каналы не найдены.
Попробуйте другое название или логин.

'; +} +function list(s) { + return `
${[['all','Все'],['following','Подписки'],['mine','Мои']].map(([value,label])=>``).join('')}
${rows(s)}

Хорошие разговоры начинаются
с общих интересов.

`; +} +function actions(s, id, count, replies) { + const liked = s.likes.has(id); + replies = 2 + s.replies.filter(reply => reply.post === id).length; + return `
`; +} +function post(s, second = false) { + return `
АК
${escapeText(s.channel.owner==='anna'?'Анна К.':s.channel.owner)}${second?'вчера, 19:10':'12 минут назад'}
${second?'

Собрала наши любимые места в один список. Добавляйте свои находки в ответах!

'+icon('file')+'
Места для прогулок.pdfPDF · 552 КБ
':`

Маленькие открытия

${mainText}

${landscape()}`}${actions(s, second?'second':'first',second?12:24, second?3:2+s.replies.length)}
`; +} +function feed(s) { + return `

Замечаем красоту рядом. Делимся местами,
историями и маленькими открытиями.

Новые сообщения
${post(s)}${post(s,true)}`; +} +function replyRow(name, initials, color, text, target='') { + return `
${initials}
${target?`
В ответ ${escapeText(target)}
`:''}${escapeText(name)} · сейчас

${escapeText(text)}

`; +} +function thread(s) { + const replies=s.replies.filter(r=>r.post===s.activePost); + const second=s.activePost==='second'; + return `${post(s,second)}
Ответы · ${2+replies.length}По порядку
${replyRow('Михаил','МЛ','blue',second?'Спасибо за список! Добавлю ещё парк у старого моста.':'У воды всегда легче выдохнуть. Это возле старого моста?')}${replyRow('Лена','ЛС','green',second?'В выходные попробую один из маршрутов.':'А я ухожу гулять без наушников. Город звучит совсем иначе.')}${replies.map(r=>replyRow('Вы','ВЫ','self',r.text,r.target)).join('')}`; +} +function composer(s) { + if (s.screen==='list') return ''; + if (s.screen==='feed' && role==='reader') return ''; + return `
`; +} +function render(s) { + const previousView = s.phone.querySelector('.view'); + s.scrolls ||= {}; + if (previousView && s.renderedScreen) s.scrolls[s.renderedScreen] = previousView.scrollTop; + s.phone.innerHTML = `
9:41${icon('wifi')}${icon('battery')}
${header(s)}
${s.screen==='list'?list(s):s.screen==='feed'?feed(s):thread(s)}
${composer(s)}${nav()}`; + if(s.screen==='feed') for(const text of s.posts) { + const item=document.createElement('article'); item.className='post'; + const author=document.createElement('strong'); author.textContent='Вы · сейчас'; + const body=document.createElement('p'); body.textContent=text; item.append(author,body); + s.phone.querySelector('.view').append(item); + } + s.phone.querySelector('.view').scrollTop = s.scrolls[s.screen] || 0; + s.renderedScreen = s.screen; + if (s.editor) mountEditor(s); +} +function keyboard() { + return ``; +} +function mountEditor(s) { + const mode = s.editor; + const full = s.id==='mono'||s.id==='club'||s.expanded; + const overlay = document.createElement('div'); + overlay.className = 'overlay'; + overlay.innerHTML = ``; + s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=true); + s.phone.append(overlay); + overlay.querySelector('.sheet').focus({preventScroll:true}); +} +function attachmentMarkup(s) { + const name = s.attachments[s.editor]; + return name?`
${icon('file')}${escapeText(name)}${button('remove-attachment','Удалить вложение','close')}
`:''; +} +function removeOverlay(s) { + s.phone.querySelector('.overlay')?.remove(); + s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=false); +} +function closeEditor(s) { + s.editor = null; + removeOverlay(s); + if (s.returnFocus?.isConnected) s.returnFocus.focus({preventScroll:true}); + else s.phone.querySelector('[data-action="reply"], [data-action="new-post"], [data-action="back"]')?.focus({preventScroll:true}); +} +function openEditor(s, mode='reply', author=s.channel.owner, text=s.activePost==='second'?secondText:mainText, trigger=null) { + if (s.editor) closeEditor(s); + s.editor=mode; + s.replyTo=author; + s.replyText=text; + s.returnFocus=trigger; + s.expanded=false; + mountEditor(s); +} +function toast(s, message) { + s.phone.querySelector('.toast')?.remove(); + clearTimeout(s.toastTimer); + const el=document.createElement('div'); + el.className='toast'; el.setAttribute('role','status'); el.textContent=message; + s.phone.append(el); + s.toastTimer=setTimeout(()=>el.remove(),3500); +} +function menu(s, title, contents, trigger) { + removeOverlay(s); + s.returnFocus=trigger; + const el=document.createElement('div'); el.className='overlay'; + el.innerHTML=``; + s.phone.querySelectorAll(':scope > *').forEach(child=>child.inert=true); + s.phone.append(el); el.querySelector('.sheet').focus({preventScroll:true}); +} +const menuAction=(action,label,i='file')=>``; +function updateEditor(s) { + const modal=s.phone.querySelector('.overlay'); + modal.querySelector('.char-count').textContent=`${s.drafts[s.editor].length} / 2000`; + modal.querySelector('[data-action="send"]').disabled=!s.drafts[s.editor].trim()&&!s.attachments[s.editor]; + modal.querySelector('.selected-attachment').innerHTML=attachmentMarkup(s); +} +function handle(s, event) { + const el=event.target.closest('[data-action]'); + if (!el || el.disabled) return; + const action=el.dataset.action; + if (action==='filter') { s.filter=el.dataset.value; render(s); } + else if (action==='open-channel') { s.channel=channels[Number(el.dataset.index)]; s.screen='feed'; s.activePost='first'; s.replies=[]; s.posts=[]; s.likes.clear(); render(s); } + else if (action==='list'||action==='back') { s.screen=action==='list'||s.screen==='feed'?'list':'feed'; render(s); } + else if (action==='thread') { s.activePost=el.dataset.post||s.activePost; s.screen='thread'; render(s); } + else if (action==='reply'||action==='new-post') { s.activePost=el.dataset.post||s.activePost; openEditor(s,action==='new-post'?'post':'reply',s.channel.owner,s.activePost==='second'?secondText:mainText,el); } + else if (action==='reply-to') openEditor(s,'reply',el.dataset.author,el.dataset.text,el); + else if (action==='close-editor') { closeEditor(s); toast(s,'Черновик сохранён до закрытия страницы'); } + else if (action==='expand') { s.expanded=!s.expanded; removeOverlay(s); mountEditor(s); } + else if (action==='attach') s.phone.querySelector('[data-file]').click(); + else if (action==='remove-attachment') { s.attachments[s.editor]=''; updateEditor(s); } + else if (action==='send') { + const mode=s.editor; + const text=s.drafts[mode].trim(); + const attachment=s.attachments[mode]; + if (!text&&!attachment) return; + if (mode==='reply') s.replies.push({text:text+(attachment?'\n📎 '+attachment:''),target:s.replyTo,post:s.activePost}); + else s.posts.push(text+(attachment?'\n📎 '+attachment:'')); + s.drafts[mode]=''; s.attachments[mode]=''; s.editor=null; s.screen=mode==='reply'?'thread':'feed'; render(s); + const view=s.phone.querySelector('.view'); view.scrollTop=view.scrollHeight; + s.phone.querySelector('.composer-trigger')?.focus({preventScroll:true}); toast(s,'Отправлено в макете. Данные на сервер не передаются.'); + } + else if (action==='like') { + const key=el.dataset.post; const liked=s.likes.has(key); liked?s.likes.delete(key):s.likes.add(key); + el.setAttribute('aria-pressed',String(!liked)); el.querySelector('span').textContent=String(Number(el.querySelector('span').textContent)+(liked?-1:1)); + } + else if (action==='share') menu(s,'Поделиться', '

В приложении здесь появится системное меню отправки ссылки. В макете внешние ссылки не создаются.

'+menuAction('close-menu','Понятно','check'),el); + else if (action==='subscribe') { s.subscribed=!s.subscribed; el.textContent=s.subscribed?'Вы подписаны':'Подписаться'; } + else if (action==='notifications') { s.muted=!s.muted; el.setAttribute('aria-label',s.muted?'Включить уведомления':'Настроить уведомления'); toast(s,s.muted?'Уведомления канала выключены в макете':'Уведомления канала включены в макете'); } + else if (action==='post-menu') menu(s,'Сообщение',menuAction('reactions','Кто поставил лайк','heart')+menuAction('details','Данные записи','file')+(role==='owner'?menuAction('edit-demo','Редактировать','edit'):'')+'

Редкие действия собраны здесь, чтобы оставлять больше места самому сообщению.

',el); + else if (action==='channel-menu') menu(s,s.channel.title,menuAction('about','О канале','channels')+menuAction('contents','Оглавление','book')+menuAction('notifications','Уведомления','bell'),el); + else if (action==='contents') menu(s,'Оглавление','

С чего начать знакомство с каналом

'+menuAction('jump-post','01 · Маленькие открытия','book')+menuAction('jump-file','02 · Наш список любимых мест','file'),el); + else if (action==='jump-post'||action==='jump-file') { removeOverlay(s); s.screen='feed'; render(s); s.phone.querySelectorAll('.post')[action==='jump-file'?1:0]?.scrollIntoView({block:'nearest'}); } + else if (action==='reactions') menu(s,'Лайки','

Все: 24 · основные: 18 · сияющие: 6

Названия категорий сохранены из текущего приложения. Их объяснение пользователю нужно согласовать отдельно.

',el); + else if (action==='details') menu(s,'Данные записи','

Здесь доступны номер записи, цепочка автора, хэш и подпись. В мокапе технические данные не генерируются.

',el); + else if (action==='about') menu(s,'О канале','

Замечаем красоту рядом. Делимся местами, историями и маленькими открытиями.

Автор: @'+escapeText(s.channel.owner)+'

',el); + else if (action==='close-menu') { removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); } + else if (action==='new-channel') toast(s,'Создание канала — следующий этап проектирования'); + else if (action==='edit-demo') toast(s,'Редактор существующего сообщения — следующий этап'); + else if (action==='outside') toast(s,'Сейчас проектируем каналы. Этот раздел пока вне макета.'); +} +function attachEvents(s) { + s.phone.addEventListener('click',event=>handle(s,event)); + s.phone.addEventListener('input',event=>{ + if(event.target.matches('[data-search]')) { s.query=event.target.value; s.phone.querySelector('.channel-rows').innerHTML=rows(s); } + if(event.target.matches('.editor-input')) { s.drafts[s.editor]=event.target.value; updateEditor(s); } + }); + s.phone.addEventListener('change',event=>{ + if(event.target.matches('[data-file]')) { s.attachments[s.editor]=event.target.files[0]?.name||''; updateEditor(s); } + }); + s.phone.addEventListener('keydown',event=>{ + const dialog=s.phone.querySelector('[role="dialog"]'); + if(!dialog) return; + if(event.key==='Escape') { event.preventDefault(); s.editor?closeEditor(s):removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); } + if(event.key==='Tab') { + const focusable=[...dialog.querySelectorAll('button:not(:disabled), textarea, input:not([hidden])')]; + const first=focusable[0], last=focusable.at(-1); + if(event.shiftKey&&(document.activeElement===first||document.activeElement===dialog)) { event.preventDefault(); last?.focus(); } + else if(!event.shiftKey&&(document.activeElement===last||document.activeElement===dialog)) { event.preventDefault(); first?.focus(); } + } + }); +} +function init() { + const query=new URLSearchParams(location.search); + const chosen=concepts.find(c=>c.id===query.get('variant')); + const gallery=document.getElementById('gallery'); + if(chosen) {gallery.classList.add('single');document.body.classList.add('focused');} + for(const c of chosen?[chosen]:concepts) { + const article=document.createElement('section');article.className='concept';article.setAttribute('aria-label',c.title); + article.innerHTML=`
${c.number}

${c.title}

↗

${c.description}

${c.tag}
${c.note}
`; + gallery.append(article); + const s={id:c.id,phone:article.querySelector('.phone'),screen:'list',activePost:'first',channel:channels[0],query:'',filter:'all',likes:new Set(),subscribed:true,muted:false,replies:[],posts:[],drafts:{reply:'',post:''},attachments:{reply:'',post:''},editor:null}; + states.set(c.id,s);render(s);attachEvents(s); + } + document.querySelectorAll('[data-screen]').forEach(el=>el.addEventListener('click',()=>{ + document.querySelectorAll('[data-screen]').forEach(b=>{b.classList.toggle('selected',b===el);b.setAttribute('aria-pressed',String(b===el));}); + for(const s of states.values()) { s.editor=null;s.screen=el.dataset.screen==='reply'?'thread':el.dataset.screen;render(s);if(el.dataset.screen==='reply')openEditor(s); } + el.focus({preventScroll:true}); + })); + document.getElementById('role').addEventListener('change',event=>{role=event.target.value;for(const s of states.values())render(s);}); + document.getElementById('keyboard').addEventListener('change',event=>{showKeyboard=event.target.checked;for(const s of states.values())if(s.editor){removeOverlay(s);mountEditor(s);}event.target.focus({preventScroll:true});}); +} +init(); diff --git a/docs/libs/shine-server-blockchain/Doc.md b/docs/libs/shine-server-blockchain/Doc.md index 151e05c4..d0d174a7 100644 --- a/docs/libs/shine-server-blockchain/Doc.md +++ b/docs/libs/shine-server-blockchain/Doc.md @@ -1,4 +1,4 @@ -shine-server-blockchain — это библиотека, которая задаёт формат блока, правила парсинга/валидации тела, крипто-проверку (hash+Ed25519) и безопасную работу с файлами блокчейна (data/.bch через временный .tmp_bch). +shine-server-blockchain задаёт Frame v1, ANS-104 DataItem, правила парсинга/валидации body и криптографическую проверку. Пользовательские chain-файлы на диске больше не используются; полные DataItems хранятся в PostgreSQL. Как устроена структура и логика работы @@ -33,7 +33,6 @@ BchCryptoVerifier отвечает за “как получить хэш и к 4) Утилиты вокруг имени и файлов BlockchainNameUtil — извлекает login из blockchainName (отрезает 3 символа суффикса). -FileStoreUtil — безопасное файловое хранилище: 5) Объяснение структуры работы diff --git a/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md b/docs/Черновики/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md similarity index 100% rename from Черновик_CHANNEL_MEMBERSHIP_для_каналов.md rename to docs/Черновики/Черновик_CHANNEL_MEMBERSHIP_для_каналов.md diff --git a/Черновик_TEXT_CHANNEL_META_для_каналов.md b/docs/Черновики/Черновик_TEXT_CHANNEL_META_для_каналов.md similarity index 100% rename from Черновик_TEXT_CHANNEL_META_для_каналов.md rename to docs/Черновики/Черновик_TEXT_CHANNEL_META_для_каналов.md diff --git a/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md b/docs/Черновики/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md similarity index 100% rename from Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md rename to docs/Черновики/Черновик_дополнений_каналов_и_типов_сообщений_SHiNE.md diff --git a/Черновик_формата_вложений_в_сообщениях_блокчейна.md b/docs/Черновики/Черновик_формата_вложений_в_сообщениях_блокчейна.md similarity index 100% rename from Черновик_формата_вложений_в_сообщениях_блокчейна.md rename to docs/Черновики/Черновик_формата_вложений_в_сообщениях_блокчейна.md diff --git a/docs/закомментировано/Личный_дневник.md b/docs/закомментировано/Личный_дневник.md new file mode 100644 index 00000000..6da008e5 --- /dev/null +++ b/docs/закомментировано/Личный_дневник.md @@ -0,0 +1,68 @@ +# Личный дневник — временно отключён в UI + +Статус: **закомментировано / скрыто из интерфейса**. + +Личный дневник SHiNE не удалён из протокола и серверной части. На текущем этапе он только перестал отображаться пользователю в списке каналов. + +## Что это за функция + +«Дневник» — виртуальный персональный канал пользователя. Он формируется не как обычный канал с отдельной цепочкой постов, а собирается сервером из `STATUS_ACTION` записей пользователя. + +Основной read API: + +- `GetPersonalDiary` + +Связанная документация: + +- `docs/API/06_Channels_Read_API.md` — раздел `GetPersonalDiary`; +- `docs/API/09_Operations_Index.md` — операция `GetPersonalDiary`; +- `docs/Blockchain/15_STATUS_ACTION_Blocks.md` — действия, из которых собирается дневник; +- `docs/Blockchain/CHANGELOG.md` — история добавления функции. + +## Что отключено сейчас + +В `shine-UI/js/pages/channels-list.js` отключён запрос `authService.getPersonalDiary(...)` при построении списка каналов. + +Код оставлен рядом в комментариях, а в `mapApiFeed(...)` передаётся `diaryPayload = null`. Благодаря этому карточка «Дневник» не появляется в разделе каналов. + +## Что намеренно НЕ удалено + +Чтобы не ломать обратную совместимость и сохранить возможность вернуть функцию позже, оставлены: + +- серверная операция `GetPersonalDiary`; +- `authService.getPersonalDiary(...)`; +- обработка diary-route в `channel-view.js`; +- форматы `STATUS_ACTION`; +- чтение старых типов сообщений и действий. + +То есть функция сохранена технически, но скрыта из обычной навигации. + +## Как вернуть дневник + +В `shine-UI/js/pages/channels-list.js` вернуть получение `diaryPayload`: + +```js +let diaryPayload = null; +try { + diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc'); +} catch { + diaryPayload = null; +} +``` + +и убрать временное: + +```js +const diaryPayload = null; +``` + +После этого `mapApiFeed(...)` снова сможет добавить виртуальную карточку дневника в список каналов. + +## Связанные типы контента + +В интерфейсе создания новой записи канала сейчас доступны только: + +- `POST (10)` — Пост; +- `TEXT_ENTRYPOINT (100)` — Оглавление канала. + +Варианты `TEXT_EXERCISE (110)`, `TEXT_SERVICE (120)` и `TEXT_COURSE (130)` убраны только из формы создания новой записи. Их константы и обработчики чтения сохранены для совместимости со старыми данными. diff --git a/settings.gradle b/settings.gradle index 008cebfb..518f00ae 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,6 +9,7 @@ include 'shine-server-db' include 'shine-server-net-protocol' include 'shine-server-net-server' include 'shine-server-solana-users-sync' +include 'shine-server-archive' project(':shine-server-log').projectDir = file('SHiNE-server/shine-server-log') project(':shine-server-config').projectDir = file('SHiNE-server/shine-server-config') @@ -19,3 +20,4 @@ project(':shine-server-db').projectDir = file('SHiNE-server/shine-server-db') project(':shine-server-net-protocol').projectDir = file('SHiNE-server/shine-server-net-protocol') project(':shine-server-net-server').projectDir = file('SHiNE-server/shine-server-net-server') project(':shine-server-solana-users-sync').projectDir = file('SHiNE-server/shine-server-solana-users-sync') +project(':shine-server-archive').projectDir = file('SHiNE-server/shine-server-archive') diff --git a/shine-UI/assets/shine-official-badge.svg b/shine-UI/assets/shine-official-badge.svg new file mode 100644 index 00000000..b5768831 --- /dev/null +++ b/shine-UI/assets/shine-official-badge.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/shine-UI/channel-design-check.mjs b/shine-UI/channel-design-check.mjs new file mode 100644 index 00000000..46b1c2a7 --- /dev/null +++ b/shine-UI/channel-design-check.mjs @@ -0,0 +1,314 @@ +// Запуск: SHINE_UI_TEST_DEPS=/путь/к/node_modules node --experimental-vm-modules shine-UI/channel-design-check.mjs +// Зависимости проверки (не приложения): jsdom, postcss. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { execFileSync } from 'node:child_process'; + +const require = createRequire(path.join(process.env.SHINE_UI_TEST_DEPS || process.cwd() + '/node_modules', '_check.cjs')); +const { JSDOM } = require('jsdom'); +const postcss = require('postcss'); +const ui = path.dirname(fileURLToPath(import.meta.url)); +const dom = new JSDOM('
', { url: 'https://shine.test/#channel', pretendToBeVisual: true, runScripts: 'outside-only' }); +const context = dom.getInternalVMContext(); +const { window } = dom; +const { document } = window; +const state = { session: { login: 'alice', isAuthorized: true }, entrySettings: {} }; +let placed = 0; +let authPrompts = 0; +const mocks = { + 'arweave-attachment-manager.js': { markArweaveAttachmentPlaced: () => placed++, openArweaveAttachmentManager: async () => ({ name: 'photo.jpg', size: 1024, ar: 'test' }) }, + 'attachment-format.js': { MAX_MESSAGE_ATTACHMENTS: 10, composeMessageWithAttachments: (text) => text }, + 'state.js': { state }, + 'ui-error-texts.js': { toUserMessage: (error) => error.message }, + 'avatar-image.js': { renderUserAvatar: () => document.createElement('span') }, + 'auth-required-modal.js': { openAuthRequiredModal: () => authPrompts++ }, +}; +async function moduleAt(relative, mockImports = false) { + const module = new vm.SourceTextModule(fs.readFileSync(path.join(ui, relative), 'utf8'), { context }); + await module.link(async (specifier) => { + const values = mockImports && mocks[path.basename(specifier)]; + assert.ok(values, `Неожиданный импорт ${specifier}`); + return new vm.SyntheticModule(Object.keys(values), function () { + for (const [key, value] of Object.entries(values)) this.setExport(key, value); + }, { context }); + }); + await module.evaluate(); + return module.namespace; +} +const tick = () => new Promise((resolve) => setTimeout(resolve, 35)); +const query = (selector) => document.querySelector(selector); +function input(value) { + const field = query('textarea'); + field.value = value; + field.dispatchEvent(new window.Event('input', { bubbles: true })); +} +function key(key, options = {}) { + const event = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...options }); + document.activeElement.dispatchEvent(event); + return event; +} + +const { openChannelEditor } = await moduleAt('js/components/channel-editor.js', true); +let sent = []; +const options = { key: 'channel:one:message:1', onSubmit: async (value) => sent.push(value) }; +query('#opener').focus(); +let editor = openChannelEditor(options); +await tick(); +assert.equal(document.activeElement.tagName, 'TEXTAREA'); +assert.equal(query('.channel-editor__submit').disabled, true); +input('Первая строка\nВторая строка'); +assert.equal(key('Enter').defaultPrevented, false); +assert.equal(sent.length, 0); +editor.close(); +await tick(); +assert.equal(document.activeElement.id, 'opener'); +editor = openChannelEditor(options); +await tick(); +assert.equal(query('textarea').value, 'Первая строка\nВторая строка'); +key('Enter', { ctrlKey: true, isComposing: true }); +assert.equal(sent.length, 0); +key('Enter', { ctrlKey: true }); +await tick(); +assert.equal(sent.length, 1); +assert.equal(query('.channel-editor-overlay'), null); +editor = openChannelEditor(options); +assert.equal(query('textarea').value, ''); +editor.close(); +await tick(); + +editor = openChannelEditor({ ...options, onSubmit: async () => { throw new Error('Нет соединения'); } }); +await tick(); +input('Не потерять'); +query('.channel-editor__submit').click(); +await tick(); +assert.equal(query('[role="alert"]').textContent, 'Нет соединения'); +assert.equal(query('textarea').value, 'Не потерять'); +assert.equal(query('.channel-editor__submit').disabled, false); +query('.channel-editor__close').focus(); +key('Tab', { shiftKey: true }); +assert.equal(document.activeElement, query('.channel-editor__submit')); +editor.close(); +await tick(); +state.session.login = 'bob'; +editor = openChannelEditor(options); +assert.equal(query('textarea').value, ''); +editor.close(); +await tick(); +state.session.login = 'alice'; +editor = openChannelEditor(options); +assert.equal(query('textarea').value, 'Не потерять'); +query('.channel-editor__clear').click(); +assert.equal(query('textarea').value, ''); +query('.channel-editor__attach').click(); +await tick(); +assert.equal(document.querySelectorAll('.channel-editor__attachment').length, 1); +assert.equal(query('.channel-editor__submit').disabled, false); +query('.channel-editor__submit').click(); +await tick(); +assert.equal(placed, 1); +editor = openChannelEditor(options); +await tick(); +window.history.back(); +await tick(); +assert.equal(query('.channel-editor-overlay'), null); +editor.close(); +state.session.isAuthorized = false; +assert.equal(openChannelEditor(options), null); +assert.equal(authPrompts, 1); + +const theme = await moduleAt('js/services/theme-service.js'); +assert.equal(theme.applyThemeMode('system').resolved, 'dark'); +theme.setThemeMode('light'); +assert.equal(document.documentElement.dataset.theme, 'light'); +theme.setThemeMode('dark'); +assert.equal(document.documentElement.dataset.theme, 'dark'); +const scroll = await moduleAt('js/services/channel-view-state.js'); +query('#app-screen').scrollTop = 123; +scroll.rememberChannelPosition('alice:channel:one'); +query('#app-screen').scrollTop = 0; +scroll.restoreChannelPosition(scroll.readChannelPosition('alice:channel:one')); +assert.equal(query('#app-screen').scrollTop, 123); +assert.equal(scroll.readChannelPosition('bob:channel:one'), undefined); + +const { createDropdownMenu } = await moduleAt('js/components/dropdown-menu.js'); +const menu = createDropdownMenu({ anchorEl: query('#opener'), items: [{ label: 'Первый' }, { label: 'Второй' }] }); +menu.open(); +assert.equal(document.activeElement.textContent, 'Первый'); +key('ArrowDown'); +assert.equal(document.activeElement.textContent, 'Второй'); +key('Escape'); +assert.equal(document.activeElement.id, 'opener'); +menu.destroy(); +menu.destroy(); +assert.equal(query('.dropdown-portal'), null); +console.log('PASS: редактор — клавиши, фокус, черновики, аккаунты, ошибка, вложение, отправка, Назад; темы, позиция чтения, меню.'); + +// DOM producer страниц исполняется с изолированным API: тест не пишет в блокчейн. +let replyOptions; +const reactionState = new Map(); +const iconModule = await moduleAt('js/components/ui-icon.js'); +const pageValues = { + state, authService: {}, + channels: [], + readChannelNotificationsState: () => ({}), + createSkeletonCard: () => document.createElement('div'), + createTopBar: ({ center }) => { + const header = document.createElement('header'); + if (center) header.append(center); + return header; + }, + iconHtml: iconModule.iconHtml, + parseMessageAttachments: (text) => ({ text: text || '', attachments: [] }), + parseDmTechBlocks: (text) => ({ displayText: text, visibleText: text }), + loadProfileSnapshot: async () => null, + renderAvatar: () => document.createElement('span'), + renderUserAvatar: () => document.createElement('span'), + formatRelativeTime: () => 'сейчас', + escapeHtml: (text) => String(text || ''), + openChannelEditor: (options) => { replyOptions = options; }, + getMessageReactionState: (target) => reactionState.get(target.blockHash) || 'unliked', + setMessageReactionState: (target, value) => reactionState.set(target.blockHash, value), + makeShineMessageRoute: () => 'thread:test', + attachMessageMenu: (card, head, items) => { + const button = document.createElement('button'); + head.append(button); + card.testMenu = items; + const menu = createDropdownMenu({ anchorEl: button, items }); + card.cleanup = () => menu.destroy(); + }, +}; +async function pageProducer(file, exported) { + const source = fs.readFileSync(path.join(ui, 'js/pages', file), 'utf8'); + const imports = new Map(); + for (const match of source.matchAll(/import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g)) { + imports.set(match[2], [...new Set([...(imports.get(match[2]) || []), ...match[1].split(',').map((name) => name.trim().split(/\s+as\s+/)[0]).filter(Boolean)])]); + } + const module = new vm.SourceTextModule(source + (exported === 'render' ? '' : `\nexport { ${exported} };`), { context }); + await module.link(async (specifier) => { + const names = imports.get(specifier); + assert.ok(names, specifier); + return new vm.SyntheticModule(names, function () { + names.forEach((name) => this.setExport(name, pageValues[name] || (() => {}))); + }, { context }); + }); + await module.evaluate(); + return module.namespace[exported]; +} +state.session.isAuthorized = true; +state.session.login = 'alice'; +const ref = { blockchainName: 'alice-1', blockNumber: 2, blockHash: 'a'.repeat(64) }; +let likes = 0; +let navigations = 0; +const handlers = { selector: { ownerBlockchainName: 'alice-1', channelRootBlockNumber: 1, channelRootBlockHash: 'b'.repeat(64) }, navigate: () => navigations++, onToggleLike: async () => likes++, onReply: async () => {}, onEdit: async () => {}, isActive: () => true }; +const renderPost = await pageProducer('channel-view.js', 'renderPostCard'); +const post = renderPost({ body: 'Публикация', authorLogin: 'alice', localNumber: 1, messageRef: ref, isOwnMessage: true, msgSubType: 10, likesCount: 7, repliesCount: 2 }, handlers); +document.body.append(post); +assert.equal(post.querySelector('.channel-message-body').textContent, 'Публикация'); +assert.equal(post.querySelectorAll('.channel-action-counter').length, 3); +assert.ok(post.testMenu.some((item) => item.label === 'Редактировать')); +assert.ok(post.testMenu.some((item) => item.label === 'Удалить')); +post.querySelector('.channel-action-like').click(); +await tick(); +assert.equal(likes, 1); +assert.equal(post.querySelector('.channel-action-like').disabled, false); +post.querySelector('.channel-action-reply').click(); +assert.equal(replyOptions.context.author, 'alice'); +assert.equal(replyOptions.context.text, 'Публикация'); +assert.equal(navigations, 0); +post.cleanup(); post.cleanup(); post.remove(); +const renderNode = await pageProducer('channel-thread-view.js', 'renderNodeCard'); +const node = renderNode({ authorBlockchainName: ref.blockchainName, messageRef: ref, authorLogin: 'bob', text: 'Ответ', msgSubType: 10, likesCount: 3 }, '', handlers, 2); +document.body.append(node); +assert.equal(node.querySelector('.channel-message-body').textContent, 'Ответ'); +assert.ok(!node.testMenu.some((item) => item.label === 'Удалить')); +node.querySelector('.thread-reply-btn').click(); +assert.equal(replyOptions.context.author, 'bob'); +node.cleanup(); node.cleanup(); node.remove(); +console.log('PASS: карточки канала/ветки — текст, общий лайк без диалога, контекст ответа, меню по авторству, cleanup.'); + +pageValues.authService.onEvent = () => () => {}; +pageValues.toUserMessage = (error) => error.message; +pageValues.rememberChannelPosition = scroll.rememberChannelPosition; +pageValues.readChannelPosition = scroll.readChannelPosition; +pageValues.restoreChannelPosition = scroll.restoreChannelPosition; +state.channelsFeed = {}; +state.channelIndex = {}; +const chrome = { setTopbar() {}, setComposer() {} }; +for (const [file, method, params, payload] of [ + ['channels-list.js', 'listSubscriptionsFeed', {}, { ownedChannels: [], followedUsersChannels: [], followedChannels: [] }], + ['channel-view.js', 'getChannelMessages', { ownerBlockchainName: 'alice-1', channelRootBlockNumber: 1, channelRootBlockHash: 'b'.repeat(64) }, { channel: { ownerLogin: 'alice', channelName: 'news' }, messages: [] }], + ['channel-thread-view.js', 'getMessageThread', { messageBlockchainName: 'alice-1', messageBlockNumber: 2, messageBlockHash: ref.blockHash }, { focus: null, descendants: [], ancestors: [] }], +]) { + let finish; + pageValues.authService[method] = () => new Promise((resolve) => { finish = resolve; }); + const render = await pageProducer(file, 'render'); + const screen = render({ route: { params }, navigate() {}, chrome }); + query('#app-screen').append(screen); + await tick(); + assert.ok(finish, `${file}: запрос начат`); + screen.cleanup(); + screen.cleanup(); + const markup = screen.innerHTML; + finish(payload); + await tick(); + assert.equal(screen.innerHTML, markup, `${file}: async после dispose`); + assert.equal(query('#app-screen').firstElementChild, screen, `${file}: root identity`); + screen.remove(); + pageValues.authService[method] = async () => payload; + const loaded = render({ route: { params }, navigate() {}, chrome }); + query('#app-screen').append(loaded); + await tick(); + const expected = file === 'channels-list.js' ? '.channels-empty-state' : file === 'channel-view.js' ? '.channel-feed' : '.thread-block'; + assert.ok(loaded.querySelector(expected), `${file}: успешная загрузка ${loaded.textContent}`); + if (loaded.refresh) await loaded.refresh(); + assert.equal(query('#app-screen').firstElementChild, loaded, `${file}: refresh сохраняет root`); + loaded.cleanup(); loaded.remove(); +} +console.log('PASS: список/канал/ветка — стабильный root, идемпотентный cleanup, поздний API-ответ после dispose.'); + +function walk(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)]); +} +const scripts = walk(path.join(ui, 'js')).filter((file) => file.endsWith('.js')); +for (const file of scripts) { + const source = fs.readFileSync(file, 'utf8'); + for (const match of source.matchAll(/(?:from\s*|import\s*\()(['"])(\.[^'"]+)\1/g)) { + assert.ok(fs.existsSync(path.resolve(path.dirname(file), match[2].split(/[?#]/)[0])), `${file}: ${match[2]}`); + } +} +const repo = path.dirname(ui); +const changed = execFileSync('git', ['ls-files', '--modified', '--others', '--exclude-standard'], { cwd: repo, encoding: 'utf8' }).trim().split('\n'); +let cssCount = 0; +for (const relative of changed) { + const file = path.join(repo, relative); + if (relative.endsWith('.js')) execFileSync('node', ['--input-type=module', '--check'], { input: fs.readFileSync(file) }); + if (!relative.startsWith('shine-UI/styles/') || !relative.endsWith('.css')) continue; + cssCount++; + const source = fs.readFileSync(file, 'utf8'); + const tree = postcss.parse(source, { from: file }); + let baseline = ''; try { baseline = execFileSync('git', ['show', `HEAD:${relative}`], { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); } catch {} + const baselineRules = new Map(); + postcss.parse(baseline).walkRules((rule) => baselineRules.set(rule.selector, (baselineRules.get(rule.selector) || 0) + 1)); + const rules = new Map(); + const duplicates = []; + tree.walkRules((rule) => { + const parents = []; for (let node = rule.parent; node; node = node.parent) if (node.type === 'atrule') parents.push(node.name + ':' + node.params); + const key = parents.join('/') + ':' + rule.selector; + const count = (rules.get(key) || 0) + 1; + rules.set(key, count); + if (count > Math.max(1, baselineRules.get(rule.selector) || 0)) duplicates.push(key); + }); + assert.deepEqual(duplicates, [], `Новые повторы selectors: ${relative}`); + assert.ok((source.match(/!important/g) || []).length <= (baseline.match(/!important/g) || []).length, `Вырос !important: ${relative}`); +} +const html = fs.readFileSync(path.join(ui, 'index.html'), 'utf8'); +const cssManifest = [...html.matchAll(/['"]\.\/(styles\/[^'"]+\.css)['"]/g)].map((match) => match[1]); +assert.equal(new Set(cssManifest).size, cssManifest.length); +for (const file of cssManifest) assert.ok(fs.existsSync(path.join(ui, file)), `CSS manifest: ${file}`); +assert.ok(cssManifest.includes('styles/components/channel-editor.css')); +console.log(`PASS: импорты ${scripts.length} JS; синтаксис изменённых JS; ${cssCount} CSS — parser, дубликаты selectors, !important; CSS manifest.`); +dom.window.close(); diff --git a/shine-UI/firebase-messaging-sw.js b/shine-UI/firebase-messaging-sw.js index 855678ee..47df1f1f 100644 --- a/shine-UI/firebase-messaging-sw.js +++ b/shine-UI/firebase-messaging-sw.js @@ -131,7 +131,7 @@ self.addEventListener('push', (event) => { const notifyPromise = shouldNotify ? self.registration.showNotification(notificationTitle, { - body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'), + body: body || (fromLogin ? `Новое сообщение от ${fromLogin}` : 'Новое сообщение'), tag: callId || (kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message'), renotify: true, requireInteraction: kind === 'incoming_call', diff --git a/shine-UI/index.html b/shine-UI/index.html index db9c5a00..55d2ed5c 100644 --- a/shine-UI/index.html +++ b/shine-UI/index.html @@ -4,7 +4,7 @@ @@ -12,13 +12,28 @@ СИЯНИЕ + + + + +
+
+
SHiNE prototype viewer
+

SHiNE Solana Arweave Viewer

+

+ Статическая страница для прямого чтения shine_users из Solana, + с настройками RPC и gateway, которые можно передавать через URL и сохранять в браузере. +

+
+ +
+
+
+

Настройки доступа

+

Параметры можно сохранить локально и переиспользовать при следующем открытии.

+
+ +
+ + + + + + + + + + + + +
+ Быстрые действия +
+ + + +
+
+ +
+ Проверка доступности +
+ + + + +
+
+
+ +
Готово к проверке и загрузке.
+
+ +
+
+

Текущее состояние

+

Короткая сводка по найденной записи пользователя.

+
+
+
+ Логин + — +
+
+ PDA + — +
+
+ Версия записи + — +
+
+ Блокчейн + — +
+
+ Arweave TX + — +
+
+ Последний блок + — +
+
+
+ +
+
+

Полезные ссылки

+

Ссылки формируются из текущих настроек и содержимого PDA.

+
+ +
+ +
+
+

Содержимое PDA

+

Распарсенные данные записи пользователя из программы shine_users.

+
+
Пока ничего не загружено.
+
+ +
+
+

Содержимое блокчейна

+

Viewer скачивает закреплённый блокчейн пользователя из Arweave и локально собирает каналы без сервера SHiNE.

+
+
Сначала загрузите PDA пользователя, затем нажмите «Показать содержимое».
+ +
+
+
+ + diff --git a/shine-UI/static-sites/arweave-viewer/index.html b/shine-UI/static-sites/arweave-viewer/index.html new file mode 100644 index 00000000..aba22cff --- /dev/null +++ b/shine-UI/static-sites/arweave-viewer/index.html @@ -0,0 +1,3314 @@ + + + + + + SHiNE Arweave Viewer v3 + + + + + +
+ +
SHiNE Viewer
+
+
Готово
+ + diff --git a/shine-UI/static-sites/design-examples/channels-v1/index.html b/shine-UI/static-sites/design-examples/channels-v1/index.html new file mode 100644 index 00000000..f74d5db0 --- /dev/null +++ b/shine-UI/static-sites/design-examples/channels-v1/index.html @@ -0,0 +1,34 @@ + + + + + + SHiNE — четыре направления UI + + + + +
+ ✳ SHiNEDESIGN STUDY / 01 + Концепции · сентябрь 2026 +
+
+
+
КАНАЛЫ / СООБЩЕНИЯ / ОТВЕТЫ

Ближе к разговору.

Четыре характера. Один привычный путь от канала к ответу.
Выберите экран и сравните — или нажмите на элементы внутри макета.

+
01—04

Меньше визуального шума.
Больше места людям и мыслям.

+
+
+
+ + + + +
+
Все варианты ↗
+
+ +
С ЧЕГО НАЧАТЬ ОБСУЖДЕНИЕ

Сначала удобство. Затем характер.

01 / ЧитаемостьГде комфортнее читать длинный пост и замечать новый ответ?

02 / ПлотностьНужны просторные сообщения или больше содержимого на одном экране?

03 / Окно ответаПолный экран или лист поверх ветки? Проверьте с макетом клавиатуры.

Демонстрационные данные. Отправка и реакции работают только внутри этой страницы. Поиск фильтрует примеры. Другие разделы приложения не проектировались. Дизайн-документ ↗

+
+
SHiNE / пространство для общенияРабочий материал · не финальный дизайн
+ + diff --git a/shine-UI/static-sites/design-examples/channels-v1/mockups.css b/shine-UI/static-sites/design-examples/channels-v1/mockups.css new file mode 100644 index 00000000..c55409c9 --- /dev/null +++ b/shine-UI/static-sites/design-examples/channels-v1/mockups.css @@ -0,0 +1,212 @@ +* { box-sizing: border-box; } +:root { color-scheme: light; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #242622; background: #f1f0eb; } +body { margin: 0; } +button, input, textarea, select { font: inherit; } +button, a, select { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +button:disabled { cursor: default; opacity: .4; } +button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 3px solid #728de3; outline-offset: 3px; } +a { color: inherit; } +.studio-header { margin: 0 42px; padding: 26px 0; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #d5d6ce; gap: 16px; } +.brand { display: flex; align-items: center; gap: 9px; font-size: 25px; font-weight: 760; text-decoration: none; letter-spacing: -1px; } +.brand-spark { font-size: 36px; font-weight: 400; } +.brand-caption { margin-left: 22px; font-size: 10px; letter-spacing: 2px; font-weight: 600; } +.draft-label { font-size: 12px; color: #63675f; } +main { padding: 0 42px; max-width: 1900px; margin: auto; } +.intro { display: flex; justify-content: space-between; align-items: end; padding: 48px 0 34px; gap: 30px; } +.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: 1.8px; } +h1 { font-size: clamp(34px, 4vw, 60px); line-height: 1.1; letter-spacing: -2.8px; font-weight: 550; margin: 16px 0; } +.intro p { color: #62665d; font-size: 14px; line-height: 1.7; margin: 0; } +.intro-note { border-left: 1px solid #c6c8bd; padding-left: 24px; min-width: 250px; } +.intro-note > span { font-size: 38px; letter-spacing: -2px; } +.intro-note p { font-size: 12px; margin-top: 10px; } +.review-bar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; padding: 16px 0; border-top: 1px solid #d5d6ce; border-bottom: 1px solid #d5d6ce; } +.screen-switch { display: flex; flex-wrap: wrap; gap: 4px; } +.review-button { border: 0; padding: 11px 14px; background: transparent; border-radius: 7px; font-size: 12px; color: #62665d; min-height: 42px; } +.review-button.selected { background: #292d27; color: white; } +.review-options { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; font-size: 12px; color: #62665d; } +.review-options label { display: flex; align-items: center; gap: 6px; } +.review-options select { padding: 8px; border: 1px solid #d5d6ce; border-radius: 7px; background: transparent; color: #242622; } +.compare-link { display: none; } +.gallery { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 24px; padding: 30px 0 36px; align-items: start; } +.concept { min-width: 0; } +.concept-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.concept-number { font-size: 11px; border: 1px solid #c9cdc2; border-radius: 50%; width: 27px; height: 27px; display: grid; place-items: center; } +.concept-heading h2 { font-size: 17px; font-weight: 600; margin: 0; letter-spacing: -.5px; } +.open-concept { margin-left: auto; padding: 10px; text-decoration: none; font-size: 18px; } +.concept-description { font-size: 12px; line-height: 1.6; color: #6b7065; min-height: 40px; margin: 0 0 18px; } +.phone { --bg: #111212; --surface: #1c1d1d; --text: #f3f3f1; --muted: #a0a2a0; --line: #2c2d2d; --accent: #f3f3f1; --on-accent: #161717; --soft: #252727; position: relative; display: flex; flex-direction: column; height: 760px; width: 100%; max-width: 410px; margin: auto; color: var(--text); background: var(--bg); border: 1px solid #bfc2b8; border-radius: 32px; overflow: hidden; box-shadow: 0 14px 35px #252d2210; font-size: 14px; line-height: 1.45; isolation: isolate; } +.phone[data-theme="warm"] { --bg: #faf7f0; --surface: #fffdf8; --text: #302c25; --muted: #7b7264; --line: #e6dfd1; --accent: #93511e; --on-accent: #fffaf3; --soft: #efe6d6; } +.phone[data-theme="night"] { --bg: #101b20; --surface: #17272d; --text: #e8f2f1; --muted: #98adaf; --line: #2a3b40; --accent: #94e1ce; --on-accent: #102c27; --soft: #213b3a; } +.phone[data-theme="club"] { --bg: #f9fafb; --surface: #fff; --text: #202b3c; --muted: #6b7688; --line: #e2e7ee; --accent: #4c5caa; --on-accent: #fff; --soft: #eaeefa; } +.status-bar { display: flex; justify-content: space-between; padding: 15px 24px 6px; font-size: 11px; font-weight: 700; flex: 0 0 38px; } +.status-icons { display: flex; gap: 5px; align-items: center; font-size: 10px; } +.icon { height: 21px; width: 21px; display: inline-block; vertical-align: middle; flex-shrink: 0; } +.status-icons .icon { height: 16px; width: 16px; } +.phone button { color: inherit; } +.icon-btn { background: transparent; border: 0; width: 44px; height: 44px; flex: 0 0 44px; display: inline-grid; place-items: center; border-radius: 50%; padding: 0; } +.icon-btn:hover { background: var(--soft); } +.app-header { display: flex; align-items: center; padding: 4px 12px 10px; gap: 6px; min-height: 66px; border-bottom: 1px solid var(--line); } +.header-text { flex: 1; min-width: 0; } +.header-text h3 { font-size: 17px; letter-spacing: -.4px; margin: 0; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.header-text small { color: var(--muted); font-size: 10px; } +.list-header { padding-left: 22px; } +.list-header h3 { font-size: 25px; letter-spacing: -.9px; } +.mini-brand { color: var(--accent); font-size: 26px; line-height: 1; margin-right: 3px; } +.view { flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--line) transparent; overscroll-behavior: contain; } +.search-field { margin: 15px 18px 10px; display: flex; gap: 9px; align-items: center; background: var(--surface); border-radius: 12px; padding: 0 12px; border: 1px solid var(--line); color: var(--muted); } +.search-field input { width: 100%; min-width: 0; border: 0; background: transparent; height: 42px; outline-offset: 0; color: var(--text); font-size: 13px; } +.search-field input::placeholder { color: var(--muted); } +.filter-tabs { display: flex; padding: 0 18px 12px; gap: 7px; } +.filter { font-size: 11px; padding: 0 11px; min-height: 36px; background: transparent; border: 1px solid transparent; border-radius: 20px; color: var(--muted); } +.filter[aria-pressed="true"] { color: var(--text); background: var(--soft); } +.section-label { padding: 10px 20px 7px; display: flex; justify-content: space-between; text-transform: uppercase; letter-spacing: 1.3px; font-size: 9px; font-weight: 700; color: var(--muted); } +.channel-row { display: flex; width: 100%; align-items: center; gap: 12px; padding: 17px 18px; border: 0; border-bottom: 1px solid var(--line); background: transparent; text-align: left; } +.channel-row:hover { background: var(--soft); } +.avatar { width: 40px; height: 40px; border-radius: 50%; background: #bdc5a8; color: #283124; display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; font-size: 13px; font-weight: 650; letter-spacing: -.5px; } +.avatar.city { background: #ded3bb; color: #635039; } +.avatar.blue { background: #bac5d9; color: #354663; } +.avatar.rose { background: #d9bcbb; color: #704444; } +.avatar.green { background: #b9cdc0; color: #345747; } +.avatar.violet { background: #cbc2d9; color: #56476c; } +.avatar.self { background: var(--soft); color: var(--accent); } +.channel-row .avatar { width: 48px; height: 48px; font-size: 23px; } +.channel-copy { min-width: 0; flex: 1; } +.channel-copy strong { display: block; font-size: 14px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.channel-copy small { display: block; color: var(--muted); font-size: 10px; margin: 2px 0; } +.channel-copy p { margin: 3px 0 0; font-size: 12px; color: var(--muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.channel-tail { display: flex; align-items: end; flex-direction: column; gap: 11px; align-self: stretch; padding-top: 2px; color: var(--muted); font-size: 10px; } +.badge { min-width: 20px; height: 20px; background: var(--accent); color: var(--on-accent); padding: 2px 6px; border-radius: 10px; text-align: center; font-size: 10px; font-weight: 700; } +.list-tip { padding: 24px 24px 16px; color: var(--muted); font-size: 11px; text-align: center; } +.bottom-nav { border-top: 1px solid var(--line); display: flex; justify-content: space-around; flex: 0 0 73px; align-items: start; padding: 9px 6px 12px; background: var(--bg); } +.nav-item { border: 0; background: transparent; display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 8px; width: 20%; min-height: 44px; opacity: .65; } +.nav-item[aria-current="page"] { opacity: 1; color: var(--accent); } +.nav-item[aria-current="page"] .icon { background: var(--soft); border-radius: 7px; box-shadow: 0 0 0 5px var(--soft); } +.channel-intro { margin: 0; padding: 18px 20px 14px; border-bottom: 1px solid var(--line); } +.channel-intro p { margin: 0 0 12px; color: var(--muted); font-size: 12px; line-height: 1.6; } +.channel-tools { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.text-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border: 0; padding: 0 4px; min-height: 36px; background: transparent; color: var(--accent); font-size: 11px; font-weight: 600; } +.text-button .icon { width: 16px; height: 16px; } +.pill { border: 1px solid var(--line); background: var(--surface); border-radius: 8px; padding: 8px 11px; font-size: 10px; min-height: 36px; } +.unread-line { display: flex; align-items: center; gap: 10px; color: var(--accent); font-size: 9px; padding: 13px 20px 0; } +.unread-line::before, .unread-line::after { content: ""; height: 1px; flex: 1; background: var(--line); } +.post { padding: 18px 18px 10px; border-bottom: 1px solid var(--line); } +.post-header { display: flex; align-items: center; gap: 9px; } +.post-header .avatar { width: 34px; height: 34px; font-size: 11px; } +.author { min-width: 0; flex: 1; font-size: 12px; } +.author strong { font-weight: 650; } +.author small { color: var(--muted); font-size: 10px; display: block; } +.post .more { width: 36px; height: 36px; flex-basis: 36px; color: var(--muted); } +.post p { font-size: 14px; line-height: 1.6; margin: 10px 0 12px; overflow-wrap: anywhere; } +.post-title { font-weight: 600; } +.landscape { height: 142px; overflow: hidden; border-radius: 12px; position: relative; background: #c9d6cf; margin: 10px 0 8px; } +.landscape svg { width: 100%; height: 100%; display: block; } +.media-caption { position: absolute; bottom: 10px; left: 12px; font-size: 9px; letter-spacing: 1px; color: #fff; text-transform: uppercase; } +.post-actions { display: flex; gap: 14px; align-items: center; color: var(--muted); } +.action { border: 0; background: transparent; min-height: 44px; display: flex; align-items: center; gap: 6px; padding: 0 2px; font-size: 11px; color: var(--muted); } +.action[aria-pressed="true"] { color: #dc7477; } +.action[aria-pressed="true"] .icon { fill: currentColor; } +.reply-link { margin-left: auto; font-size: 10px; } +.attachment { display: flex; align-items: center; gap: 10px; border: 1px solid var(--line); background: var(--surface); border-radius: 12px; padding: 12px; margin: 10px 0; } +.attachment > .icon { color: var(--accent); } +.attachment strong { font-size: 11px; display: block; } +.attachment small { color: var(--muted); display: block; font-size: 10px; } +.composer-bar { display: flex; align-items: center; padding: 10px 16px; gap: 10px; border-top: 1px solid var(--line); background: var(--bg); flex: 0 0 auto; } +.composer-trigger { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 46px; padding: 10px 14px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); border-radius: 24px; text-align: left; font-size: 12px; } +.composer-trigger.primary { background: var(--accent); color: var(--on-accent); border-color: transparent; justify-content: center; font-weight: 650; } +.thread-heading { padding: 13px 20px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; border-bottom: 1px solid var(--line); } +.thread-heading strong { color: var(--text); } +.reply { display: flex; padding: 16px 18px 0; gap: 10px; } +.reply-body { flex: 1; min-width: 0; padding-bottom: 9px; border-bottom: 1px solid var(--line); } +.reply .avatar { width: 32px; height: 32px; font-size: 10px; } +.reply p { font-size: 13px; margin: 6px 0 2px; line-height: 1.55; overflow-wrap: anywhere; } +.reply small { color: var(--muted); font-size: 10px; } +.reply strong { font-size: 12px; } +.reply .post-actions { gap: 16px; } +.reply-context { color: var(--accent); font-size: 10px; } +.empty { padding: 40px 25px; text-align: center; color: var(--muted); font-size: 13px; } +.overlay { position: absolute; inset: 38px 0 0; z-index: 5; background: #0009; display: flex; flex-direction: column; justify-content: end; } +.sheet { display: flex; flex-direction: column; background: var(--bg); max-height: 100%; min-height: 78%; border-radius: 24px 24px 0 0; overflow: hidden; box-shadow: 0 -8px 40px #0002; } +.sheet.full { height: 100%; border-radius: 0; } +.sheet-handle { width: 32px; height: 4px; border-radius: 4px; background: var(--line); align-self: center; margin-top: 9px; } +.sheet-header { display: flex; align-items: center; padding: 7px 12px; border-bottom: 1px solid var(--line); gap: 6px; } +.sheet-header h4 { margin: 0; flex: 1; text-align: center; font-size: 15px; } +.sheet-header .expand { color: var(--muted); } +.editor-scroll { overflow-y: auto; flex: 1; min-height: 0; padding: 18px; } +.source-preview { display: flex; gap: 10px; padding-bottom: 20px; position: relative; } +.source-preview::after { content: ""; position: absolute; left: 19px; top: 46px; bottom: 3px; width: 1px; background: var(--line); } +.source-preview p { font-size: 12px; color: var(--muted); margin: 5px 0; line-height: 1.5; } +.source-preview strong { font-size: 12px; } +.source-preview small { color: var(--muted); font-size: 10px; } +.editor-row { display: flex; gap: 10px; } +.editor-main { flex: 1; min-width: 0; } +.editor-main strong { display: block; font-size: 12px; margin: 2px 0; } +.editor-main > small { color: var(--muted); display: block; font-size: 10px; margin: 3px 0 10px; } +.editor-input { resize: none; border: 0; width: 100%; min-height: 115px; background: transparent; color: var(--text); font-size: 15px; line-height: 1.6; padding: 3px 0; } +.editor-input::placeholder { color: var(--muted); } +.editor-input:focus-visible { outline: none; box-shadow: 0 2px 0 var(--accent); } +.editor-footer { display: flex; align-items: center; gap: 4px; padding: 11px 16px 16px; border-top: 1px solid var(--line); background: var(--bg); } +.char-count { color: var(--muted); font-size: 10px; margin-left: auto; margin-right: 10px; } +.submit { border: 0; border-radius: 22px; padding: 12px 18px; background: var(--accent); color: var(--on-accent); font-size: 12px; font-weight: 650; min-height: 44px; } +.phone .submit { color: var(--on-accent); } +.editor-attachment { margin-top: 10px; display: flex; align-items: center; gap: 6px; padding: 4px 8px; background: var(--soft); border-radius: 10px; font-size: 10px; } +.editor-attachment span { flex: 1; overflow-wrap: anywhere; } +.keyboard { flex: 0 0 auto; background: var(--surface); padding: 10px 4px 18px; border-top: 1px solid var(--line); } +.keyboard-label { font-size: 9px; text-align: center; color: var(--muted); margin-bottom: 10px; } +.keys { display: flex; justify-content: center; gap: 3px; margin: 5px 0; } +.keys span { display: grid; place-items: center; height: 31px; flex: 1; max-width: 29px; border-radius: 5px; font-size: 13px; color: var(--text); background: var(--soft); box-shadow: 0 1px 0 #0003; } +.keys .space { max-width: 150px; flex: 5; font-size: 10px; } +.keys .wide { max-width: 45px; flex: 1.7; font-size: 11px; } +.menu-sheet { min-height: 0; padding-bottom: 24px; } +.menu-content { padding: 10px 20px; } +.menu-action { border: 0; background: transparent; width: 100%; text-align: left; display: flex; gap: 12px; align-items: center; min-height: 47px; font-size: 13px; border-bottom: 1px solid var(--line); } +.menu-content p { color: var(--muted); font-size: 12px; line-height: 1.6; } +.toast { position: absolute; bottom: 86px; left: 18px; right: 18px; padding: 12px 15px; border-radius: 12px; background: var(--text); color: var(--bg); z-index: 10; font-size: 12px; box-shadow: 0 4px 20px #0003; } +.concept-notes { margin-top: 19px; font-size: 12px; color: #666d61; line-height: 1.65; } +.concept-notes strong { color: #33392f; font-weight: 600; } +.swatches { display: flex; gap: 5px; margin-bottom: 12px; } +.swatches span { width: 19px; height: 19px; border-radius: 50%; border: 1px solid #0002; } +.concept-tag { display: inline-block; border: 1px solid #c9cdc2; padding: 3px 7px; border-radius: 4px; font-size: 9px; letter-spacing: .3px; margin-bottom: 8px; } +.decision-note { padding: 36px 0; border-top: 1px solid #d5d6ce; } +.decision-note h2 { font-weight: 500; letter-spacing: -.7px; font-size: 27px; margin: 13px 0; } +.decision-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 38px; } +.decision-grid p { font-size: 13px; line-height: 1.7; color: #62665d; } +.decision-grid b { display: block; font-weight: 600; color: #33392f; margin-bottom: 6px; } +.disclaimer { font-size: 11px; color: #62665d; line-height: 1.7; margin-top: 24px; } +.studio-footer { display: flex; justify-content: space-between; margin: 0 42px; padding: 22px 0; border-top: 1px solid #d5d6ce; color: #73796b; font-size: 10px; gap: 15px; } +/* Варианты отличаются также плотностью, иерархией и формой редактора. */ +.phone[data-theme="mono"] .post { padding-left: 18px; } +.phone[data-theme="mono"] .post-content { margin-left: 43px; } +.phone[data-theme="mono"] .landscape { height: 128px; } +.phone[data-theme="warm"] .channel-row { margin: 0 12px 8px; width: calc(100% - 24px); border: 1px solid var(--line); border-radius: 16px; padding: 14px 12px; background: var(--surface); } +.phone[data-theme="warm"] .channel-intro { background: var(--soft); margin: 14px 16px 0; border: 0; border-radius: 16px; } +.phone[data-theme="warm"] .post { margin: 14px 12px; padding: 15px 14px 7px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); } +.phone[data-theme="warm"] .post-title { font-family: Georgia, serif; font-size: 20px; line-height: 1.3; } +.phone[data-theme="warm"] .reply .avatar { border-radius: 12px; } +.phone[data-theme="night"] .list-header { border-bottom: 0; padding-top: 12px; } +.phone[data-theme="night"] .channel-intro { background: linear-gradient(125deg, #1d3937, #16272e); border-bottom: 1px solid #36544f; } +.phone[data-theme="night"] .channel-row { border-bottom-color: #21343b; } +.phone[data-theme="night"] .channel-row:first-child { background: #172b2e; box-shadow: inset 3px 0 var(--accent); } +.phone[data-theme="night"] .composer-trigger { border-radius: 13px; } +.phone[data-theme="night"] .post-title { font-size: 17px; font-weight: 550; } +.phone[data-theme="club"] .channel-row { padding: 12px 16px; gap: 10px; } +.phone[data-theme="club"] .channel-row .avatar { width: 42px; height: 42px; border-radius: 13px; } +.phone[data-theme="club"] .channel-copy small { display: none; } +.phone[data-theme="club"] .filter-tabs { border-bottom: 1px solid var(--line); padding-bottom: 0; gap: 12px; } +.phone[data-theme="club"] .filter { border-radius: 0; padding: 0 1px; } +.phone[data-theme="club"] .filter[aria-pressed="true"] { background: transparent; color: var(--accent); border-bottom: 2px solid var(--accent); } +.phone[data-theme="club"] .post { padding: 12px 16px 5px; } +.phone[data-theme="club"] .post p { font-size: 13px; margin: 7px 0; } +.phone[data-theme="club"] .landscape { height: 108px; } +.phone[data-theme="club"] .channel-intro { padding: 12px 16px 8px; } +.phone[data-theme="club"] .channel-intro p { margin-bottom: 5px; } +.phone[data-theme="club"] .reply { padding-top: 12px; } +.phone[data-theme="club"] .reply-body { border-left: 2px solid var(--line); border-bottom: 0; padding-left: 12px; } +.phone[data-theme="club"] .composer-trigger { border-radius: 10px; } +.gallery.single { display: block; max-width: 410px; margin: auto; } +.single .concept-description { min-height: 0; } +body.focused .compare-link { display: inline; } +@media (max-width: 1490px) { .gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 900px; margin: auto; gap: 40px; } .concept { max-width: 410px; width: 100%; justify-self: center; } } +@media (max-width: 760px) { .studio-header { margin: 0 20px; padding: 18px 0; } main { padding: 0 20px; } .brand-caption, .draft-label, .intro-note { display: none; } .intro { padding: 30px 0 24px; } h1 { letter-spacing: -1.5px; } .intro p { font-size: 13px; } .gallery { grid-template-columns: minmax(0, 1fr); gap: 32px; } .screen-switch { display: grid; grid-template-columns: 1fr 1fr; width: 100%; } .review-button { padding: 10px; } .review-options { gap: 12px; } .phone { height: 760px; border-radius: 25px; } .decision-grid { grid-template-columns: 1fr; gap: 0; } .studio-footer { margin: 0 20px; } } +@media (max-width: 380px) { main { padding: 0 10px; } .post-actions { gap: 10px; } .phone { font-size: 13px; } .channel-row { padding-left: 12px; padding-right: 12px; gap: 8px; } .header-text h3 { font-size: 15px; } .concept-heading h2 { font-size: 16px; } } +@media (prefers-reduced-motion: no-preference) { .sheet { animation: appear .18s ease-out; } @keyframes appear { from { transform: translateY(22px); opacity: .5; } to { transform: translateY(0); opacity: 1; } } } diff --git a/shine-UI/static-sites/design-examples/channels-v1/mockups.js b/shine-UI/static-sites/design-examples/channels-v1/mockups.js new file mode 100644 index 00000000..8c97645d --- /dev/null +++ b/shine-UI/static-sites/design-examples/channels-v1/mockups.js @@ -0,0 +1,246 @@ +/* Изолированный прототип: без API, импортов приложения и постоянного хранилища. */ +'use strict'; +const icons = { + back: '', + close: '', + more: '', + search: '', + plus: '', + heart: '', + chat: '', + share: '', + channels: '', + people: '', + bell: '', + user: '', + book: '', + file: '', + attach: '', + image: '', + expand: '', + check: '', + edit: '', + wifi: '', + battery: '', +}; +const icon = name => ``; +const escapeText = value => String(value).replace(/[&<>"']/g, c => ({'&':'&', '<':'<', '>':'>', '"':'"', "'":'''}[c])); +const button = (action, label, name, extra = '') => ``; +const concepts = [ + { id:'mono', number:'01', title:'Чистый монохром', description:'Контент на первом плане. Знакомый ритм Threads.', colors:['#111212','#2c2d2d','#f3f3f1'], tag:'МАКСИМАЛЬНО ПРИВЫЧНО', note:'Плоская лента, аватар на полях и полноэкранный ответ. Самая нейтральная отправная точка.' }, + { id:'warm', number:'02', title:'Тёплый SHiNE', description:'Бумажные оттенки. Больше воздуха и мягкости.', colors:['#faf7f0','#efe6d6','#93511e'], tag:'МЯГКИЙ И ЛИЧНЫЙ', note:'Карточки отделяют разговоры, тёплый акцент выделяет действия. Ответ открывается округлым листом снизу.' }, + { id:'night', number:'03', title:'Ночной SHiNE', description:'Глубокий синий. Спокойный свет в деталях.', colors:['#101b20','#213b3a','#94e1ce'], tag:'ХАРАКТЕР SHiNE', note:'Широкий текст и выразительная шапка канала. Мой кандидат на развитие: узнаваемый, но сдержанный.' }, + { id:'club', number:'04', title:'Компактный клуб', description:'Больше разговоров на одном экране.', colors:['#f9fafb','#eaeefa','#4c5caa'], tag:'ПЛОТНО И СОБРАННО', note:'Компактные строки, подчёркнутые вкладки и линии ответов. Редактор на весь экран, без потери контекста.' }, +]; +const channels = [ + { title:'Город и люди', owner:'anna', slug:'city', symbol:'⌂', color:'city', preview:'Маленькие открытия по дороге домой', time:'12 мин', unread:3, following:true }, + { title:'Дизайн каждый день', owner:'mikhail', slug:'design', symbol:'Aa', color:'blue', preview:'Как сделать сложное простым?', time:'38 мин', unread:7, following:true }, + { title:'Тихие маршруты', owner:'lena', slug:'walks', symbol:'↗', color:'green', preview:'Сохранили маршрут на выходные', time:'1 ч', unread:2, following:true }, + { title:'Книжная полка', owner:'olga', slug:'books', symbol:'≋', color:'rose', preview:'Что сейчас читаете?', time:'2 ч', unread:0, following:true }, + { title:'SHiNE · новости', owner:'shine', slug:'news', symbol:'✳', color:'violet', preview:'Место, где начинаются разговоры', time:'вчера', unread:0, following:false }, + { title:'Мои заметки', owner:'you', slug:'notes', symbol:'✎', color:'blue', preview:'Идея для следующей встречи', time:'вчера', unread:0, following:false }, +]; +let role = 'reader'; +let showKeyboard = false; +const states = new Map(); +const mainText = 'Иногда лучший маршрут — чуть длиннее обычного. Сегодня свернула к реке и нашла это место. А где вы перезагружаетесь после рабочего дня?'; +const secondText = 'Собрала наши любимые места в один список. Добавляйте свои находки в ответах!'; +const landscape = () => ``; +function header(s) { + if (s.screen === 'list') return `
✳

Каналы

${button('new-channel','Создать канал','plus')}
`; + return `
${button('back','Назад','back')}

${s.screen === 'thread' ? 'Обсуждение' : escapeText(s.channel.title)}

${s.screen === 'thread' ? escapeText(s.channel.title) : '@'+escapeText(s.channel.owner)+' / '+escapeText(s.channel.slug)}
${button('notifications',s.muted ? 'Включить уведомления' : 'Настроить уведомления','bell')}${button('channel-menu','Меню канала','more')}
`; +} +function nav() { + return ``; +} +function rows(s) { + const filtered = channels.filter(c => (s.filter!=='mine'||c.owner==='you') && (s.filter!=='following'||c.following) && `${c.title} ${c.owner}`.toLowerCase().includes(s.query.toLowerCase())); + return filtered.length ? filtered.map(c=>``).join('') : '

Каналы не найдены.
Попробуйте другое название или логин.

'; +} +function list(s) { + return `
${[['all','Все'],['following','Подписки'],['mine','Мои']].map(([value,label])=>``).join('')}
${rows(s)}

Хорошие разговоры начинаются
с общих интересов.

`; +} +function actions(s, id, count, replies) { + const liked = s.likes.has(id); + replies = 2 + s.replies.filter(reply => reply.post === id).length; + return `
`; +} +function post(s, second = false) { + return `
АК
${escapeText(s.channel.owner==='anna'?'Анна К.':s.channel.owner)}${second?'вчера, 19:10':'12 минут назад'}
${second?'

Собрала наши любимые места в один список. Добавляйте свои находки в ответах!

'+icon('file')+'
Места для прогулок.pdfPDF · 552 КБ
':`

Маленькие открытия

${mainText}

${landscape()}`}${actions(s, second?'second':'first',second?12:24, second?3:2+s.replies.length)}
`; +} +function feed(s) { + return `

Замечаем красоту рядом. Делимся местами,
историями и маленькими открытиями.

Новые сообщения
${post(s)}${post(s,true)}`; +} +function replyRow(name, initials, color, text, target='') { + return `
${initials}
${target?`
В ответ ${escapeText(target)}
`:''}${escapeText(name)} · сейчас

${escapeText(text)}

`; +} +function thread(s) { + const replies=s.replies.filter(r=>r.post===s.activePost); + const second=s.activePost==='second'; + return `${post(s,second)}
Ответы · ${2+replies.length}По порядку
${replyRow('Михаил','МЛ','blue',second?'Спасибо за список! Добавлю ещё парк у старого моста.':'У воды всегда легче выдохнуть. Это возле старого моста?')}${replyRow('Лена','ЛС','green',second?'В выходные попробую один из маршрутов.':'А я ухожу гулять без наушников. Город звучит совсем иначе.')}${replies.map(r=>replyRow('Вы','ВЫ','self',r.text,r.target)).join('')}`; +} +function composer(s) { + if (s.screen==='list') return ''; + if (s.screen==='feed' && role==='reader') return ''; + return `
`; +} +function render(s) { + const previousView = s.phone.querySelector('.view'); + s.scrolls ||= {}; + if (previousView && s.renderedScreen) s.scrolls[s.renderedScreen] = previousView.scrollTop; + s.phone.innerHTML = `
9:41${icon('wifi')}${icon('battery')}
${header(s)}
${s.screen==='list'?list(s):s.screen==='feed'?feed(s):thread(s)}
${composer(s)}${nav()}`; + if(s.screen==='feed') for(const text of s.posts) { + const item=document.createElement('article'); item.className='post'; + const author=document.createElement('strong'); author.textContent='Вы · сейчас'; + const body=document.createElement('p'); body.textContent=text; item.append(author,body); + s.phone.querySelector('.view').append(item); + } + s.phone.querySelector('.view').scrollTop = s.scrolls[s.screen] || 0; + s.renderedScreen = s.screen; + if (s.editor) mountEditor(s); +} +function keyboard() { + return ``; +} +function mountEditor(s) { + const mode = s.editor; + const full = s.id==='mono'||s.id==='club'||s.expanded; + const overlay = document.createElement('div'); + overlay.className = 'overlay'; + overlay.innerHTML = ``; + s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=true); + s.phone.append(overlay); + overlay.querySelector('.sheet').focus({preventScroll:true}); +} +function attachmentMarkup(s) { + const name = s.attachments[s.editor]; + return name?`
${icon('file')}${escapeText(name)}${button('remove-attachment','Удалить вложение','close')}
`:''; +} +function removeOverlay(s) { + s.phone.querySelector('.overlay')?.remove(); + s.phone.querySelectorAll(':scope > *').forEach(el=>el.inert=false); +} +function closeEditor(s) { + s.editor = null; + removeOverlay(s); + if (s.returnFocus?.isConnected) s.returnFocus.focus({preventScroll:true}); + else s.phone.querySelector('[data-action="reply"], [data-action="new-post"], [data-action="back"]')?.focus({preventScroll:true}); +} +function openEditor(s, mode='reply', author=s.channel.owner, text=s.activePost==='second'?secondText:mainText, trigger=null) { + if (s.editor) closeEditor(s); + s.editor=mode; + s.replyTo=author; + s.replyText=text; + s.returnFocus=trigger; + s.expanded=false; + mountEditor(s); +} +function toast(s, message) { + s.phone.querySelector('.toast')?.remove(); + clearTimeout(s.toastTimer); + const el=document.createElement('div'); + el.className='toast'; el.setAttribute('role','status'); el.textContent=message; + s.phone.append(el); + s.toastTimer=setTimeout(()=>el.remove(),3500); +} +function menu(s, title, contents, trigger) { + removeOverlay(s); + s.returnFocus=trigger; + const el=document.createElement('div'); el.className='overlay'; + el.innerHTML=``; + s.phone.querySelectorAll(':scope > *').forEach(child=>child.inert=true); + s.phone.append(el); el.querySelector('.sheet').focus({preventScroll:true}); +} +const menuAction=(action,label,i='file')=>``; +function updateEditor(s) { + const modal=s.phone.querySelector('.overlay'); + modal.querySelector('.char-count').textContent=`${s.drafts[s.editor].length} / 2000`; + modal.querySelector('[data-action="send"]').disabled=!s.drafts[s.editor].trim()&&!s.attachments[s.editor]; + modal.querySelector('.selected-attachment').innerHTML=attachmentMarkup(s); +} +function handle(s, event) { + const el=event.target.closest('[data-action]'); + if (!el || el.disabled) return; + const action=el.dataset.action; + if (action==='filter') { s.filter=el.dataset.value; render(s); } + else if (action==='open-channel') { s.channel=channels[Number(el.dataset.index)]; s.screen='feed'; s.activePost='first'; s.replies=[]; s.posts=[]; s.likes.clear(); render(s); } + else if (action==='list'||action==='back') { s.screen=action==='list'||s.screen==='feed'?'list':'feed'; render(s); } + else if (action==='thread') { s.activePost=el.dataset.post||s.activePost; s.screen='thread'; render(s); } + else if (action==='reply'||action==='new-post') { s.activePost=el.dataset.post||s.activePost; openEditor(s,action==='new-post'?'post':'reply',s.channel.owner,s.activePost==='second'?secondText:mainText,el); } + else if (action==='reply-to') openEditor(s,'reply',el.dataset.author,el.dataset.text,el); + else if (action==='close-editor') { closeEditor(s); toast(s,'Черновик сохранён до закрытия страницы'); } + else if (action==='expand') { s.expanded=!s.expanded; removeOverlay(s); mountEditor(s); } + else if (action==='attach') s.phone.querySelector('[data-file]').click(); + else if (action==='remove-attachment') { s.attachments[s.editor]=''; updateEditor(s); } + else if (action==='send') { + const mode=s.editor; + const text=s.drafts[mode].trim(); + const attachment=s.attachments[mode]; + if (!text&&!attachment) return; + if (mode==='reply') s.replies.push({text:text+(attachment?'\n📎 '+attachment:''),target:s.replyTo,post:s.activePost}); + else s.posts.push(text+(attachment?'\n📎 '+attachment:'')); + s.drafts[mode]=''; s.attachments[mode]=''; s.editor=null; s.screen=mode==='reply'?'thread':'feed'; render(s); + const view=s.phone.querySelector('.view'); view.scrollTop=view.scrollHeight; + s.phone.querySelector('.composer-trigger')?.focus({preventScroll:true}); toast(s,'Отправлено в макете. Данные на сервер не передаются.'); + } + else if (action==='like') { + const key=el.dataset.post; const liked=s.likes.has(key); liked?s.likes.delete(key):s.likes.add(key); + el.setAttribute('aria-pressed',String(!liked)); el.querySelector('span').textContent=String(Number(el.querySelector('span').textContent)+(liked?-1:1)); + } + else if (action==='share') menu(s,'Поделиться', '

В приложении здесь появится системное меню отправки ссылки. В макете внешние ссылки не создаются.

'+menuAction('close-menu','Понятно','check'),el); + else if (action==='subscribe') { s.subscribed=!s.subscribed; el.textContent=s.subscribed?'Вы подписаны':'Подписаться'; } + else if (action==='notifications') { s.muted=!s.muted; el.setAttribute('aria-label',s.muted?'Включить уведомления':'Настроить уведомления'); toast(s,s.muted?'Уведомления канала выключены в макете':'Уведомления канала включены в макете'); } + else if (action==='post-menu') menu(s,'Сообщение',menuAction('reactions','Кто поставил лайк','heart')+menuAction('details','Данные записи','file')+(role==='owner'?menuAction('edit-demo','Редактировать','edit'):'')+'

Редкие действия собраны здесь, чтобы оставлять больше места самому сообщению.

',el); + else if (action==='channel-menu') menu(s,s.channel.title,menuAction('about','О канале','channels')+menuAction('contents','Оглавление','book')+menuAction('notifications','Уведомления','bell'),el); + else if (action==='contents') menu(s,'Оглавление','

С чего начать знакомство с каналом

'+menuAction('jump-post','01 · Маленькие открытия','book')+menuAction('jump-file','02 · Наш список любимых мест','file'),el); + else if (action==='jump-post'||action==='jump-file') { removeOverlay(s); s.screen='feed'; render(s); s.phone.querySelectorAll('.post')[action==='jump-file'?1:0]?.scrollIntoView({block:'nearest'}); } + else if (action==='reactions') menu(s,'Лайки','

Все: 24 · основные: 18 · сияющие: 6

Названия категорий сохранены из текущего приложения. Их объяснение пользователю нужно согласовать отдельно.

',el); + else if (action==='details') menu(s,'Данные записи','

Здесь доступны номер записи, цепочка автора, хэш и подпись. В мокапе технические данные не генерируются.

',el); + else if (action==='about') menu(s,'О канале','

Замечаем красоту рядом. Делимся местами, историями и маленькими открытиями.

Автор: @'+escapeText(s.channel.owner)+'

',el); + else if (action==='close-menu') { removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); } + else if (action==='new-channel') toast(s,'Создание канала — следующий этап проектирования'); + else if (action==='edit-demo') toast(s,'Редактор существующего сообщения — следующий этап'); + else if (action==='outside') toast(s,'Сейчас проектируем каналы. Этот раздел пока вне макета.'); +} +function attachEvents(s) { + s.phone.addEventListener('click',event=>handle(s,event)); + s.phone.addEventListener('input',event=>{ + if(event.target.matches('[data-search]')) { s.query=event.target.value; s.phone.querySelector('.channel-rows').innerHTML=rows(s); } + if(event.target.matches('.editor-input')) { s.drafts[s.editor]=event.target.value; updateEditor(s); } + }); + s.phone.addEventListener('change',event=>{ + if(event.target.matches('[data-file]')) { s.attachments[s.editor]=event.target.files[0]?.name||''; updateEditor(s); } + }); + s.phone.addEventListener('keydown',event=>{ + const dialog=s.phone.querySelector('[role="dialog"]'); + if(!dialog) return; + if(event.key==='Escape') { event.preventDefault(); s.editor?closeEditor(s):removeOverlay(s); if(s.returnFocus?.isConnected)s.returnFocus.focus({preventScroll:true}); } + if(event.key==='Tab') { + const focusable=[...dialog.querySelectorAll('button:not(:disabled), textarea, input:not([hidden])')]; + const first=focusable[0], last=focusable.at(-1); + if(event.shiftKey&&(document.activeElement===first||document.activeElement===dialog)) { event.preventDefault(); last?.focus(); } + else if(!event.shiftKey&&(document.activeElement===last||document.activeElement===dialog)) { event.preventDefault(); first?.focus(); } + } + }); +} +function init() { + const query=new URLSearchParams(location.search); + const chosen=concepts.find(c=>c.id===query.get('variant')); + const gallery=document.getElementById('gallery'); + if(chosen) {gallery.classList.add('single');document.body.classList.add('focused');} + for(const c of chosen?[chosen]:concepts) { + const article=document.createElement('section');article.className='concept';article.setAttribute('aria-label',c.title); + article.innerHTML=`
${c.number}

${c.title}

↗

${c.description}

${c.tag}
${c.note}
`; + gallery.append(article); + const s={id:c.id,phone:article.querySelector('.phone'),screen:'list',activePost:'first',channel:channels[0],query:'',filter:'all',likes:new Set(),subscribed:true,muted:false,replies:[],posts:[],drafts:{reply:'',post:''},attachments:{reply:'',post:''},editor:null}; + states.set(c.id,s);render(s);attachEvents(s); + } + document.querySelectorAll('[data-screen]').forEach(el=>el.addEventListener('click',()=>{ + document.querySelectorAll('[data-screen]').forEach(b=>{b.classList.toggle('selected',b===el);b.setAttribute('aria-pressed',String(b===el));}); + for(const s of states.values()) { s.editor=null;s.screen=el.dataset.screen==='reply'?'thread':el.dataset.screen;render(s);if(el.dataset.screen==='reply')openEditor(s); } + el.focus({preventScroll:true}); + })); + document.getElementById('role').addEventListener('change',event=>{role=event.target.value;for(const s of states.values())render(s);}); + document.getElementById('keyboard').addEventListener('change',event=>{showKeyboard=event.target.checked;for(const s of states.values())if(s.editor){removeOverlay(s);mountEditor(s);}event.target.focus({preventScroll:true});}); +} +init(); diff --git a/shine-UI/static-sites/index.html b/shine-UI/static-sites/index.html new file mode 100644 index 00000000..6b1cb93b --- /dev/null +++ b/shine-UI/static-sites/index.html @@ -0,0 +1,98 @@ + + + + + + SHiNE static sites + + + +
+

SHiNE static sites

+

Рабочая папка для временных статических сайтов, дизайн-примеров и standalone-viewer'ов.

+ + +
+ + diff --git a/shine-UI/styles/app-shell.css b/shine-UI/styles/app-shell.css index a360a1ae..bcbe5f71 100644 --- a/shine-UI/styles/app-shell.css +++ b/shine-UI/styles/app-shell.css @@ -3,7 +3,7 @@ * Feature-specific стили находятся в styles/features/* и network-graph.css. */ .app-shell { - width: min(var(--app-viewport-width, 100vw), 430px); + width: min(var(--app-viewport-width, 100vw), var(--shell-content-width, 430px)); height: var(--app-viewport-height, 100vh); position: fixed; top: var(--app-viewport-offset-top, 0px); @@ -21,7 +21,7 @@ background: transparent; border-left: 1px solid transparent; border-right: 1px solid transparent; - box-shadow: var(--shadow); + box-shadow: none; overflow: hidden; } @@ -33,9 +33,11 @@ bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px)); z-index: var(--z-shell-content); overflow-y: auto; - padding: 14px 14px 24px; + padding: 0 16px 24px; } +.app-shell--wide { --shell-content-width: 640px; } + .screen-content.no-app-chrome { top: var(--call-minimized-bar-height, 0px); bottom: 0; @@ -74,9 +76,9 @@ .composer-slot { position: absolute; left: 50%; - width: min(var(--app-viewport-width, 100vw), 430px); + width: 100%; transform: translateX(-50%); - bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom)); + bottom: var(--toolbar-height, 78px); padding: 0 12px; pointer-events: none; } @@ -90,8 +92,9 @@ left: 0; right: 0; bottom: 0; - padding: 2px 10px calc(4px + env(safe-area-inset-bottom)); - background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%); + padding: 0 4px env(safe-area-inset-bottom); + background: var(--background); + border-top: 1px solid var(--border-subtle); transition: opacity 0.18s ease, transform 0.18s ease; } diff --git a/shine-UI/styles/buttons-white.css b/shine-UI/styles/buttons-white.css index e0af2edc..ae54b906 100644 --- a/shine-UI/styles/buttons-white.css +++ b/shine-UI/styles/buttons-white.css @@ -1,135 +1,12 @@ -/* - * Shared semantic button roles. - * - * Stage 4: styling is opt-in by an existing UI role class. Component-owned - * controls (TopBar, Dropdown, Toolbar, tabs, ScrollToBottom, etc.) are styled - * by their own owner stylesheet and are intentionally absent from this file. - */ -.ui-button, -.primary-btn, -.secondary-btn, -.destructive-btn, -.ghost-btn, -.icon-btn, -.text-btn, -.shine-btn { - color: #ffffff; - background: transparent; - background-image: none; - border: 0; - box-shadow: none; - text-shadow: none; - backdrop-filter: none; - -webkit-backdrop-filter: none; - transition: - transform 90ms ease, - box-shadow 90ms ease, - background-color 90ms ease, - filter 120ms ease; -} - -.ui-button:hover, -.primary-btn:hover, -.secondary-btn:hover, -.destructive-btn:hover, -.ghost-btn:hover, -.icon-btn:hover, -.text-btn:hover, -.shine-btn:hover { - color: #ffffff; - background: transparent; - border: 0; - box-shadow: none; - filter: brightness(1.12); -} - -.primary-btn:hover, -.secondary-btn:hover, -.destructive-btn:hover, -.ghost-btn:hover, -.icon-btn:hover, -.text-btn:hover, -.shine-btn:hover { - transform: translateY(-1px); -} - -.ui-button:active, -.primary-btn:active, -.secondary-btn:active, -.destructive-btn:active, -.ghost-btn:active, -.icon-btn:active, -.text-btn:active, -.shine-btn:active { - color: #ffffff; - background: rgba(0, 0, 0, 0.12); - border: 0; - box-shadow: - inset 0 3px 8px rgba(0, 0, 0, 0.58), - inset 0 -1px 2px rgba(255, 255, 255, 0.08); - transform: translateY(1px) scale(0.97); - filter: brightness(0.9); -} - -.ui-button:disabled, -.primary-btn:disabled, -.secondary-btn:disabled, -.destructive-btn:disabled, -.ghost-btn:disabled, -.icon-btn:disabled, -.text-btn:disabled, -.shine-btn:disabled, -.ui-button[aria-disabled='true'], -.primary-btn[aria-disabled='true'], -.secondary-btn[aria-disabled='true'], -.destructive-btn[aria-disabled='true'], -.ghost-btn[aria-disabled='true'], -.icon-btn[aria-disabled='true'], -.text-btn[aria-disabled='true'], -.shine-btn[aria-disabled='true'] { - color: rgba(255, 255, 255, 0.42); - background: transparent; - border: 0; - box-shadow: none; - transform: none; - filter: none; -} - -/* Decorative button pseudo-elements were not part of the Stage 3.1 surface. */ -.ui-button::before, -.primary-btn::before, -.secondary-btn::before, -.destructive-btn::before, -.ghost-btn::before, -.icon-btn::before, -.text-btn::before, -.shine-btn::before, -.ui-button::after, -.primary-btn::after, -.secondary-btn::after, -.destructive-btn::after, -.ghost-btn::after, -.icon-btn::after, -.text-btn::after, -.shine-btn::after { - background: transparent; - background-image: none; - border-color: transparent; - box-shadow: none; -} - -.ui-button:focus-visible, -.primary-btn:focus-visible, -.secondary-btn:focus-visible, -.destructive-btn:focus-visible, -.ghost-btn:focus-visible, -.icon-btn:focus-visible, -.text-btn:focus-visible, -.shine-btn:focus-visible { - outline: 2px solid rgba(255, 255, 255, 0.62); - outline-offset: 2px; -} - +/* Семантические роли кнопок. Геометрией сложных компонентов владеют их стили. */ +.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn { color: var(--text-primary); background: transparent; border: 0; box-shadow: none; font: inherit; cursor: pointer; transition: background-color 160ms ease, color 160ms ease; } +.primary-btn { color: var(--on-accent); background: var(--accent); } +.secondary-btn { color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-control); } +.destructive-btn { color: var(--danger); } +:is(.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn):hover { filter: brightness(.96); } +:is(.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn):active { filter: brightness(.9); } +:is(.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn):focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } +:is(.ui-button, .primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .icon-btn, .text-btn, .shine-btn):disabled { opacity: .5; cursor: not-allowed; filter: none; } /* Explicit filled-action variant used by pre-auth/settings/profile screens. */ .filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close) { color: #ffffff; diff --git a/shine-UI/styles/components/attachments.css b/shine-UI/styles/components/attachments.css index 77573041..4e6eb582 100644 --- a/shine-UI/styles/components/attachments.css +++ b/shine-UI/styles/components/attachments.css @@ -1,4 +1,289 @@ /* Shared attachment UI styles. */ +.message-attachments { + display: grid; + gap: 0.5rem; +} + + +.message-attachment-carousel { + gap: 0.35rem; + margin: 0.5rem 0 0.4rem; + max-width: min(100%, 34rem); +} + + +.message-attachment-carousel-viewport { + align-items: center; + background: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: 14px; + display: grid; + min-height: 12rem; + overflow: hidden; + position: relative; + touch-action: pan-y; +} + + +.message-attachment-carousel-viewport.has-landscape-media { + align-items: start; + min-height: 0; +} + + +.message-attachment-slide { + align-items: stretch; + display: grid; + min-height: 12rem; +} + + +.message-attachment-slide.has-landscape-media { + min-height: 0; +} + + +.message-attachment-arrow { + align-items: center; + border-radius: 999px; + cursor: pointer; + display: inline-flex; + font-size: 1.85rem; + height: 44px; + justify-content: center; + line-height: 1; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 44px; + z-index: 2; +} + + +.message-attachment-arrow[hidden] { + display: none; +} + + +.message-attachment-arrow:disabled { + cursor: default; + opacity: 0.28; +} + + +.message-attachment-arrow--prev { + left: 0.55rem; +} + + +.message-attachment-arrow--next { + right: 0.55rem; +} + + +.message-attachment-counter { + color: var(--text-secondary); + font-size: 0.78rem; + text-align: center; +} + + +.message-attachment-media-frame { + align-items: center; + background: var(--surface); + cursor: zoom-in; + display: grid; + min-height: 12rem; + overflow: hidden; + position: relative; +} + + +.message-attachment-media-frame.is-landscape { + min-height: 0; +} + + +.message-attachment-media { + display: block; + height: min(52vh, 20rem); + max-height: 20rem; + object-fit: contain; + width: 100%; +} + + +.message-attachment-media.is-landscape { + height: auto; +} + + +.message-attachment-play { + align-items: center; + background: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: 999px; + color: var(--text-primary); + display: inline-flex; + font-size: 1.7rem; + height: 4rem; + justify-content: center; + left: 50%; + padding-left: 0.18rem; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 4rem; +} + + +.message-attachment-download { + display: inline-flex; + align-items: center; + min-height: 44px; + background: var(--accent); + border-radius: 999px; + color: var(--on-accent); + font-size: 0.82rem; + font-weight: 700; + padding: 0.45rem 0.75rem; + position: relative; + text-decoration: none; + width: max-content; + z-index: 3; +} + + +.message-attachment-media-frame > .message-attachment-download { + bottom: 0.75rem; + position: absolute; + right: 0.75rem; +} + + +.message-attachment-file-card { + align-items: center; + background: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: 0.8rem; + color: var(--text-primary); + cursor: pointer; + display: grid; + gap: 0.55rem; + grid-template-columns: auto minmax(0, 1fr); + min-height: 3.25rem; + padding: 0.55rem 0.7rem; +} + + +.message-attachment-file-ext { + align-items: center; + background: var(--accent); + border-radius: 999px; + color: var(--on-accent); + display: inline-flex; + font-size: 0.72rem; + font-weight: 800; + height: 1.7rem; + justify-content: center; + min-width: 3.25rem; + padding: 0 0.55rem; +} + + +.message-attachment-file-main { + display: grid; + gap: 0.08rem; + min-width: 0; +} + + +.message-attachment-file-main .message-attachment-meta { + color: var(--text-secondary); +} + + +.message-attachment-unavailable { + align-items: center; + background: var(--surface); + color: var(--danger); + display: flex; + font-weight: 700; + justify-content: center; + min-height: 12rem; + padding: 1.25rem; + text-align: center; +} + + +.message-attachment-card { + align-items: center; + background: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: 1rem; + color: var(--text-primary); + display: flex; + gap: 0.7rem; + padding: 0.75rem 0.85rem; + text-decoration: none; +} + + +.message-attachment-icon { + align-items: center; + background: var(--surface); + border-radius: 0.8rem; + color: var(--text-primary); + display: inline-flex; + flex: 0 0 auto; + height: 2.1rem; + justify-content: center; + width: 2.1rem; +} + + +.message-attachment-main { + display: grid; + min-width: 0; +} + + +.message-attachment-name { + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + + +.message-attachment-meta { + color: var(--text-secondary); + font-size: 0.78rem; + overflow-wrap: anywhere; +} + + +@media (max-width: 640px) { +.message-attachment-carousel { + max-width: 100%; + } +.message-attachment-carousel-viewport, +.message-attachment-slide, +.message-attachment-media-frame, +.message-attachment-unavailable { + min-height: 10rem; + } +.message-attachment-media { + height: min(42vh, 16rem); + } +.message-attachment-file-card { + grid-template-columns: auto minmax(0, 1fr); + } +.message-attachment-file-card .message-attachment-download { + grid-column: 1 / -1; + } +} +.ar-attachment-manager-root .modal { z-index: 120; } .attachment-viewer-modal { z-index: 80; @@ -6,11 +291,11 @@ .attachment-viewer-card { - background: rgba(15, 23, 42, 0.96); - border: 1px solid rgba(255, 255, 255, 0.12); + background: var(--surface); + border: 1px solid var(--border-subtle); border-radius: 1.2rem; - box-shadow: 0 24px 90px rgba(0, 0, 0, 0.45); - color: #fff; + box-shadow: none; + color: var(--text-primary); display: grid; gap: 0.8rem; max-height: 92vh; @@ -47,7 +332,7 @@ .attachment-viewer-media { - background: #020617; + background: var(--background); border-radius: 0.8rem; max-height: 76vh; max-width: 100%; @@ -66,7 +351,7 @@ .ar-attachment-meta { - color: #cbd5e1; + color: var(--text-secondary); display: grid; font-size: 0.86rem; gap: 0.25rem; @@ -76,7 +361,7 @@ .ar-attachment-wallet-select, .ar-attachment-wallet-select option { - color: #38bdf8; + color: var(--accent); } @@ -96,7 +381,7 @@ .ar-attachment-history-table th, .ar-attachment-history-table td { - border-bottom: 1px solid rgba(148, 163, 184, 0.25); + border-bottom: 1px solid var(--border-subtle); padding: 0.55rem; text-align: left; vertical-align: top; @@ -104,7 +389,7 @@ .ar-attachment-history-table th { - color: #cbd5e1; + color: var(--text-secondary); font-weight: 700; } @@ -121,7 +406,7 @@ overflow-x: hidden; overflow-y: auto; padding: 0.35rem; - scrollbar-color: rgba(212, 175, 55, 0.65) rgba(255, 255, 255, 0.06); + scrollbar-color: var(--border-control) var(--border-subtle); scrollbar-width: thin; width: 100%; } @@ -135,27 +420,27 @@ .ar-attachment-history-tiles::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.04); + background: var(--border-subtle); } .ar-attachment-history-tiles::-webkit-scrollbar-thumb { - background: rgba(212, 175, 55, 0.7); + background: var(--border-control); border-radius: 999px; } .ar-attachment-history-tiles::-webkit-scrollbar-thumb:hover { - background: rgba(240, 198, 76, 0.9); + background: var(--border-control); } .ar-attachment-history-tile { - background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(22, 36, 53, 0.96)); + background: var(--surface); border: 1px solid transparent; border-radius: 0.52rem; box-sizing: border-box; - color: #f8fafc; + color: var(--text-primary); display: grid; gap: 0.12rem; max-width: 100%; @@ -167,13 +452,13 @@ .ar-attachment-history-tile.is-selected { - border-color: rgba(34, 197, 94, 0.55); - box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.18); + border-color: var(--success); + box-shadow: none; } .ar-attachment-history-tile--page { - box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18); + box-shadow: none; } @@ -185,7 +470,7 @@ .ar-attachment-history-preview { align-items: flex-end; - background: rgba(255, 255, 255, 0.05); + background: var(--border-subtle); border-radius: 0.42rem; display: flex; justify-content: space-between; @@ -204,11 +489,11 @@ .ar-attachment-history-preview-badge { - background: rgba(15, 23, 42, 0.72); - border: 1px solid rgba(255, 255, 255, 0.12); + background: var(--surface); + border: 1px solid var(--border-subtle); border-radius: 999px; bottom: 0.42rem; - color: #f8fafc; + color: var(--text-primary); font-size: 0.62rem; left: 0.42rem; padding: 0.2rem 0.46rem; @@ -245,7 +530,7 @@ .ar-attachment-history-meta-row { align-items: center; - color: #cbd5e1; + color: var(--text-secondary); display: flex; flex-wrap: wrap; font-size: 0.6rem; @@ -256,9 +541,9 @@ .ar-attachment-history-txid { - background: rgba(255, 255, 255, 0.06); + background: var(--border-subtle); border-radius: 0.34rem; - color: rgba(226, 232, 240, 0.86); + color: var(--text-secondary); font-size: 0.56rem; line-height: 1.05; max-width: 100%; @@ -281,27 +566,27 @@ .ar-attachment-status--available { - background: rgba(220, 252, 231, 0.96); - color: #166534; + background: var(--surface-selected); + color: var(--success); } .ar-attachment-status--pending { - background: rgba(254, 249, 195, 0.96); - color: #854d0e; + background: var(--surface-selected); + color: var(--warning); } .ar-attachment-status--unavailable { - background: rgba(254, 226, 226, 0.96); - color: #991b1b; + background: var(--surface-selected); + color: var(--danger); } .ar-attachment-placement-flag { - background: rgba(219, 234, 254, 0.96); + background: var(--surface-selected); border-radius: 999px; - color: #1e3a8a; + color: var(--accent); flex: 0 0 auto; font-size: 0.58rem; font-weight: 800; diff --git a/shine-UI/styles/components/avatar.css b/shine-UI/styles/components/avatar.css index 98c03094..a0c31cec 100644 --- a/shine-UI/styles/components/avatar.css +++ b/shine-UI/styles/components/avatar.css @@ -269,3 +269,30 @@ pointer-events: none; z-index: -1; } + +/* Единый знак официального пользователя. Левый и нижний края зафиксированы, + а сам знак растёт вправо и вверх вместе с аватаром. */ +.avatar.avatar-image.avatar-framed > .avatar-official-badge { + position: absolute; + left: -2%; + bottom: -1%; + width: 32%; + height: 32%; + min-width: 18px; + min-height: 18px; + object-fit: contain; + object-position: center; + display: block; + opacity: 1; + border-radius: 0; + pointer-events: none; + z-index: 4; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42)); +} + +/* Спокойный вариант для текстовых лент; фото остаётся без фильтров. */ +.avatar.avatar-image.avatar-plain { overflow: hidden; background: var(--surface-selected); color: var(--accent); } +.avatar.avatar-image.avatar-plain::after { content: none; } +.avatar.avatar-image.avatar-plain > .avatar-fallback, +.avatar.avatar-image.avatar-plain > .avatar-photo { width: 100%; height: 100%; box-shadow: none; text-shadow: none; } +.avatar.avatar-image.avatar-plain > .avatar-fallback { background: var(--surface-selected); color: var(--accent); } diff --git a/shine-UI/styles/components/channel-editor.css b/shine-UI/styles/components/channel-editor.css new file mode 100644 index 00000000..2aefc954 --- /dev/null +++ b/shine-UI/styles/components/channel-editor.css @@ -0,0 +1,219 @@ +.channel-editor-overlay { + position: fixed; + top: var(--editor-top, 0px); + left: 0; + right: 0; + height: var(--editor-height, 100dvh); + z-index: 90; + display: grid; + background: var(--scrim); +} + +.channel-editor { + width: min(100%, 640px); + height: 100%; + min-height: 0; + margin-inline: auto; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + color: var(--text-primary); + background: var(--background); +} + +.channel-editor__header { + min-height: calc(56px + env(safe-area-inset-top)); + padding: env(safe-area-inset-top) 6px 0; + display: grid; + grid-template-columns: 44px minmax(0, 1fr) 44px; + align-items: center; + border-bottom: 1px solid var(--border-subtle); +} + +.channel-editor__header h2 { + font-size: 16px; + line-height: 24px; + font-weight: 600; + text-align: center; +} + +.channel-editor__close { + width: 44px; + height: 44px; + padding: 0; + font-size: 28px; + color: var(--text-primary); +} + +.channel-editor__body { + min-height: 0; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; +} + +.channel-editor__context { + padding-left: 12px; + border-left: 2px solid var(--accent); + color: var(--text-secondary); +} + +.channel-editor__context strong { + display: block; + margin-bottom: 4px; + color: var(--text-primary); + font-size: 13px; +} + +.channel-editor__context p { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + font-size: 13px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.channel-editor__context.is-expanded p { display: block; } + +.channel-editor__context-toggle, +.channel-editor__clear { + min-height: 44px; + padding: 6px 0; + color: var(--accent); + font-size: 13px; +} + +.channel-editor__input { + flex: 1 0 auto; + overflow-y: hidden; + width: 100%; + min-height: 160px; + padding: 0; + resize: none; + border: 0; + outline: 0; + color: var(--text-primary); + background: transparent; + font: inherit; + font-size: 16px; + line-height: 24px; + caret-color: var(--accent); +} + +.channel-editor__input::placeholder { color: var(--text-secondary); } + +.channel-editor__type { + display: grid; + gap: 6px; + color: var(--text-secondary); + font-size: 13px; +} + +.channel-editor__type .select { + color: var(--text-primary); + background: var(--surface); + border-color: var(--border-control); +} + +.channel-editor__input:focus-visible { + border-radius: 4px; + outline: 2px solid var(--focus-ring); + outline-offset: 4px; +} + +.channel-editor__attachments { display: grid; gap: 8px; } +.channel-editor__author { display: flex; align-items: center; gap: 12px; font-size: .875rem; } +.channel-editor__author .avatar { width: 40px; height: 40px; min-width: 40px; min-height: 40px; } +.channel-editor__context-toggle[hidden] { display: none; } +.channel-editor__attach[hidden], .channel-editor__clear[hidden] { display: none; } + +.channel-editor__attachment { + min-height: 44px; + padding: 6px 8px 6px 12px; + display: grid; + grid-template-columns: minmax(0, 1fr) 44px; + align-items: center; + gap: 8px; + border: 1px solid var(--border-subtle); + border-radius: 12px; + background: var(--surface); + font-size: 13px; +} + +.channel-editor__attachment span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.channel-editor__attachment button { min-width: 44px; min-height: 44px; color: var(--text-secondary); } + +.channel-editor__error { + min-height: 18px; + color: var(--danger); + font-size: 13px; + line-height: 18px; +} + +.channel-editor__footer { + min-height: calc(68px + env(safe-area-inset-bottom)); + padding: 12px 16px calc(12px + env(safe-area-inset-bottom)); + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + border-top: 1px solid var(--border-subtle); + background: var(--background); +} + +.channel-editor__attach, +.channel-editor__submit { + min-height: 44px; + padding: 0 14px; + border-radius: 12px; +} + +.channel-editor__attach { + color: var(--accent); + border: 1px solid var(--border-control); + background: var(--surface); +} + +.channel-editor__submit { + color: var(--on-accent); + background: var(--accent); +} + +.channel-editor__counter { + min-width: 0; + color: var(--text-secondary); + font-size: 12px; + text-align: right; +} + +.channel-editor__counter.is-near-limit { color: var(--warning); } + +@media (min-width: 680px) { + .channel-editor-overlay { place-items: center; padding: 24px; } + .channel-editor { + height: min(760px, 100%); + border: 1px solid var(--border-subtle); + border-radius: 20px; + box-shadow: var(--shadow); + } +} + +@media (max-width: 370px) { + .channel-editor__footer { grid-template-columns: auto minmax(0, 1fr); } + .channel-editor__counter { grid-column: 1 / -1; grid-row: 1; } + .channel-editor__attach { grid-column: 1; } + .channel-editor__submit { grid-column: 2; } +} + +@media (prefers-reduced-motion: reduce) { + .channel-editor-overlay, .channel-editor { animation: none; transition: none; } +} diff --git a/shine-UI/styles/components/dropdown-menu.css b/shine-UI/styles/components/dropdown-menu.css index 836e623f..06c69485 100644 --- a/shine-UI/styles/components/dropdown-menu.css +++ b/shine-UI/styles/components/dropdown-menu.css @@ -1,145 +1,12 @@ -:root { - --z-overlay: 40; - --z-dropdown: 50; - --z-modal: 60; -} - -.dropdown-portal { - pointer-events: none; -} - -.dropdown-backdrop { - position: fixed; - inset: 0; - z-index: var(--z-overlay); - pointer-events: auto; - background: rgba(2, 6, 14, 0.34); - backdrop-filter: blur(1.5px); - -webkit-backdrop-filter: blur(1.5px); -} - -.dropdown-menu { - position: fixed; - z-index: var(--z-dropdown); - display: grid; - gap: 2px; - padding: 6px; - min-width: 196px; - max-width: calc(100vw - 20px); - border: 1px solid rgba(92, 190, 255, 0.22); - border-radius: 16px; - background: linear-gradient(155deg, rgba(19, 27, 42, 0.97), rgba(7, 12, 22, 0.98)); - box-shadow: 0 18px 42px rgba(0, 0, 0, 0.48), 0 0 24px rgba(39, 141, 255, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.06); - backdrop-filter: blur(20px) saturate(125%); - -webkit-backdrop-filter: blur(20px) saturate(125%); - pointer-events: auto; -} - -.dropdown-menu--transparent { - border-color: rgba(92, 190, 255, 0.06); - background: rgba(7, 12, 22, 0.44); - box-shadow: 0 18px 42px rgba(0, 0, 0, 0.30), inset 0 1px 0 rgba(255, 255, 255, 0.035); -} - -.dropdown-menu__item { - width: 100%; - min-height: 42px; - padding: 0 11px; - display: flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - border: 0; - border-radius: 10px; - background: transparent; - background-image: none; - box-shadow: none; - color: #ffffff; - text-shadow: none; - backdrop-filter: none; - -webkit-backdrop-filter: none; - font: inherit; - font-size: 14px; - font-weight: 600; - line-height: 1.2; - text-align: left; - white-space: nowrap; - cursor: pointer; - transition: transform 90ms ease, box-shadow 90ms ease, background-color 90ms ease, filter 120ms ease; -} - -.dropdown-menu__item.is-selected { - background: rgba(39, 141, 255, 0.12); - box-shadow: none; - transform: none; - filter: none; -} - -.dropdown-menu__item:hover { - color: #ffffff; - background: transparent; - border: 0; - box-shadow: none; - transform: none; - filter: none; -} - -.dropdown-menu__item:active { - color: #ffffff; - background: rgba(0, 0, 0, 0.12); - border: 0; - box-shadow: - inset 0 3px 8px rgba(0, 0, 0, 0.58), - inset 0 -1px 2px rgba(255, 255, 255, 0.08); - transform: none; - filter: none; -} - -.dropdown-menu__item:focus-visible { - color: #ffffff; - outline: 2px solid rgba(255, 255, 255, 0.62); - outline-offset: 2px; - background: rgba(39, 141, 255, 0.12); - box-shadow: none; - transform: none; - filter: none; -} - -.dropdown-menu__item.is-danger { color: #ffb7c5; } -.dropdown-menu__item.is-danger:hover, -.dropdown-menu__item.is-danger:active { color: #ffffff; } -.dropdown-menu__item:disabled, -.dropdown-menu__item.is-danger:disabled { - opacity: 0.55; - color: rgba(255, 255, 255, 0.42); - background: transparent; - border: 0; - box-shadow: none; - transform: none; - filter: none; - cursor: default; -} - -.dropdown-menu__item::before, -.dropdown-menu__item::after { - background: transparent; - background-image: none; - border-color: transparent; - box-shadow: none; -} - -.dropdown-menu__icon, -.dropdown-menu__icon svg, -.dropdown-menu__item > img { - width: 18px; - height: 18px; - min-width: 18px; - flex: 0 0 18px; - object-fit: contain; - color: #65b7ff; - filter: drop-shadow(0 0 5px rgba(39, 141, 255, 0.28)); -} - -.dropdown-menu__divider { height: 1px; margin: 4px 8px; background: rgba(255, 255, 255, 0.08); } - -@media (prefers-reduced-motion: reduce) { .dropdown-menu { animation: none; } } +:root { --z-overlay: 100; --z-dropdown: 110; --z-modal: 120; } +.dropdown-portal { pointer-events: none; } +.dropdown-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay); pointer-events: auto; background: var(--scrim); } +.dropdown-menu { position: fixed; z-index: var(--z-dropdown); display: grid; gap: 2px; padding: 6px; min-width: 196px; max-width: calc(100vw - 20px); max-height: calc(100dvh - 20px); overflow-y: auto; border: 1px solid var(--border-subtle); border-radius: 20px; background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow); pointer-events: auto; } +.dropdown-menu__item { width: 100%; min-height: 44px; padding: 10px 12px; display: flex; align-items: center; gap: 10px; border: 0; border-radius: 12px; background: transparent; color: var(--text-primary); font: inherit; font-size: .875rem; line-height: 1.4; text-align: left; cursor: pointer; } +.dropdown-menu__item:hover, .dropdown-menu__item.is-selected { background: var(--surface-selected); } +.dropdown-menu__item:active { background: var(--surface-selected); } +.dropdown-menu__item:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; } +.dropdown-menu__item.is-danger { color: var(--danger); } +.dropdown-menu__item:disabled { opacity: .5; cursor: not-allowed; } +.dropdown-menu__icon, .dropdown-menu__icon svg, .dropdown-menu__item > img { width: 22px; height: 22px; flex: 0 0 22px; object-fit: contain; color: currentColor; } +.dropdown-menu__divider { height: 1px; margin: 4px 8px; background: var(--border-subtle); } diff --git a/shine-UI/styles/components/modal.css b/shine-UI/styles/components/modal.css index 4bdd5b41..482ee6f1 100644 --- a/shine-UI/styles/components/modal.css +++ b/shine-UI/styles/components/modal.css @@ -1,178 +1,20 @@ -/* Shared modal/toast/skeleton primitives. */ - -.modal-title { - font-size: 19px; - line-height: 1.2; - color: #f1d69a; -} - - -.modal-shell[hidden] { - display: none; -} - - -.modal-shell { - position: fixed; - inset: 0; - z-index: 24; -} - - -.modal-backdrop { - position: absolute; - inset: 0; - background: rgba(5, 9, 16, 0.74); - backdrop-filter: blur(4px); -} - - -.modal-dialog { - position: absolute; - left: 16px; - right: 16px; - bottom: 24px; - display: grid; - gap: 12px; - box-shadow: var(--shadow); -} - - -.modal { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.62); - display: grid; - place-items: center; - padding: 20px; - z-index: 20; -} - - -.modal-card { - width: min(100%, 390px); - background: #172238; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 16px; - padding: 14px; -} - - -.modal-card { - background: linear-gradient(165deg, rgba(16, 31, 58, 0.97), rgba(10, 18, 36, 0.97)); - border-color: transparent; - box-shadow: 0 20px 38px rgba(2, 6, 12, 0.55); -} - - -/* ===== Channels UX Stabilization ===== */ -.toast-host { - position: fixed; - left: 0; - right: 0; - bottom: calc(18px + env(safe-area-inset-bottom)); - display: grid; - justify-items: center; - gap: 8px; - z-index: 60; - pointer-events: none; -} - - -.toast { - min-width: min(88vw, 320px); - max-width: min(92vw, 420px); - border-radius: 14px; - padding: 11px 14px; - border: 1px solid transparent; - color: #f2dca8; - background: rgba(10, 14, 23, 0.8); - backdrop-filter: blur(12px); - box-shadow: 0 16px 30px rgba(1, 6, 12, 0.55); - opacity: 0; - transform: translateY(8px); - transition: opacity 0.22s ease, transform 0.22s ease; -} - - -.toast.is-visible { - opacity: 1; - transform: translateY(0); -} - - -.toast.is-hiding { - opacity: 0; - transform: translateY(8px); -} - - -.toast.toast--error { - color: #ffd8e0; - border-color: rgba(234, 122, 150, 0.45); -} - - -.skeleton-card { - display: grid; - gap: 8px; -} - - -.skeleton-line { - height: 10px; - border-radius: 999px; - background: linear-gradient(100deg, rgba(93, 117, 154, 0.10), rgba(167, 191, 226, 0.24), rgba(93, 117, 154, 0.10)); - background-size: 220% 100%; - animation: channels-shimmer 1.2s linear infinite; -} - - +/* Общие диалоги, уведомления и загрузка. */ +.modal-title { font-size: 1rem; line-height: 1.5rem; font-weight: 600; color: var(--text-primary); } +.modal-shell[hidden] { display: none; } +.modal-shell { position: fixed; inset: 0; z-index: 60; } +.modal-backdrop { position: absolute; inset: 0; background: var(--scrim); } +.modal-dialog { position: absolute; left: 16px; right: 16px; bottom: 24px; display: grid; gap: 12px; box-shadow: var(--shadow); } +.modal { position: fixed; inset: 0; background: var(--scrim); display: grid; place-items: center; padding: 16px; z-index: 60; } +.modal-card { width: min(100%, 560px); max-height: calc(100dvh - 32px); overflow-y: auto; color: var(--text-primary); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: 20px; padding: 20px; box-shadow: var(--shadow); } +.toast-host { position: fixed; left: 0; right: 0; bottom: calc(18px + env(safe-area-inset-bottom)); display: grid; justify-items: center; gap: 8px; z-index: 140; pointer-events: none; } +.toast { max-width: min(92vw,420px); padding: 12px 16px; border-radius: 12px; color: var(--text-primary); background: var(--surface); box-shadow: var(--shadow); border: 1px solid var(--border-subtle); opacity: 0; transition: opacity 160ms; } +.toast.is-visible { opacity: 1; } +.toast.is-hiding { opacity: 0; } +.toast.toast--error { color: var(--danger); } +.skeleton-card { display: grid; gap: 12px; } +.skeleton-line { height: 12px; border-radius: 6px; background: var(--surface-selected); animation: skeleton-pulse 1.2s ease-in-out infinite alternate; } .skeleton-line.w-40 { width: 40%; } - .skeleton-line.w-70 { width: 70%; } - .skeleton-line.w-90 { width: 90%; } - - -#about-channel-modal.modal, -#edit-channel-modal.modal, -#reply-modal.modal, -#channel-message-modal.modal, -#channel-edit-description-modal.modal, -#channels-subscribe-modal.modal, -#thread-reply-modal.modal { - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); -} - - -#about-channel-modal .modal-card, -#edit-channel-modal .modal-card, -#reply-modal .modal-card, -#channel-message-modal .modal-card, -#channel-edit-description-modal .modal-card, -#channels-subscribe-modal .modal-card, -#thread-reply-modal .modal-card { - background: rgba(15, 18, 30, 0.92); - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); - border: 1px solid transparent; - border-radius: 20px; - padding: 24px; -} - - -#about-channel-modal .modal-title, -#edit-channel-modal .modal-title, -#reply-modal .modal-title, -#channel-message-modal .modal-title, -#channel-edit-description-modal .modal-title, -#channels-subscribe-modal .modal-title, -#thread-reply-modal .modal-title { - color: rgba(255, 200, 50, 0.95); - font-size: 18px; - font-weight: 700; - margin-bottom: 16px; -} +@keyframes skeleton-pulse { to { opacity: .4; } } +@media (prefers-reduced-motion: reduce) { .skeleton-line, .toast { animation: none; transition: none; } } diff --git a/shine-UI/styles/components/overflow-dots.css b/shine-UI/styles/components/overflow-dots.css index f65ccd7d..1a595873 100644 --- a/shine-UI/styles/components/overflow-dots.css +++ b/shine-UI/styles/components/overflow-dots.css @@ -26,3 +26,12 @@ 0 0 5px rgba(92, 190, 255, 0.72), 0 0 10px rgba(72, 145, 255, 0.34); } + + +/* Явный вариант для шапок, где overflow-кнопку нужно выровнять по эталонной + * позиции экрана «Связи». Поднимаем кнопку вместе с зоной нажатия и якорем + * выпадающего меню, не меняя базовый TopBar для остальных экранов. */ +.topbar-overflow-action--raised { + position: relative; + top: -2px; +} diff --git a/shine-UI/styles/components/primitives.css b/shine-UI/styles/components/primitives.css index 84e3bcc0..2c67acfc 100644 --- a/shine-UI/styles/components/primitives.css +++ b/shine-UI/styles/components/primitives.css @@ -15,18 +15,9 @@ .ghost-btn { border-radius: var(--radius-sm); padding: 9px 12px; - min-height: 38px; + min-height: 44px; font-weight: 600; cursor: pointer; -} - - -.icon-btn, -.text-btn, -.primary-btn, -.secondary-btn, -.destructive-btn, -.ghost-btn { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; @@ -41,14 +32,14 @@ .secondary-btn:disabled, .destructive-btn:disabled, .ghost-btn:disabled { - opacity: 1; + opacity: .5; cursor: not-allowed; } .card { - background: linear-gradient(180deg, rgba(31, 44, 67, 0.62), rgba(21, 30, 48, 0.9)); - border: 1px solid rgba(255, 255, 255, 0.06); + background: var(--surface); + border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: 14px; } @@ -111,14 +102,14 @@ .field-label { - color: #b2c2e6; + color: var(--text-secondary); font-size: 13px; } .select { width: 100%; - border: 1px solid var(--line); + border: 1px solid var(--border-control); border-radius: 12px; background: rgba(255, 255, 255, 0.04); min-height: 44px; @@ -129,8 +120,8 @@ .select:focus { outline: none; - border-color: rgba(83, 216, 251, 0.55); - box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12); + border-color: var(--focus-ring); + box-shadow: 0 0 0 2px var(--focus-ring); } @@ -151,7 +142,7 @@ .status-line.is-unavailable { - color: #ff8d97; + color: var(--danger); } @@ -355,22 +346,22 @@ padding: 0 12px; width: 100%; outline: none; - color: #f3f7ff; + color: var(--text-primary); font-size: 16px; line-height: 1.4; - caret-color: #f1d18a; - -webkit-text-fill-color: #f3f7ff; + caret-color: var(--accent); + -webkit-text-fill-color: var(--text-primary); } .input:focus { - border-color: rgba(83, 216, 251, 0.55); - box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12); + border-color: var(--focus-ring); + box-shadow: 0 0 0 2px var(--focus-ring); } .input::placeholder { - color: rgba(190, 206, 238, 0.82); + color: var(--text-secondary); } @@ -380,8 +371,8 @@ input.input:-webkit-autofill:focus, textarea.input:-webkit-autofill, textarea.input:-webkit-autofill:hover, textarea.input:-webkit-autofill:focus { - -webkit-text-fill-color: #f3f7ff !important; - caret-color: #f1d18a; + -webkit-text-fill-color: var(--text-primary) !important; + caret-color: var(--accent); border-color: var(--line); box-shadow: 0 0 0 1000px rgba(11, 24, 46, 0.96) inset !important; transition: background-color 9999s ease-out 0s; @@ -396,6 +387,7 @@ textarea.input { .inline-error { + color: var(--danger); min-height: 18px; } @@ -495,7 +487,7 @@ textarea.input { .section-title { font-size: 14px; font-weight: 700; - color: #dbe7ff; + color: var(--text-primary); margin: 4px 2px; } @@ -505,56 +497,6 @@ textarea.input { } -#about-channel-modal .input, -#edit-channel-modal .input, -#reply-modal .input, -#channel-message-modal .input, -#channel-edit-description-modal .input, -#channels-subscribe-modal .input, -#thread-reply-modal .input { - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 12px; - color: #ffffff; - padding: 12px 16px; - font-size: 14px; - resize: vertical; - width: 100%; -} - - -#channel-edit-description-counter, -#channel-edit-description-modal #channel-description-counter { - color: rgba(255, 255, 255, 0.35); - font-size: 12px; -} - - -#about-channel-modal .secondary-btn, -#edit-channel-modal .secondary-btn, -#reply-modal .secondary-btn, -#channel-message-modal .secondary-btn, -#channel-edit-description-modal .secondary-btn, -#channels-subscribe-modal .secondary-btn, -#thread-reply-modal .secondary-btn { - border-radius: 12px; - padding: 13px 24px; -} - - -#about-channel-modal .primary-btn, -#edit-channel-modal .primary-btn, -#reply-modal .primary-btn, -#channel-message-modal .primary-btn, -#channel-edit-description-modal .primary-btn, -#channels-subscribe-modal .primary-btn, -#thread-reply-modal .primary-btn { - border-radius: 12px; - padding: 13px 24px; - font-weight: 600; -} - - /* Горизонтальный overflow: орб-ореол .dm-screen::before выходит на 12px по бокам (inset -12px) и даёт лишний скролл. Фон НЕ меняем — клиппим overflow на уровне страницы (как просит ТЗ, п.4). */ html, diff --git a/shine-UI/styles/components/tabs.css b/shine-UI/styles/components/tabs.css index 5f1797cb..fe7f0230 100644 --- a/shine-UI/styles/components/tabs.css +++ b/shine-UI/styles/components/tabs.css @@ -1,16 +1,19 @@ /* Shared tab geometry and tab primitives. */ .tabs { + min-height: 44px; display: grid; grid-template-columns: repeat(2, 1fr); - border: 1px solid rgba(255, 255, 255, 0.08); + gap: 4px; + border: 1px solid var(--border-subtle); border-radius: 12px; padding: 4px; - background: rgba(255, 255, 255, 0.02); + background: var(--surface); } - +.tabs--three { grid-template-columns: repeat(3, minmax(0, 1fr)); } .tab-btn { + min-height: 44px; border: 0; background: transparent; border-radius: 9px; @@ -21,9 +24,12 @@ .tab-btn.active { - background: rgba(83, 216, 251, 0.16); + background: var(--surface-selected); color: var(--text); } +.tab-btn:hover { color: var(--text-primary); } +.tab-btn:active { transform: scale(.98); } +.tab-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; } diff --git a/shine-UI/styles/components/toolbar.css b/shine-UI/styles/components/toolbar.css index 8610a177..56fb681b 100644 --- a/shine-UI/styles/components/toolbar.css +++ b/shine-UI/styles/components/toolbar.css @@ -1,316 +1,13 @@ -/* Bottom toolbar component styles. */ - -.toolbar-item, -.toolbar-action, -.toolbar-button { - user-select: none; - -webkit-user-select: none; - -webkit-touch-callout: none; -} - -/* Базовая поверхность toolbar: финальные effective declarations Этапа 3.1. */ -.toolbar { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 4px; - min-height: 82px; - padding: 9px 9px 6px; - align-items: end; - overflow: visible; - background: transparent; - border: 0; - border-radius: 20px; - box-shadow: 0 18px 32px rgba(2, 6, 13, 0.62); - backdrop-filter: none; - -webkit-backdrop-filter: none; -} - -.toolbar-btn { - min-height: 52px; - padding: 4px 3px 2px; - display: grid; - align-content: end; - justify-items: center; - gap: 2px; - border: 0; - border-radius: 12px; - background: transparent; - color: #b8c7ea; - font-size: 11px; - cursor: pointer; - transition: - transform 90ms ease, - box-shadow 90ms ease, - background-color 90ms ease, - color 120ms ease; -} - -.toolbar-btn.active { - background: transparent; - border: 1px solid transparent; - box-shadow: none; - color: #D4AF37; -} - -.toolbar-btn.active span:first-child { - color: #D4AF37; - filter: drop-shadow(0 0 10px rgba(212, 175, 55, 0.6)); -} - -.toolbar-btn.active span:last-child { - color: #D4AF37; - text-shadow: 0 0 10px rgba(212, 175, 55, 0.6); -} - -.toolbar-btn:active { - transform: translateY(1px) scale(0.96); - background: rgba(0, 0, 0, 0.14); - box-shadow: - inset 0 4px 10px rgba(0, 0, 0, 0.62), - inset 0 -1px 2px rgba(255, 255, 255, 0.08); -} - -.toolbar-btn-network { - position: relative; - width: calc(100% + 6px); - min-height: 72px; - margin-inline: -3px; - padding-bottom: 2px; - align-content: end; - overflow: visible; - -webkit-tap-highlight-color: transparent; -} - - -.toolbar-label-wrap { - display: grid; - justify-items: center; - line-height: 1.05; -} - - -.toolbar-btn-profile { - position: relative; -} - - -.toolbar-btn-messages { - position: relative; -} - - -.toolbar-unread-badge { - position: absolute; - top: 2px; - right: 6px; - min-width: 16px; - height: 16px; - border-radius: 999px; - padding: 0 5px; - display: inline-flex; - align-items: center; - justify-content: center; - background: #f07f8a; - color: #fff2f4; - border: 1px solid rgba(255, 222, 227, 0.55); - box-shadow: 0 4px 10px rgba(152, 36, 52, 0.35); - font-size: 10px; - font-weight: 700; - line-height: 1; - pointer-events: none; -} - - -.toolbar-btn-profile .toolbar-label-wrap { - padding-bottom: 0; -} - - -.toolbar-btn-profile .toolbar-connection-indicator { - display: none; -} - - -.toolbar-connection-indicator { - position: absolute; - left: 50%; - bottom: 0; - transform: translateX(-50%); - display: inline-flex; - align-items: center; - gap: 3px; - min-height: 9px; -} - - -.toolbar-connection-text { - display: none; - font-size: 8px; - letter-spacing: 0.02em; - text-transform: lowercase; - color: rgba(191, 213, 255, 0.8); - opacity: 0; - transition: opacity 0.2s ease; -} - - -.toolbar-connection-dot { - width: 7px; - height: 7px; - border-radius: 999px; - background: rgba(148, 162, 195, 0.65); - box-shadow: 0 0 0 2px rgba(97, 116, 156, 0.25); - transition: 0.2s ease; -} - - -.toolbar-connection-indicator.is-connected .toolbar-connection-text { - opacity: 1; - color: rgba(145, 255, 192, 0.9); -} - - -.toolbar-connection-indicator.is-connected .toolbar-connection-dot { - background: #71e9a5; - box-shadow: 0 0 0 2px rgba(72, 201, 134, 0.28); -} - - -.toolbar-connection-indicator.is-connecting .toolbar-connection-dot { - background: #f0c56b; -} - - -.toolbar-connection-indicator.is-disconnected .toolbar-connection-dot { - background: #e48792; - box-shadow: 0 0 0 2px rgba(228, 135, 146, 0.24); -} - - -/* Неоновые PNG-иконки вкладок (свечение запечено в PNG). Цвет доп.свечения — var --tab-glow (инлайн на img). */ -.toolbar-icon-img { - --tab-icon-size: 27px; /* крупнее (бар-иконки); герой ниже ещё больше */ - width: var(--tab-icon-size); - height: var(--tab-icon-size); - object-fit: contain; - display: block; - transition: transform .12s ease, filter .15s ease; -} - -/* Активная вкладка — лёгкое доп. свечение (подпись подсвечивается правилом .active span:last-child выше). */ -.toolbar-btn.active .toolbar-icon-img { - filter: drop-shadow(0 0 5px var(--tab-glow)) brightness(1.08); -} - -/* Нажатие — вдавливание + краткая вспышка свечения; на отпускании возврат. */ -.toolbar-btn:active .toolbar-icon-img { - transform: scale(0.9); - filter: drop-shadow(0 0 9px var(--tab-glow)) brightness(1.2); -} - -/* «Связи» — герой: крупнее и всегда чуть светится сильнее остальных; press-feedback ярче. */ -.toolbar-btn-hero .toolbar-icon-img { - /* Крупнее ВИЗУАЛЬНО через transform (origin center) — раскладочный размер как у остальных (27px), - поэтому иконка остаётся на одной линии с другими, а не задирается вверх. */ - transform: scale(1.63); /* ≈44px при базовых 27px */ - filter: brightness(1.05); /* CSS-ореол убран — светится только сама PNG (логотип не тронут) */ -} - -.toolbar-btn-hero.active .toolbar-icon-img { - filter: brightness(1.12); -} - -.toolbar-btn-hero:active .toolbar-icon-img { - transform: scale(1.47); /* 1.63 × 0.9 (нажатие) */ - filter: brightness(1.25); /* нажатие — только подсветление, без ореола */ -} - - -.toolbar-channels-hold-overlay { - position: fixed; - z-index: 1200; - transform: translate(-50%, -100%); - display: grid; - grid-template-columns: repeat(3, minmax(68px, 1fr)); - gap: 6px; - padding: 8px; - border-radius: 10px; - background: rgba(15, 18, 31, 0.94); - border: 1px solid rgba(160, 175, 220, 0.35); - box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35); -} - - -.toolbar-channels-hold-item { - border: 1px solid rgba(160, 175, 220, 0.35); - border-radius: 8px; - background: rgba(255, 255, 255, 0.06); - color: #e9efff; - font-size: 12px; - line-height: 1.2; - min-height: 34px; - padding: 6px 8px; - transition: background-color 0.12s ease, border-color 0.12s ease; -} - - -.toolbar-channels-hold-item.is-active { - background: rgba(133, 170, 255, 0.34); - border-color: rgba(197, 219, 255, 0.75); -} - - -/* Cosmic styling for the "Связи" toolbar button */ -/* нет рамки/подсветки фокуса ВОКРУГ кнопки — светится только сама иконка (её drop-shadow) */ -.toolbar-btn-network:focus, -.toolbar-btn-network:focus-visible { - outline: none; -} - - -.toolbar-btn-network::before { - content: ""; - display: none; /* подсветка-подложка вокруг иконки «Связи» убрана по запросу (иконка и её drop-shadow-ореол не тронуты) */ - position: absolute; - inset: 6px; - border-radius: 10px; - pointer-events: none; - opacity: 0.42; - background: - radial-gradient(circle at 24% 24%, rgba(112, 170, 255, 0.35), transparent 56%), - radial-gradient(circle at 78% 72%, rgba(197, 132, 255, 0.24), transparent 60%); - filter: blur(8px); -} - - -.toolbar-btn-network span:first-child { - color: #9eb3e8; - filter: drop-shadow(0 0 5px rgba(123, 170, 255, 0.24)); -} - - -/* ===== Единая полировка меню, DM и управляющих кнопок (2026-08-26) ===== */ - -/* Центральная вкладка остаётся на прежней оси иконок, но имеет большую - * интерактивную область; подпись опущена ниже и больше не заходит на логотип. */ -.toolbar-btn-network > span:last-child { - display: block; - position: relative; - transform: translateY(-2px); - line-height: 1.05; - padding-bottom: 1px; -} - - -/* Toolbar: reserve enough vertical room for the Connections label and safe area. */ -:root { --toolbar-height: 88px; } - -.toolbar-slot { - min-height: calc(88px + env(safe-area-inset-bottom)); - overflow: visible; - padding-bottom: env(safe-area-inset-bottom); -} - -.toolbar-btn-network .toolbar-icon-img { - transform: translateY(-4px); -} +/* Пять разделов с одинаковыми действиями и доступными подписями. */ +.toolbar { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); min-height: 64px; gap: 0; padding: 4px 0; background: var(--background); } +.toolbar-btn { position: relative; min-width: 0; min-height: 56px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; padding: 4px 1px; border: 0; border-radius: 12px; background: transparent; color: var(--text-secondary); font-size: .6875rem; line-height: 1rem; overflow-wrap: anywhere; cursor: pointer; } +.toolbar-btn > svg { width: 36px; height: 28px; padding: 3px 7px; border-radius: 10px; flex-shrink: 0; } +.toolbar-btn.active { color: var(--accent); } +.toolbar-btn.active > svg { background: var(--surface-selected); } +.toolbar-btn:hover, .toolbar-btn:active { background: var(--surface); } +.toolbar-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; } +.toolbar-label-wrap { display: flex; align-items: center; gap: 3px; } +.toolbar-connection-dot { display: block; width: 4px; height: 4px; border-radius: 50%; background: var(--text-secondary); } +.toolbar-connection-indicator.is-connected .toolbar-connection-dot { background: var(--success); } +.toolbar-connection-indicator.is-disconnected .toolbar-connection-dot { background: var(--danger); } +.toolbar-unread-badge { position: absolute; top: 0; right: 6px; min-width: 16px; padding: 0 4px; border-radius: 12px; color: var(--on-accent); background: var(--accent); font-size: .625rem; line-height: 16px; } diff --git a/shine-UI/styles/components/topbar.css b/shine-UI/styles/components/topbar.css index 8cf2d510..e90afff8 100644 --- a/shine-UI/styles/components/topbar.css +++ b/shine-UI/styles/components/topbar.css @@ -1,222 +1,17 @@ -:root { - --app-topbar-base-height: 64px; - --app-topbar-title-size: 18px; - --app-topbar-bg: #05070A; - --app-topbar-fg: #F7FBFF; - --app-topbar-gold: var(--app-topbar-fg); - --app-topbar-blue-glow: rgba(92, 190, 255, 0.72); - --app-topbar-blue-glow-soft: rgba(72, 145, 255, 0.34); -} - -.topbar { - width: 100%; - min-height: calc(var(--app-topbar-base-height) + env(safe-area-inset-top)); - margin: 0 0 14px; - padding: calc(10px + env(safe-area-inset-top)) 0 10px; - box-sizing: border-box; - display: grid; - grid-template-columns: minmax(44px, 1fr) minmax(0, auto) minmax(44px, 1fr); - align-items: center; - gap: 8px; -} - -.topbar-slot .topbar { - height: calc(var(--app-topbar-base-height) + env(safe-area-inset-top)); - min-height: calc(var(--app-topbar-base-height) + env(safe-area-inset-top)); - max-height: calc(var(--app-topbar-base-height) + env(safe-area-inset-top)); - margin: 0; - padding-top: env(safe-area-inset-top); - padding-bottom: 0; - border: 0; - border-radius: 0; - background: transparent; - box-shadow: none; - backdrop-filter: none; - -webkit-backdrop-filter: none; -} - -.topbar__left, -.topbar__center, -.topbar__right { - min-width: 0; - display: flex; - align-items: center; -} - -.topbar__left { width: 100%; justify-content: flex-start; gap: 8px; } -.topbar__center { justify-content: center; overflow: hidden; text-align: center; } -.topbar__right { width: 100%; justify-content: flex-end; gap: 8px; } - -.topbar__title { - margin: 0; - min-width: 0; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-main); - font-size: var(--app-topbar-title-size); - font-weight: 700; - line-height: 22px; - letter-spacing: 0; - color: var(--app-topbar-fg); - text-align: center; - text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 14px var(--app-topbar-blue-glow-soft); -} - -.topbar__left-label { - display: inline-block; - max-width: 120px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; - color: #a8bcdf; -} - -.topbar__back { - width: 40px; - height: 40px; - min-width: 40px; - min-height: 40px; - padding: 0; - display: inline-flex; - align-items: center; - justify-content: center; - font-family: var(--font-main); - font-size: 24px; - line-height: 1; - font-weight: 500; -} - -.topbar-slot .topbar__action, -.topbar-slot .topbar__back { - min-height: 40px; - height: 40px; - color: var(--app-topbar-fg); - text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 12px var(--app-topbar-blue-glow-soft); -} - -.topbar-slot .topbar__action svg, -.topbar-slot .topbar__back svg { - filter: drop-shadow(0 0 3px var(--app-topbar-blue-glow)) drop-shadow(0 0 7px var(--app-topbar-blue-glow-soft)); -} - +:root { --app-topbar-base-height: 56px; --app-topbar-title-size: 1.0625rem; --app-topbar-bg: var(--background); --app-topbar-fg: var(--text-primary); --app-topbar-gold: var(--text-primary); --app-topbar-blue-glow: transparent; --app-topbar-blue-glow-soft: transparent; } +.topbar { width: 100%; min-height: calc(56px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) 0 0; display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 4px; background: var(--background); } +.topbar__left, .topbar__center, .topbar__right { display: flex; min-width: 0; align-items: center; } +.topbar__center { justify-content: center; } +.topbar__right { justify-content: flex-end; } +.topbar__title { margin: 0; min-width: 0; font-size: var(--app-topbar-title-size); line-height: 1.375rem; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.topbar__left-label { font-size: .75rem; color: var(--text-secondary); } +.topbar__back, .topbar__action { display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; padding: 6px; border: 0; border-radius: 12px; background: transparent; color: var(--text-primary); font-size: 22px; cursor: pointer; } +.topbar__center .app-topbar-title-action, .topbar__center .channels-top-title, .topbar__center .dm-head-filter-title { display: flex; flex-direction: column; justify-content: center; max-width: 100%; min-width: 0; min-height: 44px; padding: 0 4px; border: 0; background: transparent; color: var(--text-primary); font-size: var(--app-topbar-title-size); font-weight: 600; text-align: center; overflow: hidden; } +.topbar__back:hover, .topbar__action:hover, .topbar__center button:hover { color: var(--accent); background: var(--surface); } +.topbar button:active, .topbar button[aria-expanded='true'] { background: var(--surface-selected); } +.topbar button:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; } +.topbar button:disabled { color: var(--text-secondary); cursor: default; } .topbar--profile .topbar__left { visibility: hidden; } - -@media (prefers-reduced-motion: reduce) { - .topbar__action, - .topbar__back { transition: none; } -} - -/* Custom center content used by chat/channel/list screens still follows the same TopBar rhythm. */ -.topbar-slot { - padding: 0 12px; - background: var(--app-topbar-bg); -} - -.topbar__center .app-topbar-title-action, -.topbar__center .channels-top-title, -.topbar__center .dm-head-filter-title { - margin: 0; - min-width: 0; - max-width: 100%; - min-height: 40px; - padding: 0 4px; - display: inline-flex; - align-items: center; - justify-content: center; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-main); - font-size: var(--app-topbar-title-size); - font-weight: 700; - line-height: 22px; - text-align: center; -} - -.topbar__center .app-topbar-title-action::before, -.topbar__center .app-topbar-title-action::after, -.topbar__center .channels-top-title::before, -.topbar__center .channels-top-title::after, -.topbar__center .dm-head-filter-title::before, -.topbar__center .dm-head-filter-title::after { - background: transparent; - background-image: none; - border-color: transparent; - box-shadow: none; -} - -.topbar-slot .topbar__center button, -.topbar-slot .topbar__center button:hover, -.topbar-slot .topbar__center button:focus, -.topbar-slot .topbar__center button:active, -.topbar-slot .topbar__center button:focus-visible { - color: #f7fbff; - background: transparent; - background-image: none; - border: 0; - box-shadow: none; - text-shadow: - 0 0 5px rgba(92, 190, 255, 0.72), - 0 0 12px rgba(72, 145, 255, 0.34); - backdrop-filter: none; - -webkit-backdrop-filter: none; - filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)); -} - -.topbar-slot .topbar__center button::before, -.topbar-slot .topbar__center button::after { - background: transparent; - background-image: none; - border-color: transparent; - box-shadow: none; -} - -/* TopBar owns its button interaction states. */ - -.topbar-slot .topbar__action, -.topbar-slot .topbar__back { - filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)); -} - -.topbar-slot .topbar__action:active, -.topbar-slot .topbar__back:active, -.topbar-slot .topbar__center button:active { - background: rgba(0, 0, 0, 0.12); - box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.58), inset 0 -1px 2px rgba(255, 255, 255, 0.08); - transform: translateY(1px) scale(0.97); -} - -.topbar-slot .topbar__action:focus-visible, -.topbar-slot .topbar__back:focus-visible, -.topbar-slot .topbar__center button:focus-visible { - color: #ffffff; - outline: none; - filter: drop-shadow(0 0 5px rgba(110, 205, 255, 0.82)) drop-shadow(0 0 10px rgba(72, 145, 255, 0.42)); -} - -.topbar-slot .topbar__action[aria-expanded='true'], -.topbar-slot .topbar__action[data-open='true'], -.topbar-slot .topbar__center button[aria-expanded='true'], -.topbar-slot .topbar__center button[data-open='true'] { - background: rgba(0, 0, 0, 0.16); - box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.58), inset 0 -1px 2px rgba(255, 255, 255, 0.08); - transform: translateY(1px) scale(0.97); - filter: brightness(0.9); -} - -/* Migrated from legacy components.css during Stage 3 ownership split. */ -.header-icon-svg { - display: block; - width: 1em; - height: 1em; -} - - -.header-icon-svg--phone, -.header-icon-svg--search { - width: 22px; - height: 22px; -} +.header-icon-svg { display: block; width: 22px; height: 22px; } +.topbar--root .topbar__center { justify-content: flex-start; } +.topbar--root .topbar__title { font-size: 1.5rem; line-height: 1.875rem; } diff --git a/shine-UI/styles/features/channel-thread.css b/shine-UI/styles/features/channel-thread.css index 5be1cf3b..d3c543f6 100644 --- a/shine-UI/styles/features/channel-thread.css +++ b/shine-UI/styles/features/channel-thread.css @@ -1,247 +1,7 @@ -/* Channel thread screen styles. */ - -.thread-node-card.is-rating { - border-color: transparent; - background: - linear-gradient(180deg, rgba(112, 84, 22, 0.12), rgba(20, 25, 35, 0.58)), - rgba(20, 25, 35, 0.55); - box-shadow: 0 0 38px rgba(181, 136, 42, 0.14); -} -.thread-node-stats.is-hidden { - opacity: 0; - max-height: 0; - overflow: hidden; - margin: 0; -} - - - - - - - -.thread-node-heading { - color: #f1dcab; - font-size: 15px; -} - - -.thread-node-meta { - color: #aebddd; - font-size: 12px; -} - - -.thread-node-body { - color: #eef3ff; - font-size: 14px; - line-height: 1.45; - white-space: pre-wrap; - word-break: break-word; - border-radius: 10px; - padding: 8px 10px; - background: linear-gradient(170deg, rgba(22, 40, 73, 0.72), rgba(12, 25, 48, 0.78)); - border: 1px solid rgba(116, 141, 193, 0.24); -} - - -.thread-node-stats { - color: #99acd6; - font-size: 12px; -} - - -.thread-node-views { - margin: 0; - font-size: 12px; - line-height: 1.35; - color: rgba(255, 255, 255, 0.58); -} - - -.thread-node-actions { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 8px; -} - - -.channels-screen--thread .thread-node-actions { - display: flex; - grid-template-columns: none; -} - - -.thread-node-level { - --depth: 0; - margin-left: calc(var(--depth) * 12px); -} - - -.channels-screen--thread .thread-node-card { - padding: 14px; - margin-bottom: 10px; -} - - -.thread-history-divider { - height: 0; - border-top: 2px solid rgba(255, 255, 255, 0.26); - margin: 6px 0 10px; -} - - -.thread-block--ancestors > .section-title { - color: #b9cbef; -} - - -.thread-block--ancestors { - border-color: rgba(141, 166, 214, 0.26); - background: linear-gradient(160deg, rgba(12, 28, 56, 0.73), rgba(7, 15, 32, 0.78)); -} - - -.thread-block--focus > .section-title { - color: #f0d9a4; -} - - -.thread-block--focus { - border-color: transparent; - background: linear-gradient(160deg, rgba(33, 44, 72, 0.68), rgba(12, 20, 36, 0.8)); -} - - -.thread-block--replies > .section-title { - color: #c8d6f5; -} - - -.thread-block--replies { - border-color: rgba(161, 186, 233, 0.24); -} -.thread-like-btn { - transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease; -} -.thread-like-btn.is-long-press { - transform: scale(0.96); - filter: brightness(1.08) drop-shadow(0 0 8px rgba(255, 220, 100, 0.28)); - box-shadow: none; -} - - -.thread-open-btn { - color: rgba(255, 255, 255, 0.62); -} -@media (max-width: 430px) { -.thread-node-actions { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} -@media (max-width: 365px) { -.thread-node-actions { - grid-template-columns: 1fr; - } -} -.thread-node-card.is-own-new { - box-shadow: 0 0 52px rgba(88, 69, 176, 0.2), 0 12px 24px rgba(2, 8, 16, 0.46); -} -@media (max-width: 420px) { -.thread-node-actions { - grid-template-columns: 1fr; - } -} - - -.thread-block { - gap: 8px; - padding: 8px; - background: rgba(20, 25, 35, 0.55); - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); - border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 20px; - box-shadow: 0 0 60px rgba(80, 60, 180, 0.15); - border-color: rgba(255, 255, 255, 0.07); -} - - -.thread-summary { - color: #efd9a4; - background: rgba(20, 25, 35, 0.55); - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); - border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 20px; - box-shadow: 0 0 60px rgba(80, 60, 180, 0.15); - border-color: rgba(255, 255, 255, 0.07); -} - - -.thread-node-card { - gap: 9px; - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); - border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 20px; - box-shadow: 0 0 60px rgba(80, 60, 180, 0.15); - padding: 20px; - margin-bottom: 16px; - border-color: rgba(255, 255, 255, 0.07); - background: rgba(20, 25, 35, 0.55); -} - - -.thread-node-actions .secondary-btn { - display: inline-flex; - align-items: center; - gap: 5px; - background: none; - border: none; - color: rgba(255, 255, 255, 0.45); - font-size: 12px; - cursor: pointer; - padding: 0; - min-height: 0; - white-space: nowrap; -} - - -.channels-screen--thread .channels-user-chip { - background: transparent; - border: none; - box-shadow: none; - padding: 0; - margin-bottom: 0; - color: rgba(255, 255, 255, 0.35); - min-height: 0; - font-size: 12px; -} - - -.thread-block--replies > .section-title { - color: rgba(255, 255, 255, 0.4); - font-size: 12px; - font-weight: 500; - margin: 0 0 2px; -} -.channels-screen .thread-node-card, -.channels-screen .thread-block, -.channels-screen .thread-summary { - animation: breatheCard 8s ease-in-out infinite; - position: relative; -} -.channels-screen .thread-node-card:hover, -.channels-screen .thread-block:hover, -.channels-screen .thread-summary:hover { - transform: none; -} - - -/* Thread cards should stay fixed without breathing motion */ -.channels-screen--thread .thread-node-card, -.channels-screen--thread .thread-block, -.channels-screen--thread .thread-summary { - animation: none; -} +/* Ветка сохраняет ширину текста на любой глубине. */ +.thread-block { display: grid; gap: 0; min-width: 0; } +.thread-node-level { margin-left: 0; } +.thread-block--replies > .section-title { padding: 16px 0 8px; margin: 0; color: var(--text-secondary); font-size: .875rem; font-weight: 600; } +.thread-node-heading { color: var(--text-secondary); font-size: .8125rem; } +.thread-node-card .channel-message-avatar.avatar { width: 32px; height: 32px; min-width: 32px; min-height: 32px; } +.thread-history-divider { border-top: 1px solid var(--border-subtle); margin: 8px 0; } diff --git a/shine-UI/styles/features/channel.css b/shine-UI/styles/features/channel.css index 3cb2163c..18fcc0d4 100644 --- a/shine-UI/styles/features/channel.css +++ b/shine-UI/styles/features/channel.css @@ -1,685 +1,536 @@ -/* Channel screen styles. */ -.channels-screen.channels-screen--channel::before { - background: - radial-gradient(circle at 14% 16%, rgba(82, 73, 184, 0.2), transparent 44%), - radial-gradient(circle at 86% 8%, rgba(57, 89, 161, 0.22), transparent 46%), - radial-gradient(circle at 54% 84%, rgba(64, 47, 138, 0.15), transparent 42%), - linear-gradient(180deg, #0a0b10 0%, #0a0b10 62%, rgba(10, 11, 16, 0.92) 100%); +/* Геометрия ленты канала и сопутствующих экранов. */ +.channel-feed { gap: 0; min-width: 0; } +.channels-screen .channel-unread-line { display: flex; align-items: center; justify-content: center; padding: 8px 0; border: 0; border-radius: 0; background: transparent; color: var(--accent); font-size: .75rem; } +.channel-head-meta, .channel-note { color: var(--text-secondary); font-size: .8125rem; line-height: 1.5; } +.channel-message-target-preview { display: grid; gap: 4px; padding: 12px; border-left: 2px solid var(--accent); background: var(--surface); font-size: .8125rem; overflow-wrap: anywhere; } +.channels-screen .channel-message-card.is-focus-flash { background: var(--surface-selected); } +.channel-system-event-card { padding: 8px; margin: 8px auto; width: fit-content; max-width: 100%; color: var(--text-secondary); font-size: .75rem; text-align: center; } +.channel-meta-details-grid { display: grid; grid-template-columns: auto minmax(0,1fr); gap: 8px; overflow-wrap: anywhere; } +.channel-profile-avatar { width: 96px; height: 96px; border-radius: 50%; overflow: hidden; } +.channel-profile-avatar img { width: 100%; height: 100%; object-fit: cover; } +.channel-profile-modal-head { display: flex; gap: 12px; align-items: center; } +.channel-main-action { min-height: 44px; border-radius: 12px; padding: 10px 16px; color: var(--on-accent); background: var(--accent); } +.channel-back-btn { min-height: 44px; color: var(--accent); } +.entrypoint-history-list { display: grid; gap: 8px; max-height: 58vh; overflow: auto; } +.entrypoint-history-item { display: grid; gap: 8px; text-align: left; padding: 12px; border-radius: 12px; background: var(--surface); color: var(--text-primary); } +.entrypoint-history-item p { white-space: pre-wrap; overflow-wrap: anywhere; } +.entrypoint-history-item span { font-size: .75rem; color: var(--text-secondary); } +.channels-screen--channel-about { + padding-bottom: calc(22px + env(safe-area-inset-bottom)); } - -.channel-counter-meta, -.channel-counter-value { - opacity: 0; - transform: translateY(3px); - transition: opacity 0.22s ease, transform 0.22s ease; +.channel-about-card { + padding: 0; + overflow: hidden; } +.channel-about-content { + gap: 0; +} -.channel-head-card { +.channel-about-hero { display: grid; + justify-items: center; gap: 6px; -} - - -.channel-head-actions { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 8px; - flex-wrap: wrap; -} - - -.channel-note { - font-size: 13px; - color: #e8d8b0; - line-height: 1.4; -} - - -.channel-feed { - gap: 10px; -} - - -.channel-unread-line { - display: flex; - align-items: center; - gap: 10px; - margin: 10px 0 12px; - padding: 10px 14px; - border-radius: 999px; - border: 1px solid transparent; - background: - linear-gradient(180deg, rgba(50, 39, 14, 0.9), rgba(22, 25, 39, 0.9)); - color: #ffe6a7; - font-size: 11px; - font-weight: 800; - letter-spacing: 0.08em; - line-height: 1; - text-transform: uppercase; + padding: 26px 20px 22px; text-align: center; - justify-content: center; - box-shadow: 0 10px 24px rgba(6, 10, 20, 0.35); } - -.channel-unread-line::before, -.channel-unread-line::after { - content: ""; - flex: 1 1 0; - height: 1px; - min-width: 18px; - background: linear-gradient(90deg, transparent, rgba(244, 202, 102, 0.8), transparent); +.channel-about-avatar-slot { + margin-bottom: 8px; } - -.channels-screen--channel .channel-feed { - gap: 2px; - margin-left: -7px; - margin-right: -7px; +.channel-about-avatar.channel-profile-avatar { + box-shadow: none; } - -.channel-message-top { - display: flex; - align-items: center; - gap: 12px; -} - - -.channel-message-avatar.avatar-image { - display: grid; - place-items: center; -} - - -.channel-message-kind-badge--status { - color: #bfe9d1; - background: rgba(38, 92, 62, 0.32); - border: 1px solid rgba(137, 223, 176, 0.26); -} - - -.channel-message-target-preview { - display: grid; - gap: 4px; - padding: 9px 11px; - border-radius: 10px; - border: 1px solid rgba(154, 178, 224, 0.16); - background: rgba(13, 22, 39, 0.56); -} - - -.channel-message-target-preview strong { - color: #f0d99c; - font-size: 12px; -} - - -.channel-message-target-preview span { - color: rgba(188, 208, 244, 0.82); - font-size: 12px; -} - - -.channel-message-target-preview p { +.channel-about-title { margin: 0; - color: #d9e6ff; - font-size: 13px; + color: var(--text-secondary); + font-size: clamp(24px, 7vw, 32px); + line-height: 1.12; + font-weight: 760; + overflow-wrap: anywhere; +} + +.channel-about-technical { + color: rgba(196, 210, 238, 0.72); + font-size: 14px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.channel-about-subscribers { + color: rgba(170, 190, 226, 0.68); + font-size: 14px; line-height: 1.35; } - -.channels-screen .channel-message-card.is-rating { - border-color: transparent; - background: - linear-gradient(180deg, rgba(112, 84, 22, 0.12), rgba(20, 25, 35, 0.58)), - rgba(20, 25, 35, 0.55); - box-shadow: 0 0 38px rgba(181, 136, 42, 0.14); -} - - -.channels-screen .channel-message-card.is-focus-flash { - border-color: transparent; - box-shadow: 0 0 34px rgba(255, 214, 117, 0.16); -} - - -.channel-message-body--deleted { - color: #ff9e9e; - border: 1px solid rgba(255, 126, 126, 0.5); - background: rgba(120, 18, 18, 0.28); - border-radius: 10px; - padding: 8px 10px; -} - - -.channel-system-event-card { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; - width: fit-content; - max-width: min(88%, 460px); - margin: 0 auto 4px; - padding: 8px 16px; - border-color: transparent; - border-radius: 999px; - background: rgba(89, 67, 31, 0.26); - color: rgba(255, 235, 188, 0.92); - cursor: pointer; - text-align: center; -} - - -.channel-system-event-card__label { - display: block; - font-size: 13px; - line-height: 1.2; -} - - -.channel-meta-details-grid { +.channel-about-section { display: grid; - grid-template-columns: minmax(96px, 120px) 1fr; - gap: 10px 14px; - align-items: start; - color: rgba(255, 255, 255, 0.92); + gap: 10px; + padding: 20px; + border-top: 1px solid var(--text-secondary); +} + +.channel-about-section h3 { + margin: 0; + color: rgba(255, 218, 135, 0.92); + font-size: 14px; + font-weight: 700; +} + +.channel-about-description { + margin: 0; + color: rgba(235, 241, 255, 0.92); + font-size: 16px; + line-height: 1.58; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.channel-about-owner-link { + display: grid; + gap: 3px; + justify-items: start; + width: 100%; + padding: 0; + border: 0; + background: var(--surface); + color: inherit; + text-align: left; + cursor: pointer; +} + +.channel-about-owner-link strong { + color: var(--text-secondary); + font-size: 16px; + line-height: 1.3; +} + +.channel-about-owner-link span { + color: rgba(176, 198, 234, 0.72); + font-size: 13px; + line-height: 1.3; +} + +.channel-about-owner-link:hover strong, +.channel-about-owner-link:focus-visible strong { + color: var(--text-primary); +} + +.channel-about-owner-link:focus-visible { + outline: 2px solid rgba(244, 220, 166, 0.52); + outline-offset: 5px; + border-radius: 8px; +} + +.channel-about-support-btn { + width: 100%; + min-height: 44px; + margin-top: 2px; +} + +.channel-about-link-box { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 48px; + padding: 10px 10px 10px 12px; + border: 1px solid var(--border-subtle); + border-radius: 12px; + background: var(--surface); } -.channel-meta-details-grid > span:nth-child(odd) { - color: rgba(255, 255, 255, 0.48); -} - - -.channel-meta-details-grid code { - color: rgba(255, 214, 117, 0.92); +.channel-about-link-box a, +.channel-about-link-box span { + color: rgba(169, 215, 255, 0.95); + font-size: 13px; + line-height: 1.45; + overflow-wrap: anywhere; word-break: break-word; } -.channel-profile-card { - align-items: stretch; +.channel-about-copy-btn { + width: 38px; + min-width: 38px; + height: 38px; + min-height: 38px; + padding: 0; + display: inline-grid; + place-items: center; + font-size: 19px; } +.channel-about-open-btn, +.channel-about-subscription-btn { + margin: 18px 20px 0; + min-height: 48px; +} -.channel-profile-modal-head { - display: flex; - align-items: center; - justify-content: space-between; +.channel-about-subscription-btn { + margin-top: 12px; + margin-bottom: 20px; +} + +.channel-about-subscription-btn.is-unsubscribe { + border: 1px solid var(--border-subtle); + background: var(--surface); + color: var(--text-primary); +} + +.channel-about-subscription-btn.is-unsubscribe:hover, +.channel-about-subscription-btn.is-unsubscribe:focus-visible { + background: var(--surface); +} + +.channel-about-open-btn:last-child { + margin-bottom: 20px; +} + +/* ===== Channel unsubscribe confirmation ===== */ +#channel-unsubscribe-confirm-modal { + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +#channel-unsubscribe-confirm-modal .channel-unsubscribe-confirm-card { + width: min(100%, 390px); + gap: 14px; + padding: 20px; +} + +.channel-unsubscribe-confirm-card .modal-title { + margin: 0; +} + +.channel-unsubscribe-confirm-text { + margin: 0; + line-height: 1.5; +} + +.channel-unsubscribe-confirm-actions { + display: grid; + grid-template-columns: 1fr 1fr; gap: 10px; } - -.channel-profile-edit-btn { - width: 38px; - padding-left: 0; - padding-right: 0; -} - - -.channel-profile-avatar { - --channel-avatar-size: 96px; - width: var(--channel-avatar-size); - height: var(--channel-avatar-size); - display: inline-flex; - align-items: center; - justify-content: center; - align-self: center; - border-radius: 50%; - background: linear-gradient(145deg, rgba(255, 214, 122, 0.22), rgba(56, 41, 22, 0.65)); - border: 1px solid transparent; - color: rgba(255, 236, 194, 0.95); - font-size: calc(var(--channel-avatar-size) * 0.42); - font-weight: 700; - overflow: hidden; -} - - -.channel-profile-avatar img { - width: 100%; - height: 100%; - display: block; - object-fit: cover; -} - - -.channel-message-views { - margin: 0; - font-size: 12px; - line-height: 1.35; - color: rgba(255, 255, 255, 0.58); -} - - -.channel-message-stats.is-hidden { - opacity: 0; - max-height: 0; - overflow: hidden; - margin: 0; -} - - -.channels-screen .channel-message-card.is-counters-visible .channel-action-counter { - opacity: 1; - max-width: 40px; - margin-left: 2px; - transform: translateX(0); -} - - -.topbar__right .channel-header-entrypoint-btn { - border: 1px solid transparent; - background: linear-gradient(180deg, rgba(57, 74, 119, 0.34), rgba(18, 29, 54, 0.34)); - color: #f1d99c; - border-radius: 10px; - padding-inline: 9px; - font-size: 11px; - font-weight: 700; - letter-spacing: .02em; - text-transform: uppercase; -} - - -.channel-main-action { - min-height: 48px; - border-radius: 13px; -} - - -.channel-back-btn { +.channel-unsubscribe-confirm-actions button { min-height: 44px; } - -.channel-action-like { - transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease; +.channel-unsubscribe-confirm-yes { + border: 1px solid var(--border-subtle); + background: var(--surface); + color: var(--text-primary); } - -.channel-action-like.is-long-press { - transform: scale(0.96); - filter: brightness(1.08) drop-shadow(0 0 8px rgba(255, 220, 100, 0.28)); - box-shadow: none; +.channel-unsubscribe-confirm-yes:hover, +.channel-unsubscribe-confirm-yes:focus-visible { + background: var(--surface); } - - - -.channel-menu-item:disabled { - opacity: 0.68; - cursor: not-allowed; +/* ===== Channel support transfer ===== */ +#channel-support-modal { + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); } - - - -.channel-toggle-btn { - width: 48px; - height: 28px; - border-radius: 999px; - border: 0; - background: transparent; - color: #fff; - box-shadow: none; - text-shadow: none; - backdrop-filter: none; - -webkit-backdrop-filter: none; - position: relative; - cursor: pointer; - transition: - transform 90ms ease, - box-shadow 90ms ease, - background-color 90ms ease, - filter 120ms ease; +#channel-support-modal .channel-support-modal-card { + width: min(100%, 430px); + max-height: min(88vh, 720px); + overflow-y: auto; + gap: 9px; + padding: 20px; } -.channel-toggle-btn:hover { - background: transparent; - border: 0; - box-shadow: none; - filter: brightness(1.12); +.channel-support-modal-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + margin-bottom: 4px; } -.channel-toggle-btn:active { - background: rgba(0, 0, 0, 0.12); - border: 0; - box-shadow: - inset 0 3px 8px rgba(0, 0, 0, 0.58), - inset 0 -1px 2px rgba(255, 255, 255, 0.08); - transform: translateY(1px) scale(0.97); - filter: brightness(0.9); +.channel-support-modal-head .modal-title { + margin: 0 0 3px; } -.channel-toggle-btn:focus-visible { - outline: 2px solid rgba(255, 255, 255, 0.62); - outline-offset: 2px; -} - - -.channel-toggle-btn::after { - content: ""; - position: absolute; - top: 3px; - left: 3px; - width: 20px; - height: 20px; - border-radius: 50%; - background: #d9e6ff; - transition: transform 0.24s cubic-bezier(.2,.9,.3,1.15), background 0.2s ease; -} - - -.channel-toggle-btn.is-on::after { - transform: translateX(20px); - background: #f4dca6; -} - - -.channel-head-description { +.channel-support-subtitle { margin: 0; - color: #c8d7f6; - font-size: 14px; - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; + overflow-wrap: anywhere; +} + +.channel-support-close { + width: 38px; + min-width: 38px; + height: 38px; + min-height: 38px; + padding: 0; + display: inline-grid; + place-items: center; + font-size: 24px; + line-height: 1; +} + +.channel-support-address { + min-height: 36px; + padding: 8px 10px; + border-radius: 10px; + background: var(--surface); + color: rgba(186, 215, 255, 0.8); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-all; +} + +.channel-support-balance-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 44px; +} + +.channel-support-balance-row strong { + color: var(--text-secondary); + text-align: right; +} + +.channel-support-refresh { + min-height: 36px; + padding-block: 6px; +} + +.channel-support-amount-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.channel-support-amount-row span { + color: rgba(232, 239, 255, 0.82); + font-weight: 700; +} + +.channel-support-status { + min-height: 18px; + margin: 2px 0 0; + color: rgba(192, 207, 236, 0.76); +} + +.channel-support-status.is-error { + color: var(--text-primary); +} + +.channel-support-result { + display: grid; + gap: 5px; + padding: 10px 12px; + border: 1px solid var(--border-subtle); + border-radius: 12px; + background: var(--surface); +} + +.channel-support-result[hidden] { + display: none; +} + +.channel-support-result strong { + color: var(--text-primary); +} + +.channel-support-result span, +.channel-support-result code { + color: rgba(220, 236, 255, 0.82); + font-size: 12px; + overflow-wrap: anywhere; + word-break: break-all; +} + +.channel-support-submit { + min-height: 48px; + margin-top: 4px; +} + +@media (max-width: 420px) { + .channel-about-section { + padding-inline: 16px; + } + + .channel-about-open-btn, + .channel-about-subscription-btn { + margin-inline: 16px; + } + + .channel-support-balance-row { + grid-template-columns: 1fr auto; + } + + .channel-support-balance-row .meta-muted { + grid-column: 1 / -1; + } +} + +/* ===== Channel donation view ===== */ +.channels-screen--channel-donate { + padding-bottom: calc(24px + env(safe-area-inset-bottom)); +} + +.channel-donate-card { + width: min(100%, 680px); + margin-inline: auto; + padding: 0; overflow: hidden; } - -.channel-head-description.is-expanded { - -webkit-line-clamp: unset; - display: block; +.channel-donate-content { + gap: 0; } - -.channel-head-more { - border: 0; - background: transparent; - color: #efcf8b; - font-size: 12px; - padding: 0; - width: fit-content; - cursor: pointer; -} - - -.channel-message-type-chip { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 28px; - padding: 4px 9px; - border-radius: 9px; - border: 1px solid transparent; - background: linear-gradient(180deg, rgba(57, 74, 119, 0.34), rgba(18, 29, 54, 0.34)); - color: #f1d99c; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.02em; - text-transform: uppercase; -} - - -.channel-message-tools { +.channel-donate-hero { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - align-items: center; -} - - -.channel-message-type-select { - min-width: 0; - min-height: 46px; -} - - -.channels-screen .channel-message-card.is-own-new { - box-shadow: 0 0 52px rgba(88, 69, 176, 0.2), 0 12px 24px rgba(2, 8, 16, 0.46); -} -.primary-btn.channel-main-action { - border-radius: 14px; - padding: 14px; - width: 100%; -} - - -.channel-head-card { - background: transparent; - border: none; - box-shadow: none; - padding: 0; - margin-bottom: 6px; - backdrop-filter: none; - -webkit-backdrop-filter: none; -} - - -.channel-head-title { - line-height: 1.35; - font-family: inherit; - color: rgba(255, 255, 255, 0.95); - font-size: 22px; - font-weight: 700; + justify-items: center; + gap: 5px; + padding: 28px 20px 24px; text-align: center; } - -.channel-head-meta { - line-height: 1.35; - font-family: inherit; - color: rgba(255, 255, 255, 0.4); - font-size: 12px; - font-weight: 500; - text-align: center; +.channel-donate-hero h2 { + margin: 0 0 4px; + color: var(--text-secondary); + font-size: clamp(24px, 7vw, 31px); + line-height: 1.12; + font-weight: 760; } - -.entrypoint-history-list { - display: grid; - gap: 10px; - max-height: min(58vh, 30rem); - overflow: auto; +.channel-donate-hero strong { + color: rgba(255, 230, 169, 0.94); + font-size: 17px; + overflow-wrap: anywhere; } - -.entrypoint-history-item { - user-select: none; - -webkit-user-select: none; - -webkit-touch-callout: none; - appearance: none; - border-radius: 14px; - display: grid; - gap: 4px; - padding: 12px 13px; - text-align: left; -} - - -.entrypoint-history-item strong { - color: #f1d99c; - font-size: 13px; -} - - -.entrypoint-history-item span { - color: rgba(188, 208, 244, 0.74); - font-size: 12px; -} - - -.entrypoint-history-item p { - margin: 0; - color: #f5f8ff; +.channel-donate-hero span { + color: rgba(176, 198, 234, 0.72); font-size: 13px; line-height: 1.4; - white-space: pre-wrap; - word-break: break-word; + overflow-wrap: anywhere; } - -.channel-head-actions .secondary-btn { - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.6); - border-radius: 12px; - padding: 10px 16px; +.channel-donate-section { + display: grid; + gap: 9px; + padding: 18px 20px; + border-top: 1px solid var(--text-secondary); } - -/* 1) Minimal breathing only for content cards */ - - -.channels-screen .channel-message-card { - gap: 14px; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.07); - backdrop-filter: blur(24px); - box-shadow: 0 0 60px rgba(80, 60, 180, 0.15); - padding: 20px; - margin-bottom: 16px; - border-color: rgba(255, 255, 255, 0.07); - background: rgba(20, 25, 35, 0.55); - animation: breatheCard 8s ease-in-out infinite; - position: relative; -} - - -/* Keep card hover static so breathe is the only motion */ -.channels-screen .channel-message-card:hover { - transform: none; -} - - -.channels-screen--channel .channel-main-action--compose { - position: sticky; - bottom: -12px; - margin: 16px 20px 0; - width: calc(100% - 40px); - font-weight: 700; - z-index: 3; -} - - -.channel-menu-trigger .app-overflow-dots { - margin: auto; -} - - -.channel-menu-trigger { - width: 34px; - height: 34px; +.channel-donate-address { + min-height: 38px; + padding: 9px 10px; border-radius: 10px; - cursor: pointer; - font-size: 18px; - line-height: 1; - border: none; - background: none; - color: var(--app-topbar-gold); - text-shadow: 0 0 5px var(--app-topbar-blue-glow), - 0 0 12px var(--app-topbar-blue-glow-soft); + background: var(--surface); + color: rgba(186, 215, 255, 0.82); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-all; } - -/* У меню больше нет «облачного» треугольного хвостика. */ - - -.channel-menu-item { - width: 100%; - min-height: 42px; - padding: 0 11px; - display: flex; - align-items: center; - justify-content: flex-start; +.channel-donate-balance-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; - border: 0; - border-radius: 10px; - background: transparent; - background-image: none; - box-shadow: none; - color: #ffffff; - font: inherit; - font-size: 14px; - font-weight: 600; - line-height: 1.2; - text-align: left; - white-space: nowrap; - cursor: pointer; + align-items: center; + min-height: 44px; + margin-top: 3px; } -.channel-menu-item:hover { - color: #ffffff; - background: transparent; - border: 0; - box-shadow: none; - filter: brightness(1.12); +.channel-donate-balance-row strong { + color: var(--text-secondary); + text-align: right; } -.channel-menu-item:active { - color: #ffffff; - background: rgba(0, 0, 0, 0.12); - border: 0; - box-shadow: - inset 0 3px 8px rgba(0, 0, 0, 0.58), - inset 0 -1px 2px rgba(255, 255, 255, 0.08); - transform: translateY(1px) scale(0.97); - filter: brightness(0.9); +.channel-donate-refresh { + min-height: 36px; + padding-block: 6px; } -.channel-menu-item:focus-visible { - color: #ffffff; - background: rgba(var(--shine-action-blue-rgb), 0.12); - border: 0; - box-shadow: none; - transform: none; - filter: none; - outline: 2px solid rgba(255, 255, 255, 0.62); - outline-offset: 2px; +.channel-donate-amount-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; } -.channel-menu-item::before, -.channel-menu-item::after { - background: transparent; - background-image: none; - border-color: transparent; - box-shadow: none; +.channel-donate-amount-row span { + color: rgba(232, 239, 255, 0.82); + font-weight: 700; } - -.channel-menu-icon { - width: 18px; - height: 18px; - min-width: 18px; - flex: 0 0 18px; - object-fit: contain; - color: #65b7ff; - filter: drop-shadow(0 0 5px rgba(39, 141, 255, 0.28)); +.channel-donate-status { + min-height: 18px; + margin: 14px 20px 0; + color: rgba(192, 207, 236, 0.76); } - - - -.channel-menu-item.destructive { - color: #ffb7c5; - background: transparent; +.channel-donate-status.is-error { + color: var(--text-primary); } -.channel-menu-item.destructive:hover, -.channel-menu-item.destructive:active { - color: #ffffff; +.channel-donate-result { + display: grid; + gap: 6px; + margin: 12px 20px 0; + padding: 12px 14px; + border: 1px solid var(--border-subtle); + border-radius: 12px; + background: var(--surface); } -.channel-menu-item.destructive:active { - background: rgba(0, 0, 0, 0.12); +.channel-donate-result[hidden] { + display: none; } -.channel-menu-item.destructive:focus-visible { - color: #ffb7c5; - background: rgba(var(--shine-action-blue-rgb), 0.12); +.channel-donate-result strong { + color: var(--text-primary); +} + +.channel-donate-result span, +.channel-donate-result code { + color: rgba(220, 236, 255, 0.82); + font-size: 12px; + overflow-wrap: anywhere; + word-break: break-all; +} + +.channel-donate-submit { + min-height: 50px; + margin: 16px 20px 22px; +} + +@media (max-width: 420px) { + .channel-donate-section { + padding-inline: 16px; + } + + .channel-donate-status, + .channel-donate-result, + .channel-donate-submit { + margin-inline: 16px; + } + + .channel-donate-balance-row { + grid-template-columns: 1fr auto; + } + + .channel-donate-balance-row .meta-muted { + grid-column: 1 / -1; + } } diff --git a/shine-UI/styles/features/channels-common.css b/shine-UI/styles/features/channels-common.css index 4669aa68..512522e7 100644 --- a/shine-UI/styles/features/channels-common.css +++ b/shine-UI/styles/features/channels-common.css @@ -1,438 +1,51 @@ -/* Styles shared by Channel and Thread rendering. */ - -.attachment-trigger-btn { - justify-self: start; - min-width: 3.25rem; - padding-inline: 0.8rem; -} - - -.draft-attachments { - display: flex; - flex-wrap: wrap; - gap: 0.45rem; -} - - -.draft-attachment-chip { - border-radius: 999px; - cursor: pointer; - font-size: 0.82rem; - max-width: 100%; - overflow: hidden; - padding: 0.4rem 0.65rem; - text-overflow: ellipsis; - white-space: nowrap; -} - - -.modal-danger-action { - width: 100%; -} - - -.channels-screen--channel .channel-message-card { - margin-bottom: 6px; -} - - -.channels-screen--channel .channel-message-card.is-diary-entry { - width: min(100%, 28rem); - max-width: min(84vw, 28rem); - padding: 16px 16px 15px; - justify-self: center; - align-self: center; -} - - -.channel-message-author-tile { - appearance: none; - -webkit-appearance: none; - display: flex; - align-items: center; - gap: 12px; - flex: 1 1 auto; - width: auto; - min-width: 0; - padding: 8px 10px; - border-radius: 8px; - text-align: left; - cursor: pointer; - min-height: 0; -} - - - - -.channel-message-avatar { - width: 28px; - height: 28px; - min-width: 28px; - min-height: 28px; - border-radius: 50%; - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 10px; - font-weight: 700; - color: #f4f6ff; - background: radial-gradient(circle at 30% 30%, #8a73ff, #4f4bda 58%, #3b2b89); -} - - -.channel-message-author { +/* Общий контракт публикаций и ответов. */ +.channels-screen .channel-message-card { display: grid; - gap: 4px; - min-width: 0; -} - - -.channel-message-head-row { - display: flex; - align-items: stretch; gap: 8px; -} - - -.channel-message-title { - font-size: 15px; - color: #f5f8ff; -} - - -.channel-message-kind-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: fit-content; - padding: 3px 8px; - border-radius: 999px; - font-size: 12px; - line-height: 1.2; -} - - -.channel-message-kind-badge--rating { - color: #ffe8b0; - background: rgba(124, 92, 28, 0.36); - border: 1px solid transparent; -} - - -.channel-message-body { - color: #ffffff; - line-height: 1.5; - font-size: 15px; - white-space: pre-wrap; - word-break: break-word; + min-width: 0; margin: 0; - padding: 0; - background: transparent; - border: 0; -} - - -.channel-message-card--deleted-compact { - gap: 7px; - padding-top: 9px; - padding-bottom: 9px; -} - - -.deleted-message-pill { - appearance: none; - width: 100%; - border-radius: 12px; - font-size: 13px; - line-height: 1.2; - text-align: left; - padding: 8px 10px; - cursor: pointer; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - - - - -.channel-message-time { - font-size: 11px; - color: rgba(255, 255, 255, 0.48); -} - - -.channel-message-actions { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - flex-wrap: nowrap; - overflow-x: auto; - padding-top: 14px; - border-top: 1px solid rgba(255, 255, 255, 0.06); -} - - -.channel-message-actions::-webkit-scrollbar { - width: 0; - height: 0; - display: none; -} - - -.channel-action-item { - appearance: none; + padding: 16px 0 8px; border: 0; + border-bottom: 1px solid var(--border-subtle); + border-radius: 0; background: transparent; - position: relative; - min-height: 0; - padding: 0; - margin: 0; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - min-width: 24px; - font-size: 12px; - cursor: pointer; - transition: color 0.18s ease, text-shadow 0.18s ease, transform 0.18s ease; - white-space: nowrap; -} - - -.channel-action-item:disabled { - opacity: 0.56; - cursor: not-allowed; -} - - - - - - -.channel-action-icon { - font-size: 14px; - line-height: 1; -} - - -.channel-action-label { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - - - - -.message-edited-marker { - appearance: none; - border: none; - background: transparent; - font-size: 11px; - line-height: 1; - padding: 0; - margin-left: 6px; - cursor: pointer; - text-decoration: underline; - text-underline-offset: 2px; -} - - - - -.channel-action-counter { - font-size: 11px; - color: rgba(255, 255, 255, 0.45); - opacity: 0; - max-width: 0; - overflow: hidden; - transform: translateX(-3px); - transition: opacity 0.2s ease, max-width 0.2s ease, transform 0.2s ease, margin-left 0.2s ease; -} - - -.blockchain-details-card { - max-width: min(620px, calc(100vw - 28px)); -} - - -.blockchain-details-grid { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - gap: 8px 12px; - padding: 12px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 14px; - background: rgba(255, 255, 255, 0.035); -} - - -.blockchain-details-grid span { - color: rgba(255, 255, 255, 0.58); - font-size: 12px; -} - - -.blockchain-details-grid code, -.blockchain-details-grid strong { - min-width: 0; - color: rgba(255, 244, 210, 0.96); - font-size: 12px; - overflow-wrap: anywhere; -} - - -.blockchain-raw-block { - max-height: 280px; - overflow: auto; - padding: 12px; - border-radius: 14px; - background: rgba(0, 0, 0, 0.32); - color: rgba(255, 255, 255, 0.82); - font-size: 11px; - line-height: 1.45; - white-space: pre-wrap; -} - - -.channel-header-route-btn { - position: static; - min-height: 40px; - max-width: min(72vw, 100%); - padding: 0 4px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 14px; - border-radius: 10px; - text-align: center; - transform: none; -} - - -.channel-header-route-btn:hover, -.channel-header-route-btn:focus-visible, -.channel-header-route-btn:active, -.channel-header-route-btn.is-springing { - transform: none; -} - - -@media (max-width: 430px) { -.channel-message-actions { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} -@media (max-width: 365px) { -.channel-message-actions { - grid-template-columns: 1fr; - } -} - - -.author-line { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 6px; - width: 100%; -} - - -.author-line-main { - display: inline-flex; - align-items: center; - gap: 6px; - min-width: 0; - flex-wrap: wrap; -} -.channel-message-type-button { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 82px; - min-height: 28px; - padding: 6px 10px; - border-radius: 10px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.02em; - text-transform: uppercase; - cursor: pointer; -} - - -.channel-message-type-button.is-static, -.channel-message-type-button:disabled { - opacity: 1; - cursor: default; -} - - -.author-line-login { - font-weight: 700; - color: #f5f8ff; -} - - -.author-line-num { - font-weight: 400; - color: rgba(255, 255, 255, 0.44); -} - - -@media (max-width: 420px) { -.channel-message-actions { - grid-template-columns: 1fr; - } -} - - -.channel-message-actions, -.thread-node-actions { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - flex-wrap: nowrap; - overflow-x: auto; - padding-top: 14px; - border-top: 1px solid rgba(255, 255, 255, 0.06); -} -/* 2) Static controls with energy + glass glare (no levitation) */ -.channels-screen--list .channels-tab-btn, -.channels-screen--list .channels-bottom-action, -.channels-screen .channel-main-action, -.channels-screen .channel-back-btn, -.channels-screen .channel-head-actions .secondary-btn, -.channels-screen .channel-action-item { - position: relative; - overflow: hidden; - transition: box-shadow 0.75s ease-out, color 0.28s ease, border-color 0.28s ease, background 0.28s ease; + box-shadow: none; animation: none; } - - -.channels-screen--list .channels-tab-btn:hover, -.channels-screen--list .channels-bottom-action:hover, -.channels-screen .channel-main-action:hover, -.channels-screen .channel-back-btn:hover, -.channels-screen .channel-head-actions .secondary-btn:hover, -.channels-screen .channel-action-item:hover { - transform: none; -} - - -.channels-screen::before, -.channels-screen.channels-screen--channel::before { - background: transparent; -} +.channel-message-head-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; } +.channel-message-author-tile { display: flex; flex: 1; gap: 12px; align-items: center; min-width: 0; min-height: 44px; padding: 0; text-align: left; cursor: pointer; } +.channel-message-author { display: grid; min-width: 0; gap: 2px; } +.channel-message-avatar.avatar { width: 36px; height: 36px; min-width: 36px; min-height: 36px; } +.channel-message-title, .author-line-main { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; min-width: 0; } +.author-line-login { font-size: .875rem; line-height: 1.25rem; font-weight: 600; color: var(--text-primary); overflow-wrap: anywhere; } +.author-line-num, .channel-message-time { font-size: .75rem; line-height: 1.125rem; color: var(--text-secondary); } +.channel-message-body { margin: 0; font-size: .9375rem; line-height: 1.4375rem; color: var(--text-primary); white-space: pre-wrap; overflow-wrap: anywhere; min-width: 0; } +.channel-message-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; } +.channel-action-item { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 44px; min-height: 44px; padding: 8px; border-radius: 12px; color: var(--text-secondary); cursor: pointer; font-size: .75rem; } +.channel-action-item:hover { color: var(--text-primary); background: var(--surface); } +.channel-action-item.is-liked { color: var(--reaction-active); } +.channel-action-item:disabled { opacity: .5; cursor: wait; } +.channel-action-icon { display: inline-flex; font-size: 22px; line-height: 1; } +.channel-action-icon svg { width: 22px; height: 22px; } +.channel-action-counter { color: inherit; font-size: .75rem; } +.channel-action-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); } +.channel-action-reply, .thread-reply-btn { margin-left: auto; color: var(--accent); } +.channel-action-reply .channel-action-label, .thread-reply-btn .channel-action-label { position: static; width: auto; height: auto; clip: auto; } +.channel-action-reply .channel-action-icon, .channel-action-reply .channel-action-counter, .thread-reply-btn .channel-action-icon, .thread-reply-btn .channel-action-counter { display: none; } +.channel-message-more { width: 44px; height: 44px; margin-left: auto; font-size: 24px; color: var(--text-secondary); } +.channel-message-kind-badge, .channel-message-type-button { min-height: 28px; padding: 6px 10px; border-radius: 8px; color: var(--accent); background: var(--surface-selected); font-size: .75rem; } +.channel-message-type-button:not(:disabled) { min-height: 44px; cursor: pointer; } +.message-edited-marker { font-size: .75rem; color: var(--text-secondary); } +.deleted-message-pill { min-height: 44px; text-align: left; color: var(--text-secondary); padding: 8px 0; } +.channel-header-title, .channel-header-owner { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.channel-header-owner { color: var(--text-secondary); font-size: .75rem; line-height: 1rem; font-weight: 400; } +.channel-composer { display: flex; gap: 8px; padding: 8px 0; background: var(--background); } +.channel-composer > button { width: 100%; min-height: 44px; border-radius: 12px; color: var(--on-accent); background: var(--accent); } +.blockchain-details-grid { display: grid; grid-template-columns: auto minmax(0,1fr); gap: 8px; font-size: .75rem; } +.blockchain-details-grid code, .blockchain-raw-block { overflow-wrap: anywhere; white-space: pre-wrap; } +.blockchain-raw-block { max-height: 40vh; overflow: auto; padding: 12px; background: var(--background); } +.draft-attachments { display: flex; flex-wrap: wrap; gap: 8px; } +.draft-attachment-chip { max-width: 100%; overflow-wrap: anywhere; } +.modal-danger-action { width: 100%; color: var(--danger); } +.thread-reply-context { color: var(--text-secondary); font-size: .75rem; line-height: 1.125rem; border-left: 2px solid var(--border-control); padding-left: 10px; overflow-wrap: anywhere; } +@media (prefers-reduced-motion: reduce) { .channels-screen *, .channel-composer * { animation: none; scroll-behavior: auto; transition: none; } } diff --git a/shine-UI/styles/features/channels-list.css b/shine-UI/styles/features/channels-list.css index da1cd6ab..84a0b39b 100644 --- a/shine-UI/styles/features/channels-list.css +++ b/shine-UI/styles/features/channels-list.css @@ -1,979 +1,239 @@ -/* Channels list/create/search styles. */ +/* Список каналов, поиск и форма создания. Визуальный owner этих экранов. */ -.channels-screen .modal-card .input { - background: linear-gradient(180deg, rgba(16, 31, 58, 0.9), rgba(11, 22, 43, 0.94)); -} - - -.channels-scroll-wrap { - position: relative; - max-height: 58vh; - overflow-y: auto; - padding-right: 12px; -} - - -.channels-groups { - min-height: min-content; -} - - -.channels-divider { - border: 0; - border-top: 1px solid rgba(255, 255, 255, 0.14); - margin: 6px 0 8px; -} - - -.channels-scroll-hint { +.sr-only { position: absolute; - top: 4px; - right: 0; - width: 4px; - height: calc(100% - 8px); - border-radius: 999px; - background: linear-gradient(180deg, rgba(83, 216, 251, 0.55), rgba(83, 216, 251, 0.15)); - pointer-events: none; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } - .channels-screen { position: relative; - gap: 11px; - isolation: isolate; -} - - -.channels-screen::before { - content: ""; - position: absolute; - inset: -24px -18px 0; - z-index: -1; - pointer-events: none; - background: - radial-gradient(circle at 16% -4%, rgba(232, 191, 92, 0.22), transparent 40%), - radial-gradient(circle at 80% 6%, rgba(77, 114, 189, 0.35), transparent 39%), - radial-gradient(circle at 52% 22%, rgba(13, 33, 70, 0.54), transparent 44%), - linear-gradient(180deg, rgba(4, 11, 24, 0.92), rgba(4, 11, 23, 0.4) 46%, transparent 100%); -} - - - - - - - - - - -.channels-screen .card { - background: - linear-gradient(168deg, rgba(16, 31, 58, 0.95), rgba(8, 17, 34, 0.98)), - radial-gradient(circle at 50% 0%, rgba(53, 90, 165, 0.2), transparent 52%); - border: 1px solid transparent; - box-shadow: 0 14px 30px rgba(2, 7, 15, 0.54); -} - - -.channels-screen .meta-muted { - color: #c3cfea; -} - - -.channels-screen .status-line.is-unavailable { - color: #ffd9e1; -} - - -.channels-hero { - display: grid; - grid-template-columns: auto 1fr; + width: min(100%, 640px); + margin-inline: auto; gap: 12px; + color: var(--text-primary); +} + +.channels-screen::before { content: none; } + +.channels-screen .meta-muted { color: var(--text-secondary); } + +.channels-create-btn { + width: 44px; + height: 44px; + padding: 0; + color: var(--accent); + font-size: 28px; + font-weight: 400; +} + +.channels-list-controls { + display: grid; + gap: 12px; +} + +.channels-inline-search { + min-height: 44px; + padding: 0 6px 0 12px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; - padding: 12px; - border-color: transparent; - box-shadow: 0 14px 30px rgba(4, 9, 20, 0.54); + gap: 8px; + border: 1px solid var(--border-subtle); + border-radius: 12px; + color: var(--text-secondary); + background: var(--surface); } +.channels-inline-search:focus-within { + border-color: var(--focus-ring); + outline: 2px solid color-mix(in srgb, var(--focus-ring) 24%, transparent); +} -.channels-hero-emblem { +.channels-inline-search input { + width: 100%; + min-width: 0; + height: 42px; + border: 0; + outline: 0; + color: var(--text-primary); + background: transparent; + font-size: 16px; +} + +.channels-inline-search input::placeholder { color: var(--text-secondary); } + +.channels-server-search { + min-height: 44px; + padding: 0 8px; + color: var(--accent); + font-size: 13px; +} + +.channels-list-content, +.channels-groups { + min-width: 0; + display: grid; + gap: 0; +} + +.channel-row { + text-align: left; position: relative; - width: 62px; - height: 62px; + width: 100%; + min-width: 0; + min-height: 76px; + padding: 14px 0; + display: grid; + grid-template-columns: 48px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + border: 0; + border-bottom: 1px solid var(--border-subtle); + border-radius: 0; + color: var(--text-primary); + background: transparent; + box-shadow: none; + cursor: pointer; + transition: background-color 160ms ease; +} + +.channel-row:hover { background: color-mix(in srgb, var(--surface) 66%, transparent); } +.channel-row:active { background: var(--surface-selected); } +.channel-row:focus-within { outline: 2px solid var(--focus-ring); outline-offset: -2px; } + +.channels-screen--list .channel-row .avatar { + width: 48px; + height: 48px; + min-width: 48px; + min-height: 48px; border-radius: 50%; - border: 1px solid transparent; - background: - radial-gradient(circle at 45% 40%, rgba(247, 217, 145, 0.9), rgba(199, 146, 61, 0.95) 46%, rgba(127, 88, 36, 0.96) 100%); - box-shadow: - inset 0 0 0 5px rgba(20, 38, 74, 0.76), - 0 10px 20px rgba(3, 7, 16, 0.52); + overflow: hidden; } - -.channels-hero-emblem::before, -.channels-hero-emblem::after { - content: ""; - position: absolute; - inset: 14px; - border-radius: 50%; - border: 2px solid rgba(27, 47, 85, 0.74); -} - - -.channels-hero-emblem::after { - inset: 7px; - border-width: 1px; - border-color: transparent; -} - - -.channels-hero-copy { +.channel-row-main { min-width: 0; display: grid; gap: 2px; } - -.channels-hero-kicker { - font-size: 11px; - letter-spacing: 0.16em; - text-transform: uppercase; - color: #e8cc8f; -} - - -.channels-hero-title { - font-family: "Cormorant Garamond", "Times New Roman", serif; - font-size: 26px; - line-height: 1; - color: #f4ddab; -} - - -.channels-hero-subtitle { - color: #afc1e3; - font-size: 12px; - line-height: 1.35; -} - - -.channels-user-chip { - display: flex; - align-items: center; - gap: 9px; - color: #e9f0ff; - min-height: 46px; - font-size: 15px; -} - - -.channels-user-chip::before { - content: ""; - width: 9px; - height: 9px; - border-radius: 50%; - background: #ddb86f; -} - - -.channels-help-card strong { - font-size: 17px; - color: #f3d79c; - font-family: "Cormorant Garamond", "Times New Roman", serif; -} - - -.channels-help-card .meta-muted { - line-height: 1.34; - font-size: 13px; -} - - -.channels-help-card { - gap: 6px; - padding-top: 12px; - padding-bottom: 12px; -} - - -.channels-info-strip { - border-radius: 14px; - padding: 10px 13px; - color: #ead3a0; - border: 1px solid transparent; - background: linear-gradient(134deg, rgba(180, 140, 62, 0.22), rgba(23, 44, 84, 0.3)); - font-size: 12px; - line-height: 1.35; -} - - -.channels-action-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 9px; -} - - -.channels-action-btn { - min-height: 50px; - border-radius: 999px; - text-align: center; - line-height: 1.25; - padding: 9px 10px; - font-weight: 700; - letter-spacing: 0.01em; - font-size: 13px; -} - - -.channels-action-btn.primary-btn { - box-shadow: inset 0 1px 0 rgba(255, 247, 214, 0.6), 0 10px 18px rgba(5, 8, 16, 0.46); -} - - -.channels-action-btn.destructive-btn { - border-color: rgba(248, 141, 163, 0.5); - background: linear-gradient(120deg, rgba(126, 31, 52, 0.94), rgba(88, 22, 37, 0.95)); - box-shadow: inset 0 1px 0 rgba(171, 198, 255, 0.26), 0 9px 16px rgba(5, 8, 16, 0.36); -} - - -.channels-scroll-wrap { - max-height: none; - padding-right: 8px; -} - - -.channels-groups { - gap: 11px; - justify-items: start; -} - - -.channels-section { - gap: 8px; -} - - -.channels-section .section-title { - font-size: 21px; - color: #f2d395; - margin: 0 2px; - font-family: "Cormorant Garamond", "Times New Roman", serif; - letter-spacing: 0.01em; -} - - -.channels-list-empty { - border: 1px dashed transparent; - border-radius: var(--radius-md); - padding: 12px; - color: #aeb9d8; - background: rgba(10, 18, 36, 0.5); -} - - -.channels-divider { - border-top-color: transparent; - margin: 2px 0; -} - - -.channel-row:hover { - border-color: transparent; - box-shadow: 0 10px 22px rgba(2, 8, 16, 0.44); - transform: translateY(-1px); -} - - -.channel-row .avatar { - width: 40px; - height: 40px; - min-width: 40px; - min-height: 40px; - border-radius: 50%; - background: linear-gradient(138deg, #efd193, #c18c42); - color: #17233f; - box-shadow: inset 0 1px 0 rgba(255, 244, 208, 0.58); -} - - -.channel-row-main { +.channel-row-title-line, +.channel-row-preview-line { min-width: 0; - display: grid; - gap: 4px; -} - - -.channel-row-title { - font-size: 18px; - color: #f5daa0; - line-height: 1.2; - letter-spacing: 0.01em; - display: -webkit-box; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; - overflow: hidden; -} - - -.channel-row-description { - font-size: 11px; - color: #9db0dc; - word-break: break-word; - opacity: 0.95; -} - - -.channel-row-message { - font-size: 14px; - line-height: 1.35; - word-break: break-word; - min-height: 18px; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - - -.channel-row-owner { - font-size: 12px; - color: #bdcdeb; -} - - -.channel-row.is-counters-visible .channel-counter-meta, -.channel-row.is-counters-visible .channel-counter-value { - opacity: 1; - transform: translateY(0); -} - - -.channel-row-meta { - display: grid; - justify-items: center; - align-content: start; - gap: 6px; -} - - -.channel-row-kind { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.08em; - padding: 4px 8px; - border-radius: 999px; - border: 1px solid transparent; - color: #f1d49a; - background: rgba(191, 149, 66, 0.16); -} - - -.channel-row-time { - font-size: 11px; - color: #96a7ce; -} - - -.channel-row-count { - min-width: 28px; - justify-content: center; - background: linear-gradient(180deg, #57b6ff 0%, #2a86ff 100%); - color: #ffffff; - box-shadow: - 0 0 0 1px rgba(255, 255, 255, 0.08) inset, - 0 0 10px rgba(52, 146, 255, 0.68), - 0 0 18px rgba(52, 146, 255, 0.32); - font-size: 12px; - font-weight: 700; - line-height: 1; -} - - -.channels-screen--list .channel-row-count[hidden] { - display: none !important; -} - - -.channels-bottom-action { - margin-top: 6px; - min-height: 48px; - border-radius: 13px; - font-size: 14px; -} - - -.channels-status { - border-color: rgba(227, 127, 144, 0.45); - background: linear-gradient(165deg, rgba(68, 24, 36, 0.55), rgba(19, 17, 33, 0.82)); -} - - -@media (max-width: 430px) { -.channels-hero-title { - font-size: 24px; - } -.channels-action-btn { - min-height: 48px; - font-size: 12px; - } -.channel-row { - grid-template-columns: 64px minmax(0, 1fr) 64px; - gap: 10px; - padding: 12px; - } -.channel-row .avatar { - width: 40px; - height: 40px; - min-width: 40px; - min-height: 40px; - } -.channel-row-title { - font-size: 16px; - } -.channel-row-message { - font-size: 13px; - } -} - - -@media (max-width: 365px) { -.channels-action-grid { - grid-template-columns: 1fr; - } -} - - -@keyframes channels-shimmer { - 0% { background-position: 210% 0; } - 100% { background-position: -10% 0; } -} - - -.channels-tabs { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - padding: 6px; - border-radius: 14px; - border: 1px solid transparent; - background: linear-gradient(165deg, rgba(12, 24, 46, 0.92), rgba(9, 17, 34, 0.96)); -} - - - - - - - - - - - - - - - - - - - - -.channels-search-icon { - position: relative; - display: block; - width: 13px; - height: 13px; - border: 1.8px solid #d9f5ff; - border-radius: 50%; - box-shadow: 0 0 6px rgba(133, 225, 255, 0.58); -} - - -.channels-search-icon::after { - content: ""; - position: absolute; - width: 6px; - height: 1.8px; - right: -4px; - bottom: -2px; - border-radius: 999px; - background: #d9f5ff; - box-shadow: 0 0 5px rgba(133, 225, 255, 0.58); - transform: rotate(45deg); - transform-origin: left center; -} - - -.channels-top-action-btn { - min-height: 38px; - padding: 8px 12px; - white-space: nowrap; -} - - -.channels-tab-btn { - min-height: 38px; - border-radius: 10px; - border: 1px solid rgba(122, 151, 210, 0.26); - background: rgba(18, 33, 62, 0.55); - color: #b8c9ec; - font-weight: 600; - cursor: pointer; -} - - -.channels-tab-btn.is-active { - border-color: transparent; - color: #f4d99e; - background: linear-gradient(160deg, rgba(193, 154, 76, 0.22), rgba(18, 33, 62, 0.64)); -} - - -.channels-empty-state { - border: 1px dashed transparent; - border-radius: 14px; - padding: 16px; - display: grid; - gap: 10px; - color: #b7c6e8; - background: rgba(10, 18, 34, 0.52); -} - - -.channels-empty-state--compact { - border: 0; - background: transparent; - border-radius: 0; - padding: 4px 2px; - gap: 6px; -} - - -.channels-empty-icon { - font-size: 20px; - color: #d9b56d; -} - - -.channel-row-main { - padding-right: 6px; -} - - -.channel-row-description { - display: none; -} - - -.channel-row-controls { - display: grid; - justify-items: end; - align-content: start; - gap: 6px; - position: relative; - min-width: 44px; - grid-template-rows: auto auto; -} - - -.channels-screen--list .channel-row-time { - grid-row: 2; - align-self: end; -} - - -@keyframes spring-tap { - 0% { transform: scale(1); } - 30% { transform: scale(0.95); } - 60% { transform: scale(1.06); } - 100% { transform: scale(1); } -} -@media (max-width: 420px) { -.channels-action-grid { - grid-template-columns: 1fr; - } -} - - -.channels-tabs--sticky { - position: sticky; - top: 0; - z-index: 8; - backdrop-filter: blur(12px); -} - - -.channels-list-content { - display: grid; - gap: 10px; - min-height: 0; -} - - -.channels-unread-divider { display: flex; - align-items: center; - justify-content: center; - margin: 6px 0 2px; - padding: 8px 12px; - border-top: 1px solid rgba(255, 255, 255, 0.12); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - color: rgba(255, 220, 130, 0.9); - font-size: 12px; - letter-spacing: 0.03em; + align-items: baseline; + gap: 8px; } - -.channels-unread-jump { - position: fixed; - right: 18px; - bottom: 96px; - width: 48px; - height: 48px; - border-radius: 999px; - border: 1px solid transparent; - background: linear-gradient(160deg, rgba(16, 24, 38, 0.92), rgba(12, 18, 30, 0.86)); - color: rgba(255, 227, 150, 0.95); - box-shadow: 0 12px 26px rgba(3, 8, 18, 0.45), 0 0 20px rgba(212, 175, 55, 0.16); - display: inline-flex; - align-items: center; - justify-content: center; - z-index: 38; - opacity: 0; - pointer-events: none; - transform: translateY(8px); - transition: opacity 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; -} - - -.channels-unread-jump.is-visible { - opacity: 1; - pointer-events: auto; - transform: translateY(0); -} - - -.channels-unread-jump:hover { - box-shadow: 0 14px 30px rgba(3, 8, 18, 0.55), 0 0 24px rgba(212, 175, 55, 0.22); -} - - -.channels-unread-jump-icon { - font-size: 18px; - line-height: 1; -} - - -.channels-unread-jump-badge { - position: absolute; - top: -6px; - right: -6px; - min-width: 20px; - height: 20px; - border-radius: 999px; - background: rgba(212, 175, 55, 0.95); - color: #1f1702; - font-size: 11px; - font-weight: 700; - padding: 0 5px; - display: inline-flex; - align-items: center; - justify-content: center; - box-shadow: 0 0 0 2px rgba(10, 14, 24, 0.9); -} - - -.channels-list-body-fade { - animation: channels-fade-in 0.2s ease; -} - - -@keyframes channels-fade-in { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } -} - - -.channels-search-suggest { - max-height: 172px; - overflow-y: auto; - border: 1px solid rgba(150, 174, 224, 0.3); - border-radius: 12px; - background: rgba(9, 18, 35, 0.94); - padding: 6px; -} - - -.channel-search-item { - width: 100%; - text-align: left; - border-radius: 9px; - padding: 8px 10px; - cursor: pointer; - margin-bottom: 6px; -} - - -.channel-search-item:last-child { - margin-bottom: 0; -} - - - - -/* ===== Final Glass Polish (channels) ===== */ -.channels-screen { - color: rgba(255, 255, 255, 0.9); -} - - -.channels-screen::before { - background: - radial-gradient(600px 600px at 100% 0%, rgba(100, 60, 200, 0.12), transparent 70%), - radial-gradient(500px 500px at 0% 100%, rgba(40, 80, 200, 0.08), transparent 70%), - linear-gradient(180deg, #0a0b10 0%, #0a0b10 100%); -} - - -.channels-screen .card { - background: rgba(20, 25, 35, 0.55); - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); - border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 20px; - box-shadow: 0 0 60px rgba(80, 60, 180, 0.15); -} - - -.channel-row { - display: grid; - gap: 12px; - width: min(100%, 340px); - cursor: pointer; - transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; - position: relative; - grid-template-columns: 46px minmax(0, 1fr) auto; - background: rgba(20, 25, 35, 0.55); - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); - border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 20px; - box-shadow: 0 0 60px rgba(80, 60, 180, 0.15); - padding: 20px; - margin-bottom: 16px; - border-color: rgba(255, 255, 255, 0.07); -} - - -.channels-tabs { - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); -} - - -.channels-tab-btn { - background: transparent; - border: none; - border-bottom: 2px solid transparent; - border-radius: 0; - color: rgba(255, 255, 255, 0.4); -} - - -.channels-tab-btn.is-active { - background: transparent; - border-bottom-color: transparent; - color: rgba(255, 200, 50, 0.9); -} - - -.channel-row .avatar { - width: 40px; - height: 40px; - min-width: 40px; - min-height: 40px; - border-radius: 50%; - overflow: hidden; - color: #ffffff; - font-weight: 700; -} - - -.channel-row .avatar img { - width: 100%; - height: 100%; - display: block; - object-fit: cover; - object-position: center; - border-radius: 50%; -} - - .channel-row-title { - color: #ffffff; - font-size: 17px; - font-weight: 800; - letter-spacing: 0.01em; -} - - -.channel-row-technical { - margin: -2px 0 0; - color: rgba(255, 255, 255, 0.46); - font-size: 11px; - line-height: 1.2; + min-width: 0; + flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: var(--text-primary); + font-size: 15px; + line-height: 20px; + font-weight: 600; } - -.channel-row-message { - color: rgba(255, 255, 255, 0.5); - font-size: 13px; -} - - .channel-row-time { - color: rgba(255, 255, 255, 0.35); + flex: 0 0 auto; + color: var(--text-secondary); font-size: 12px; - text-align: right; - width: 100%; + line-height: 18px; + white-space: nowrap; } +.channel-row-technical, +.channel-row-message { + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-secondary); + font-size: 12px; + line-height: 18px; +} +.channel-row-message { font-size: 13px; } +.channel-row-description, +.channel-row-owner { display: none; } + +.channel-row-controls { + align-self: end; + min-width: 24px; + display: grid; + justify-items: end; +} + +.channel-row-count { + min-width: 22px; + height: 22px; + padding: 0 7px; + border: 0; + color: var(--on-accent); + background: var(--accent); + box-shadow: none; + font-size: 12px; +} + +.channel-row-count[hidden] { display: none; } + +.channels-empty-state, +.channels-status { + min-height: 180px; + padding: 32px 20px; + display: grid; + place-content: center; + justify-items: center; + gap: 12px; + border: 0; + text-align: center; + background: transparent; +} + +.channels-empty-state--compact { min-height: 132px; } + +.channels-status .primary-btn, .channels-bottom-action { - background: rgba(255, 180, 0, 0.12); - border: 1px solid transparent; - color: rgba(255, 200, 50, 0.9); - border-radius: 14px; - padding: 14px; - width: 100%; -} - - - - - - - - - - - - - - - - - - - - -.channels-screen--channel-about .screen-footer { - margin-top: auto; - padding: 8px 4px 0; - text-align: center; - font-size: 12px; - letter-spacing: 0.02em; -} - - -.channels-empty-state--compact .meta-muted { - color: rgba(255, 255, 255, 0.4); - text-align: center; - font-style: italic; -} - - -/* ===== Channels list tabs: match toolbar active button background ===== */ - - -.channels-screen--list .channels-tab-btn { - color: #b8c7ea; + min-height: 44px; + padding: 0 18px; border-radius: 12px; - border: 1px solid transparent; - background: transparent; + color: var(--on-accent); + background: var(--accent); } +.channels-list-body-fade { animation: channels-fade-in 160ms ease-out; } +@keyframes channels-fade-in { from { opacity: 0; } to { opacity: 1; } } -.channels-screen--list .channels-tab-btn.is-active { - background: - linear-gradient(145deg, rgba(220, 181, 94, 0.32), rgba(39, 66, 122, 0.3)), - rgba(20, 35, 64, 0.62); - border: 1px solid transparent; - color: #f7e2ad; - box-shadow: inset 0 1px 0 rgba(255, 242, 204, 0.42); +.channels-search-suggest { + max-height: 240px; + padding: 6px; + overflow-y: auto; + border: 1px solid var(--border-subtle); + border-radius: 12px; + background: var(--surface); } - -/* ===== SHiNE Web3 Glassmorphism polish ===== */ -.channels-screen { - color: rgba(255, 255, 255, 0.9); -} - - -.channels-screen--list .channels-tab-btn { - background: transparent; - border: 1px solid transparent; - color: rgba(255, 255, 255, 0.66); - text-shadow: none; -} - - -.channels-screen--list .channels-tab-btn.is-active { - background: - linear-gradient(145deg, rgba(212, 175, 55, 0.22), rgba(68, 92, 171, 0.2)), - rgba(18, 24, 38, 0.44); - border: 1px solid transparent; - color: #D4AF37; - text-shadow: 0 0 10px rgba(212, 175, 55, 0.6); - box-shadow: inset 0 1px 0 rgba(255, 238, 197, 0.3); -} - - -.channels-screen--list .channels-tab-btn.is-disabled { - text-decoration: line-through; - text-decoration-thickness: 1.5px; - opacity: 0.72; -} - - -/* Визуальная оболочка framed Avatar принадлежит styles/components/avatar.css. */ - - -.channels-screen--add #cancel-create-channel, -.channels-screen--add #submit-create-channel { - min-height: 50px; - border-radius: 14px; - font-weight: 700; - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); -} - - -.channels-screen--add #cancel-create-channel { - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.16); - color: rgba(255, 255, 255, 0.72); -} - - -.channels-screen--add #submit-create-channel { - background: linear-gradient(135deg, rgba(212, 175, 55, 0.2), rgba(212, 175, 55, 0.05)); - border: 1px solid transparent; - color: rgba(255, 213, 118, 0.95); -} - - -.channels-screen--add #cancel-create-channel:hover, -.channels-screen--add #submit-create-channel:hover { - transform: none; - box-shadow: 0 0 22px rgba(212, 175, 55, 0.2), inset 0 0 8px rgba(212, 175, 55, 0.1); +.channel-search-item { + width: 100%; + min-height: 44px; + padding: 9px 10px; + border-radius: 9px; + color: var(--text-primary); + text-align: left; + cursor: pointer; } +.channels-screen--add > .card { padding: 16px; } .channel-create-avatar-head { display: grid; @@ -982,538 +242,50 @@ align-items: start; } - -.channel-create-avatar-side { - min-width: 0; - display: grid; - gap: 10px; - align-content: start; -} - +.channel-create-avatar-side, +.channel-create-avatar-status { min-width: 0; display: grid; gap: 8px; } .channel-create-avatar-status-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; - gap: 10px; - align-items: start; -} - - -.channel-create-avatar-status { - min-width: 0; -} - - -.channel-avatar-status-text { - margin: 0; - color: rgba(255, 255, 255, 0.96); - font-weight: 500; - font-size: 12px; - line-height: 1.35; -} - - -.channel-avatar-status-line { - margin: 2px 0 0; - color: rgba(255, 255, 255, 0.96); - font-size: 12px; - line-height: 1.35; -} - - -.channel-avatar-txid { - margin: 0; - word-break: break-all; - color: rgba(255, 255, 255, 0.96); - font-size: 11px; -} - - -.channel-avatar-status-pending { - color: rgba(255, 255, 255, 0.82); -} - - -.channel-create-login-hint { - font-size: 11px; - line-height: 1.35; -} - - -.channel-link-preview { - min-height: 18px; -} - - -.channel-avatar-remove-btn { - width: 26px; - min-width: 26px; - height: 26px; - padding: 0; - border-radius: 8px; - font-weight: 700; - line-height: 1; -} - - -.channel-avatar-remove-btn[hidden] { - display: none; -} - - -@media (max-width: 420px) { -.channel-create-avatar-head { - grid-template-columns: 1fr; - } -} -.channels-screen--list .channel-row:hover { - transform: none; -} - - -.channels-screen--list .channels-tab-btn::after, -.channels-screen--list .channels-bottom-action::after { - content: ""; - position: absolute; - top: 0; - left: -150%; - width: 50%; - height: 100%; - background: linear-gradient( - to right, - rgba(255, 255, 255, 0) 0%, - rgba(255, 255, 255, 0.1) 50%, - rgba(255, 255, 255, 0) 100% - ); - transform: skewX(-25deg); - transition: none; - z-index: 1; - pointer-events: none; -} - - -.channels-screen--list .channels-tab-btn:hover::after, -.channels-screen--list .channels-bottom-action:hover::after { - animation: glareSweep 1.6s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; -} - - -/* ===== Channels CTA proportions fix ===== */ -.channels-empty-state--compact .meta-muted, -.channels-list-empty { - display: flex; - justify-content: center; - align-items: center; - text-align: center; - width: 100%; -} - - -.channels-screen, -.channels-list-content, -.channels-scroll-wrap { - background: transparent; -} - - -/* ===== SHiNE Glassmorphism activation (channels list) ===== */ - - -.channels-screen--list .channels-empty-state { - margin-left: 24px; - margin-right: 24px; - background: rgba(18, 24, 38, 0.4); - backdrop-filter: blur(25px); - -webkit-backdrop-filter: blur(25px); - border: 1px solid transparent; - border-radius: 20px; - margin: 16px 8px; -} - - -.channels-screen--list .channels-status { - margin-left: 24px; - margin-right: 24px; - background: rgba(18, 24, 38, 0.4); - backdrop-filter: blur(25px); - -webkit-backdrop-filter: blur(25px); - border: 1px solid transparent; - border-radius: 20px; - margin: 16px 8px; -} - - -.channels-screen--list .channels-tabs { - box-shadow: 0 18px 32px rgba(2, 6, 13, 0.48); - margin-left: 24px; - margin-right: 24px; - margin-top: 16px; - margin-bottom: 12px; - background: rgba(18, 24, 38, 0.4); - backdrop-filter: blur(25px); - -webkit-backdrop-filter: blur(25px); - border: 1px solid transparent; - border-radius: 20px; - margin: 16px 8px; -} - - -.channels-screen--list .channels-bottom-action { - display: block; - width: min(90%, 360px); - max-width: 90%; - margin-left: auto; - margin-right: auto; - margin-top: 16px; - margin-bottom: 20px; - padding: 12px 24px; - box-sizing: border-box; - align-self: center; - font-weight: 700; - margin: 16px 8px; - background: linear-gradient(135deg, rgba(212, 175, 55, 0.2), rgba(212, 175, 55, 0.05)); - backdrop-filter: blur(25px); - -webkit-backdrop-filter: blur(25px); - border: 1px solid transparent; - border-radius: 20px; - color: #D4AF37; -} - - -.channels-empty-state--silent { - padding: 0; - min-height: 0; -} - - -.channels-screen--list .channels-bottom-action.is-empty-lift { - margin-top: 6px; -} - - -/* Compact rows keep the channel sphere as the visual height reference. */ - - -.channels-screen--list .channel-row-message { - min-height: 0; - line-height: 1.25; -} - - -/* Пустые каналы: больше никакой золотой вставки/рамки. Сообщение оформлено - * как спокойный матовый chip из панели фильтров «Связей». */ -.channels-screen--list .channels-empty-state.channels-empty-state--silent { - width: 100%; - min-height: 0; - margin: 12px 0; - padding: 0; - border: 0; - border-radius: 0; - background: transparent; - box-shadow: none; - backdrop-filter: none; - -webkit-backdrop-filter: none; - place-items: center; -} - - -.channels-screen--list .channels-empty-state.channels-empty-state--silent .meta-muted { - display: inline-flex; - align-items: center; - justify-content: center; - width: auto; - max-width: calc(100% - 24px); - min-height: 30px; - margin: 0; - padding: 7px 14px; - border: 0.5px solid rgba(255, 255, 255, 0.1); - border-radius: 999px; - background: rgba(255, 255, 255, 0.03); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.08); - color: #cfe0ff; - font-size: 12px; - font-weight: 600; - font-style: normal; - line-height: 1.2; - text-align: center; -} - - -/* Каналы: счётчик непрочитанных — целиком голубой светящийся овал, - * цифра остаётся обычной белой, без собственного glow. */ -.channel-row-count { - background: rgba(92, 190, 255, 0.16); - border: 1px solid rgba(92, 190, 255, 0.56); - color: #FFFFFF; - box-shadow: - 0 0 7px rgba(92, 190, 255, 0.58), - 0 0 15px rgba(72, 145, 255, 0.28), - inset 0 0 8px rgba(92, 190, 255, 0.10); - text-shadow: none; -} - - -/* Каналы: логин/техническая подпись и overflow-троеточие используют - * тот же основной белый + голубое свечение, что верхний toolbar. */ -.channel-row-technical { - font-family: var(--font-main); - font-weight: 600; - color: var(--app-topbar-gold); - text-shadow: - 0 0 5px var(--app-topbar-blue-glow), - 0 0 12px var(--app-topbar-blue-glow-soft); -} - - -/* Название канала и техническая строка (login / channel) — одна типографическая - * система с основным интерфейсом: белый текст + голубое свечение. */ - - -/* Счётчик непрочитанных: без видимой границы. Сам овал — светящийся синий объём, - * цифра остаётся обычной белой. */ - - -.channels-screen--list .channel-row .channel-row-controls > .channel-row-count { - top: 0; - right: 0; - position: absolute; - z-index: 2; -} - - -.channels-screen--list .channel-row .channel-row-controls > .channel-menu-trigger { - display: none !important; -} -.channels-filter-title { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - cursor: pointer; -} -.channels-filter-title::after { - content: "⌄"; - font-size: 13px; - line-height: 1; - opacity: 0.72; - transform: translateY(-1px); -} - - -/* ===== Каналы: плотные широкие строки, badge сверху, время на линии preview ===== */ - - -.channels-screen--list .unread.channel-row-count { - border: 0; - outline: 0; - background: linear-gradient(180deg, #57b6ff 0%, #2a86ff 100%); - color: #FFFFFF; - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08) inset, - 0 0 10px rgba(52, 146, 255, 0.68), - 0 0 18px rgba(52, 146, 255, 0.32); - text-shadow: none; - position: static; - margin: 0; -} - - -.channels-screen--list .channel-row-count { - grid-row: 1; - align-self: start; - justify-self: end; - border: 0; - outline: 0; - background: linear-gradient(180deg, #57b6ff 0%, #2a86ff 100%); - color: #FFFFFF; - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08) inset, - 0 0 10px rgba(52, 146, 255, 0.68), - 0 0 18px rgba(52, 146, 255, 0.32); - text-shadow: none; - position: static; - margin: 0; -} - - -/* Channels: much wider, substantially shorter rows. */ - -.channels-screen--list .channel-row .avatar { - width: 48px; - height: 48px; - min-width: 48px; - min-height: 48px; -} - -.channels-screen--list .channel-row-title { - font-family: var(--font-main); - color: var(--app-topbar-gold); - text-shadow: 0 0 5px var(--app-topbar-blue-glow), - 0 0 12px var(--app-topbar-blue-glow-soft); - font-weight: 700; - margin-block: 0; - line-height: 1.12; -} - - -.channels-screen--list .channel-row-controls { - position: absolute; - width: auto; - min-width: 0; - height: auto; - top: 3px; - right: 5px; -} - - -/* ===== Channel list alignment with Personal chats (2026-08-29) ===== */ -/* Match the Personal tab width model exactly: only the list itself grows beyond - * the content gutter, rather than widening several nested channel containers. */ - -.channels-screen--list .channels-list-content { - width: 100%; - margin-left: 0; - margin-right: 0; -} - -.channels-screen--list .channels-groups { - gap: 0; - width: 100%; - margin-left: 0; - margin-right: 0; -} - - -.channels-screen--list .stack:has(> .channel-row) { - margin-left: -8px; - margin-right: -8px; - width: calc(100% + 16px); - margin-inline: -8px; - gap: 4px; -} - - -/* Slightly taller than Personal (~12%) to comfortably fit title + technical id - * + last-message preview, while keeping the compact list rhythm. */ - - -.channels-screen--list .channel-row { - margin-left: 24px; - margin-right: 24px; - margin-top: 16px; - margin-bottom: 16px; - animation: breatheCard 8s ease-in-out infinite; - backdrop-filter: blur(25px); - -webkit-backdrop-filter: blur(25px); - border: 1px solid transparent; - align-items: center; - border-color: transparent; - background: rgba(18, 24, 38, 0.18); - box-shadow: none; - position: relative; - margin: 1px 0; - border-radius: 15px; - width: 100%; - min-height: 61px; - padding: 6px 7px 6px 5px; - grid-template-columns: 50px minmax(0, 1fr); gap: 8px; } - -.channels-screen--list .channel-row-main { - padding-right: 3px; - align-self: center; - display: grid; - align-content: center; - gap: 1px; - min-width: 0; - overflow: hidden; -} - - -/* Restore the compact technical channel id: owner login / channel name. */ - - -.channels-screen--list .channel-row-technical { - font-family: var(--font-main); - color: var(--app-topbar-gold); - text-shadow: 0 0 5px var(--app-topbar-blue-glow), - 0 0 12px var(--app-topbar-blue-glow-soft); - margin-block: 0; - display: block; - min-width: 0; - margin: 0; - font-size: 10.5px; - line-height: 1.05; - font-weight: 500; - opacity: .72; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - - -/* Keep only the requested two supporting lines; description/counters stay out of - * the compact channel tile. */ - - -.channels-screen--list .channel-row-owner { - margin-block: 0; - line-height: 1.12; - display: none; -} - - -.channels-screen--list .channel-row-description { - margin-block: 0; - line-height: 1.12; - display: none; -} - - -/* Last message and its timestamp form one single line. The preview yields space - * to the timestamp and truncates with an ellipsis when necessary. */ - -.channels-screen--list .channel-row-preview-line { - grid-template-columns: minmax(0, 1fr) auto; - line-height: 1.15; - display: flex; - align-items: baseline; - width: 100%; - min-width: 0; - gap: 7px; - margin: 0; -} - - -/* Channel list: last message preview + timestamp. - * Override the legacy `.channel-row-time { width: 100% }`: that rule made the - * timestamp consume the whole preview row, collapsing the message text to zero - * width and clipping the rightmost timestamp character. */ -.channels-screen--list .channel-row-preview-line .channel-row-time { - position: static; - transform: none; - align-self: baseline; - font-size: 11px; - line-height: 1.1; - width: auto; - max-width: max-content; - flex: 0 0 auto; - margin: 0 2px 0 0; - padding: 0; - box-sizing: border-box; - white-space: nowrap; -} -.channels-screen--list .channel-row-preview-line .channel-row-message { - max-width: 100%; +.channel-avatar-status-text, +.channel-avatar-status-line, +.channel-avatar-txid { margin: 0; + color: var(--text-secondary); font-size: 12px; - line-height: 1.1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - width: auto; - flex: 1 1 0; - min-width: 0; + line-height: 18px; + overflow-wrap: anywhere; +} + +.channel-avatar-remove-btn { width: 44px; height: 44px; padding: 0; color: var(--danger); } +.channel-avatar-remove-btn[hidden] { display: none; } +.channel-create-login-hint, .channel-link-preview { font-size: 12px; line-height: 18px; } + +.channels-screen--add :is(.input, .select) { + color: var(--text-primary); + background: var(--surface); + border-color: var(--border-control); +} + +.channels-screen--add :is(.primary-btn, .secondary-btn) { + min-height: 44px; + border-radius: 12px; +} + +.channels-screen--add .primary-btn { color: var(--on-accent); background: var(--accent); } +.channels-screen--add .secondary-btn { color: var(--text-primary); border: 1px solid var(--border-control); background: var(--surface); } + +@media (max-width: 360px) { + .channel-row { grid-template-columns: 44px minmax(0, 1fr) auto; gap: 9px; } + .channels-screen--list .channel-row .avatar { width: 44px; height: 44px; min-width: 44px; min-height: 44px; } + .channels-server-search { padding-inline: 4px; } + .channel-create-avatar-head { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + .channels-list-body-fade, .channel-row { animation: none; transition: none; } } diff --git a/shine-UI/styles/features/chat.css b/shine-UI/styles/features/chat.css index ff70d858..8b5dbc74 100644 --- a/shine-UI/styles/features/chat.css +++ b/shine-UI/styles/features/chat.css @@ -254,287 +254,6 @@ } -.message-attachments { - display: grid; - gap: 0.5rem; -} - - -.message-attachment-carousel { - gap: 0.35rem; - margin: 0.5rem 0 0.4rem; - max-width: min(100%, 34rem); -} - - -.message-attachment-carousel-viewport { - align-items: center; - background: linear-gradient(135deg, rgba(15, 23, 42, 0.05), rgba(20, 184, 166, 0.08)); - border: 1px solid rgba(15, 23, 42, 0.1); - border-radius: 1.15rem; - display: grid; - min-height: 12rem; - overflow: hidden; - position: relative; - touch-action: pan-y; -} - - -.message-attachment-carousel-viewport.has-landscape-media { - align-items: start; - min-height: 0; -} - - -.message-attachment-slide { - align-items: stretch; - display: grid; - min-height: 12rem; -} - - -.message-attachment-slide.has-landscape-media { - min-height: 0; -} - - -.message-attachment-arrow { - align-items: center; - border-radius: 999px; - cursor: pointer; - display: inline-flex; - font-size: 1.85rem; - height: 2.35rem; - justify-content: center; - line-height: 1; - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 2.35rem; - z-index: 2; -} - - -.message-attachment-arrow[hidden] { - display: none; -} - - -.message-attachment-arrow:disabled { - cursor: default; - opacity: 0.28; -} - - -.message-attachment-arrow--prev { - left: 0.55rem; -} - - -.message-attachment-arrow--next { - right: 0.55rem; -} - - -.message-attachment-counter { - color: #64748b; - font-size: 0.78rem; - text-align: center; -} - - -.message-attachment-media-frame { - align-items: center; - background: #0f172a; - cursor: zoom-in; - display: grid; - min-height: 12rem; - overflow: hidden; - position: relative; -} - - -.message-attachment-media-frame.is-landscape { - min-height: 0; -} - - -.message-attachment-media { - display: block; - height: min(52vh, 20rem); - max-height: 20rem; - object-fit: contain; - width: 100%; -} - - -.message-attachment-media.is-landscape { - height: auto; -} - - -.message-attachment-play { - align-items: center; - background: rgba(15, 23, 42, 0.72); - border: 1px solid rgba(255, 255, 255, 0.25); - border-radius: 999px; - color: #fff; - display: inline-flex; - font-size: 1.7rem; - height: 4rem; - justify-content: center; - left: 50%; - padding-left: 0.18rem; - position: absolute; - top: 50%; - transform: translate(-50%, -50%); - width: 4rem; -} - - -.message-attachment-download { - background: rgba(20, 184, 166, 0.95); - border-radius: 999px; - color: #fff; - font-size: 0.82rem; - font-weight: 700; - padding: 0.45rem 0.75rem; - position: relative; - text-decoration: none; - width: max-content; - z-index: 3; -} - - -.message-attachment-media-frame > .message-attachment-download { - bottom: 0.75rem; - position: absolute; - right: 0.75rem; -} - - -.message-attachment-file-card { - align-items: center; - background: linear-gradient(135deg, rgba(15, 23, 42, 0.94), rgba(30, 41, 59, 0.94)); - border: 1px solid rgba(20, 184, 166, 0.28); - border-radius: 0.8rem; - color: #f8fafc; - cursor: pointer; - display: grid; - gap: 0.55rem; - grid-template-columns: auto minmax(0, 1fr); - min-height: 3.25rem; - padding: 0.55rem 0.7rem; -} - - -.message-attachment-file-ext { - align-items: center; - background: rgba(20, 184, 166, 0.95); - border-radius: 999px; - color: #fff; - display: inline-flex; - font-size: 0.72rem; - font-weight: 800; - height: 1.7rem; - justify-content: center; - min-width: 3.25rem; - padding: 0 0.55rem; -} - - -.message-attachment-file-main { - display: grid; - gap: 0.08rem; - min-width: 0; -} - - -.message-attachment-file-main .message-attachment-meta { - color: #cbd5e1; -} - - -.message-attachment-unavailable { - align-items: center; - background: linear-gradient(135deg, rgba(254, 242, 242, 0.96), rgba(255, 247, 237, 0.96)); - color: #7f1d1d; - display: flex; - font-weight: 700; - justify-content: center; - min-height: 12rem; - padding: 1.25rem; - text-align: center; -} - - -.message-attachment-card { - align-items: center; - background: linear-gradient(135deg, rgba(240, 253, 250, 0.95), rgba(239, 246, 255, 0.95)); - border: 1px solid rgba(20, 184, 166, 0.25); - border-radius: 1rem; - color: #0f172a; - display: flex; - gap: 0.7rem; - padding: 0.75rem 0.85rem; - text-decoration: none; -} - - -.message-attachment-icon { - align-items: center; - background: #0f766e; - border-radius: 0.8rem; - color: #fff; - display: inline-flex; - flex: 0 0 auto; - height: 2.1rem; - justify-content: center; - width: 2.1rem; -} - - -.message-attachment-main { - display: grid; - min-width: 0; -} - - -.message-attachment-name { - font-weight: 700; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - - -.message-attachment-meta { - color: #64748b; - font-size: 0.78rem; - overflow-wrap: anywhere; -} - - -@media (max-width: 640px) { -.message-attachment-carousel { - max-width: 100%; - } -.message-attachment-carousel-viewport, -.message-attachment-slide, -.message-attachment-media-frame, -.message-attachment-unavailable { - min-height: 10rem; - } -.message-attachment-media { - height: min(42vh, 16rem); - } -.message-attachment-file-card { - grid-template-columns: auto minmax(0, 1fr); - } -.message-attachment-file-card .message-attachment-download { - grid-column: 1 / -1; - } -} .dm-chat-wrap { @@ -1275,3 +994,401 @@ 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; +} + +/* ===== DM file transfer v2 + voice notes ===== */ +.dm-attachment-stack { + display: grid; + gap: 8px; + min-width: 0; +} + +.dm-send-icon-btn svg { + width: 22px; + height: 22px; + display: block; + margin: auto; +} + +.dm-send-icon-btn.is-recording { + transform: scale(1.04); + box-shadow: 0 0 0 5px rgba(255, 104, 126, 0.08), 0 0 24px rgba(255, 104, 126, 0.18); +} + +.dm-send-icon-btn.is-recording:not(.is-locked) { + color: rgba(255, 190, 202, 0.98); +} + +.dm-send-icon-btn.is-locked { + color: rgba(255, 218, 226, 0.98); + background: rgba(164, 45, 70, 0.28); + border-color: rgba(255, 150, 171, 0.32); +} + +.dm-send-busy-dot { + display: inline-grid; + place-items: center; + width: 100%; + height: 100%; + font-size: 20px; + line-height: 1; +} + +.dm-voice-recording-status { + grid-column: 1; + min-height: 42px; + display: grid; + grid-template-columns: auto auto minmax(0, 1fr); + align-items: center; + gap: 8px; + padding: 0 12px; + border: 1px solid rgba(255, 255, 255, 0.10); + border-radius: 14px; + background: rgba(255, 255, 255, 0.035); + overflow: hidden; +} + +.dm-voice-recording-status[hidden] { + display: none; +} + +.dm-voice-recording-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(255, 104, 126, 0.96); + box-shadow: 0 0 12px rgba(255, 104, 126, 0.55); + animation: dmVoicePulse 1s ease-in-out infinite; +} + +@keyframes dmVoicePulse { + 0%, 100% { opacity: 0.55; transform: scale(0.82); } + 50% { opacity: 1; transform: scale(1); } +} + +.dm-voice-recording-time { + font-variant-numeric: tabular-nums; + font-size: 13px; + font-weight: 720; + color: rgba(255, 230, 235, 0.96); +} + +.dm-voice-recording-hint { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; + font-size: 11px; + color: rgba(210, 222, 241, 0.66); +} + +.dm-chat-input--recording .dm-actions-col { + align-self: center; +} + +.dm-voice-card { + min-width: min(280px, 72vw); + max-width: min(330px, 76vw); + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + align-items: center; + gap: 10px; +} + +.dm-voice-card__play { + width: 42px; + height: 42px; + min-width: 42px; + padding: 0; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 15px; + line-height: 1; + background: rgba(92, 190, 255, 0.12); + border: 1px solid rgba(159, 211, 255, 0.22); +} + +.dm-voice-card__play:disabled { + opacity: 0.65; + cursor: progress; +} + +.dm-voice-card__body { + min-width: 0; + display: grid; + gap: 6px; +} + +.dm-voice-card__track { + position: relative; + width: 100%; + height: 18px; + min-height: 18px; + padding: 0; + border: 0; + background: transparent; + overflow: hidden; +} + +.dm-voice-card__track::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 8px; + height: 2px; + border-radius: 999px; + background: rgba(220, 234, 255, 0.20); +} + +.dm-voice-card__fill { + position: absolute; + left: 0; + top: 7px; + width: 0; + height: 4px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 8px rgba(92, 190, 255, 0.35); + pointer-events: none; +} + +.dm-voice-card__meta { + display: flex; + justify-content: space-between; + gap: 10px; + font-size: 11px; + color: rgba(205, 219, 242, 0.68); + font-variant-numeric: tabular-nums; +} + +@media (max-width: 390px) { + .dm-voice-card { + min-width: min(252px, 76vw); + } + + .dm-voice-recording-hint { + max-width: 150px; + } +} + +/* Пересылка DM: выбор чата и подтверждение остаются внутри одного feature-owned modal. */ +.dm-forward-modal { + align-items: center; + padding: 18px; +} + +.dm-forward-card { + width: min(92vw, 430px); + max-height: min(78vh, 680px); + overflow: hidden; + gap: 12px; + border: 1px solid rgba(104, 193, 255, 0.25); + border-radius: 24px; + background: linear-gradient(155deg, rgba(20, 31, 49, 0.98), rgba(7, 13, 24, 0.98)); + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.52), 0 0 34px rgba(56, 165, 255, 0.12); + backdrop-filter: blur(24px) saturate(130%); + -webkit-backdrop-filter: blur(24px) saturate(130%); +} + +.dm-forward-head, +.dm-forward-confirm-peer { + display: flex; + align-items: center; + gap: 12px; +} + +.dm-forward-head { + justify-content: space-between; +} + +.dm-forward-close { + width: 36px; + height: 36px; + border-radius: 50%; + font-size: 25px; + line-height: 1; + color: rgba(255, 255, 255, 0.78); + background: rgba(255, 255, 255, 0.05); +} + +.dm-forward-preview { + padding: 11px 13px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + color: rgba(242, 247, 255, 0.76); + background: rgba(255, 255, 255, 0.035); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dm-forward-preview--confirm { + white-space: normal; + max-height: 128px; + overflow: auto; +} + +.dm-forward-list { + display: flex; + flex-direction: column; + gap: 4px; + min-height: 120px; + max-height: min(54vh, 460px); + overflow-y: auto; + padding-right: 2px; +} + +.dm-forward-chat-row { + width: 100%; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 11px; + min-height: 62px; + padding: 8px 10px; + border: 0; + border-radius: 15px; + text-align: left; + color: var(--text); + background: transparent; +} + +.dm-forward-chat-row:hover, +.dm-forward-chat-row:focus-visible { + outline: none; + background: rgba(67, 166, 255, 0.11); +} + +.dm-forward-chat-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.dm-forward-chat-copy strong, +.dm-forward-chat-copy span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dm-forward-chat-arrow { + font-size: 28px; + color: rgba(130, 205, 255, 0.7); +} + +.dm-forward-loading { + padding: 20px 8px; + text-align: center; +} + +.dm-forward-confirm-peer { + padding: 12px; + border-radius: 16px; + background: rgba(67, 166, 255, 0.08); +} + +.dm-forward-confirm-actions { + margin-top: 2px; +} diff --git a/shine-UI/styles/features/messages.css b/shine-UI/styles/features/messages.css index 545bbabb..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; @@ -722,3 +723,16 @@ button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); } opacity: 0.72; transform: translateY(-1px); } + + +/* ===== Personal chat row layout: time top-right, unread bottom-right ===== */ +.dm-list-screen .dm-row-time { + grid-row: 1; + align-self: center; +} + +.dm-list-screen .dm-unread-badge, +.dm-list-screen .dm-row-meta-spacer { + grid-row: 2; + align-self: center; +} diff --git a/shine-UI/styles/features/notifications.css b/shine-UI/styles/features/notifications.css index 460a54df..62a8d841 100644 --- a/shine-UI/styles/features/notifications.css +++ b/shine-UI/styles/features/notifications.css @@ -186,14 +186,32 @@ -/* Уведомления: переключатели лент повторяют стеклянные чипы экрана «Связи». */ -.notifications-screen .notification-feed-tabs { +/* Уведомления: переключатели лент занимают место заголовка в закреплённом + * TopBar и повторяют стеклянные чипы экрана «Связи». */ +.notifications-topbar { + grid-template-columns: minmax(0, 1fr); +} + + +.notifications-topbar .topbar__left, +.notifications-topbar .topbar__right { + display: none; +} + + +.notifications-topbar .topbar__center { + width: 100%; +} + + +.notifications-topbar .notification-feed-tabs { + width: 100%; display: flex; align-items: center; justify-content: center; - flex-wrap: wrap; + flex-wrap: nowrap; gap: 8px; - padding: 8px 12px 4px; + padding: 0; margin: 0; background: transparent; border: 0; @@ -201,25 +219,22 @@ } -.notifications-screen .notification-feed-tabs .notification-tab-btn { - min-width: 92px; +.notifications-topbar .notification-feed-tabs .notification-tab-btn { + flex: 1 1 0; + min-width: 0; + padding-inline: 8px; text-align: center; } -/* На обычных экранах отступ создаёт стандартный padding screen-content (14px). */ -.notifications-screen .notification-feed-tabs.app-top-tabs { +/* Высота панели остаётся стандартной: перенос фильтров не увеличивает TopBar. */ +.notifications-topbar .notification-feed-tabs.app-top-tabs { min-height: var(--app-primary-tab-min-height); padding: 0; margin: 0; } -.notifications-screen .notification-feed-tabs .notification-tab-btn { - min-width: 92px; -} - - /* Уведомления: информационные блоки без рамок. Hover/focus не возвращает * обводку — различение сохраняется фоном и лёгкой реакцией на нажатие. */ .notifications-screen .notifications-list > .notification-card, diff --git a/shine-UI/styles/features/profile.css b/shine-UI/styles/features/profile.css index 8a470721..96f3e323 100644 --- a/shine-UI/styles/features/profile.css +++ b/shine-UI/styles/features/profile.css @@ -799,8 +799,26 @@ .user-profile-screen .user-profile-hero-avatar.avatar-glow::before { - inset: -12%; - opacity: 0.5; + inset: -7%; + border: 2px solid rgba(145, 239, 255, 0.94); + background: transparent; + box-shadow: + 0 0 3px rgba(155, 244, 255, 0.96), + 0 0 9px rgba(112, 222, 255, 0.74), + 0 0 17px rgba(86, 190, 255, 0.38), + inset 0 0 4px rgba(176, 248, 255, 0.72); + filter: none; + transform: none; + opacity: 1; +} + + +/* На крупном аватаре профиля официальный знак на 20% компактнее базового. + * Левый и нижний края остаются на месте, поэтому сокращение приходится на + * верхнюю и правую стороны знака. */ +.user-profile-screen .user-profile-hero-avatar.avatar-framed > .avatar-official-badge { + width: 25.6%; + height: 25.6%; } @@ -1805,3 +1823,165 @@ .profiles-actions { margin-top: 4px; } .profiles-close-all { margin-top: 4px; } + +/* ===== 2026-09-09: profile layout — identity, social row, about card ===== */ +.user-profile-identity { + width: min(88%, 340px); + margin: 12px auto 0; + text-align: center; +} + +.user-profile-identity .user-profile-full-name { + width: 100%; + margin: 0; + color: rgba(244, 248, 255, 0.96); + font-size: clamp(17px, 4.8vw, 20px); + font-weight: 700; + line-height: 1.2; +} + +.user-profile-login { + margin-top: 4px; + color: rgba(199, 211, 229, 0.62); + font-size: 12px; + font-weight: 500; + line-height: 1.2; + overflow-wrap: anywhere; +} + +.user-profile-social-metrics { + width: min(92%, 360px); + margin: 22px auto 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: start; + justify-items: center; + gap: 4px; +} + +.user-profile-social-metrics .user-profile-metric { + width: 100%; + min-height: 58px; + gap: 5px; + justify-content: flex-start; +} + +.user-profile-social-metrics .user-profile-metric-circle, +.user-profile-metric-value-combined { + width: auto; + min-width: 0; + height: auto; + min-height: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: rgba(240, 246, 255, 0.95); + font-size: clamp(17px, 4.7vw, 20px); + font-weight: 800; + line-height: 1.1; + white-space: nowrap; +} + +.user-profile-social-metrics .user-profile-metric-label { + position: static; + width: 100%; + min-height: 0; + transform: none; + color: rgba(203, 216, 236, 0.72); + font-size: 11px; + line-height: 1.15; + text-align: center; +} + +.user-profile-about-card { + width: min(88%, 344px); + margin: 20px auto 0; +} + +.user-profile-about-title { + margin: 0 0 7px 3px; + color: rgba(224, 233, 247, 0.82); + font-size: 12px; + font-weight: 650; + line-height: 1.2; +} + +.user-profile-about-field { + min-height: 68px; + padding: 13px 14px; + border: 1px solid rgba(210, 220, 234, 0.14); + border-radius: 14px; + background: rgba(162, 172, 186, 0.16); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + color: rgba(236, 242, 251, 0.9); + font-size: 13px; + line-height: 1.5; + text-align: left; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.user-profile-about-field.is-empty { + color: rgba(199, 210, 226, 0.46); +} + +.user-profile-detail-links.user-profile-detail-links--below-about { + width: min(88%, 344px); + margin: 10px auto 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: center; + gap: 10px; +} + +.user-profile-detail-links--below-about > :first-child { + justify-self: start; +} + +.user-profile-detail-links--below-about > :last-child { + justify-self: end; +} + +.user-profile-detail-links--below-about button { + width: auto; + min-width: 0; + padding-left: 6px; + padding-right: 6px; +} + +.profile-list-tabs { + width: min(92%, 360px); + margin: 4px auto 10px; + padding: 3px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + border: 1px solid rgba(205, 221, 244, 0.12); + border-radius: 14px; + background: rgba(14, 25, 43, 0.24); +} + +.profile-list-tab { + min-height: 36px; + padding: 7px 10px; + border: 0; + border-radius: 11px; + background: transparent; + color: rgba(207, 219, 237, 0.72); + font: inherit; + font-size: 12px; + font-weight: 650; + cursor: pointer; +} + +.profile-list-tab.is-active { + background: rgba(156, 177, 204, 0.18); + color: rgba(246, 249, 255, 0.97); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +@media (max-height: 700px) { + .user-profile-identity { margin-top: 8px; } + .user-profile-social-metrics { margin-top: 16px; } + .user-profile-about-card { margin-top: 15px; } +} 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; +} diff --git a/shine-UI/styles/main.css b/shine-UI/styles/main.css index e88b4c5f..b8f967ba 100644 --- a/shine-UI/styles/main.css +++ b/shine-UI/styles/main.css @@ -1,27 +1,59 @@ :root { - --bg-0: #050c1a; - --bg-1: #0a1630; - --bg-2: #132346; - --card: #162646; - --card-soft: #1a2f55; - --line: #2f4777; - --text: #edf2ff; - --text-muted: #9eb0d8; - --accent: #d9b56f; - --accent-soft: rgba(217, 181, 111, 0.18); - --danger: #ff718f; - --ok: #84f4a1; + --background: #101b20; + --surface: #17272d; + --surface-selected: #213b3a; + --text-primary: #e8f2f1; + --text-secondary: #98adaf; + --border-subtle: #2a3b40; + --border-control: #71888a; + --accent: #94e1ce; + --on-accent: #102c27; + --focus-ring: #94e1ce; + --reaction-active: #f29aab; + --danger: #ffaba8; + --success: #94e1ce; + --warning: #e6c184; + --scrim: #00000099; + --bg-0: var(--background); + --bg-1: var(--surface); + --bg-2: var(--surface-selected); + --card: var(--surface); + --card-soft: var(--surface-selected); + --line: var(--border-subtle); + --text: var(--text-primary); + --text-muted: var(--text-secondary); + --accent-soft: color-mix(in srgb, var(--accent) 16%, transparent); + --ok: var(--success); /* Единый цвет основных действий: стартовые/регистрационные кнопки, * настройки и scroll-down. */ --shine-action-blue: #1248a3; --shine-action-blue-hover: #1b62cf; --shine-action-blue-pressed: #0d3577; --shine-action-blue-rgb: 18, 72, 163; - --radius-lg: 18px; + --radius-lg: 14px; --radius-md: 12px; --radius-sm: 9px; - --shadow: 0 20px 40px rgba(0, 0, 0, 0.35); - --font-main: "Manrope", "IBM Plex Sans", "Segoe UI", sans-serif; + --shadow: 0 16px 42px rgba(0, 0, 0, 0.26); + --font-main: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +:root[data-theme='light'] { + --background: #faf7f0; + --surface: #fffdf8; + --surface-selected: #efe6d6; + --text-primary: #302c25; + --text-secondary: #706658; + --border-subtle: #e6dfd1; + --border-control: #978b79; + --accent: #93511e; + --on-accent: #fffaf3; + --focus-ring: #93511e; + --reaction-active: #a43350; + --danger: #b13135; + --success: #346a48; + --warning: #845b14; + --scrim: #302c2566; + --shadow: 0 16px 42px rgba(65, 49, 28, 0.14); } * { @@ -37,10 +69,7 @@ body { min-height: 100%; overflow: hidden; overscroll-behavior: none; - background: - radial-gradient(circle at 12% -8%, rgba(214, 176, 90, 0.24), transparent 35%), - radial-gradient(circle at 84% 4%, rgba(43, 78, 148, 0.42), transparent 38%), - linear-gradient(180deg, #050b18, #030812 70%) fixed; + background: var(--background); color: var(--text); font-family: var(--font-main); } @@ -72,4 +101,3 @@ a { - diff --git a/shine-UI/styles/network-graph.css b/shine-UI/styles/network-graph.css index 675818bb..6ca65f7d 100644 --- a/shine-UI/styles/network-graph.css +++ b/shine-UI/styles/network-graph.css @@ -200,6 +200,31 @@ background: #454b55; color: #ffffff; font-weight: 600; font-size: 20px; } + +/* Маленький знак официального пользователя. Он находится внутри .node-dot, поэтому автоматически + масштабируется вместе с аватаркой при focus/zoom/анимациях графа. */ +/* ВАЖНО: селектор намеренно специфичнее глобального `.node-dot img` из features/network.css, + где обычные фото стартуют с opacity:0 и width/height:100%. Иначе badge тоже наследует эти + правила и становится полностью невидимым. */ +.fg-node .node-dot .fg-official-badge { + position: absolute; + left: -2%; + bottom: -1%; + width: 32%; + height: 32%; + min-width: 18px; + min-height: 18px; + object-fit: contain; + object-position: center; + display: block; + opacity: 1; + border-radius: 0; + transition: none; + z-index: 20; + pointer-events: none; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.42)); +} + .fg-node.is-family .node-dot { background: linear-gradient(165deg, #785038, #5f3e2c); border-color: rgba(255, 194, 143, 0.6); @@ -241,15 +266,17 @@ .fg-node.is-pressed .node-dot { transform: none; } } -/* «Сияние» — постоянное живое свечение НА УЗЛЕ (аватарке), а не на линии связи. - Пульсация остаётся мягкой, но нижняя точка теперь не проваливается почти в ноль: - визуально сияющий пользователь всегда остаётся явно сияющим. */ +/* «Сияние» на аватарке теперь стационарное. Интенсивность зафиксирована примерно на 2/3 пути + от прежнего минимального состояния к максимальному: заметно, но без постоянного «дыхания». */ .fg-node.is-shine .node-dot { border-color: rgba(150, 240, 255, 0.62); - animation: fg-shine-glow 3.6s ease-in-out infinite; + box-shadow: + 0 0 8.3px rgba(146, 243, 255, 0.57), + 0 0 18.3px rgba(121, 235, 255, 0.39), + 0 0 33px rgba(100, 220, 255, 0.24); } -/* размытый радиальный ореол позади аватарки; внешний SVG-фильтр даёт мягкое гауссово размытие */ +/* Статичный размытый ореол позади аватарки — та же промежуточная интенсивность ~2/3. */ .fg-node.is-shine .node-dot::before { content: ''; position: absolute; @@ -259,34 +286,8 @@ filter: url(#fg-shine-glow); z-index: -1; pointer-events: none; - animation: fg-shine-halo 3.6s ease-in-out infinite; -} - -/* пульсация многослойной тени: компактное приглушённое → широкое мягкое свечение */ -@keyframes fg-shine-glow { - 0%, 100% { - box-shadow: - 0 0 7px rgba(138, 239, 255, 0.48), - 0 0 15px rgba(118, 232, 255, 0.32), - 0 0 27px rgba(100, 220, 255, 0.19); - } - 50% { - box-shadow: - 0 0 9px rgba(150, 245, 255, 0.62), - 0 0 20px rgba(122, 236, 255, 0.42), - 0 0 36px rgba(100, 220, 255, 0.26); - } -} - -/* ореол дышит размером и прозрачностью синхронно с тенью (мягко, без рывков) */ -@keyframes fg-shine-halo { - 0%, 100% { transform: scale(0.98); opacity: 0.72; } - 50% { transform: scale(1.12); opacity: 1; } -} - -@media (prefers-reduced-motion: reduce) { - .fg-node.is-shine .node-dot { animation: none; } - .fg-node.is-shine .node-dot::before { animation: none; } + transform: scale(1.07); + opacity: 0.91; } /* мягкое свечение вокруг фокуса (статичное; «дышит» вместе с размером узла ниже) */ @@ -458,6 +459,20 @@ box-shadow: inset 0 0.5px 0 rgba(255, 255, 255, 0.12), 0 0 14px rgba(110, 210, 255, 0.28); } +/* Служебные переключатели карты: история и X2 намеренно чуть компактнее основных фильтров. */ +.fg-history-chip { + padding-left: 11px; + padding-right: 11px; +} + +.fg-x2-chip { + min-width: 36px; + padding-left: 9px; + padding-right: 9px; + font-weight: 750; + letter-spacing: 0.02em; +} + /* Контекстное меню узла (долгое нажатие) — в #modal-root, поверх всего, не масштабируется */ .fg-menu-overlay { position: fixed; @@ -524,37 +539,11 @@ background: transparent; } -/* === Партия 2: бейдж-счётчик связей, поиск, хлебные крошки, цветовые кластеры ============ */ - -/* Бейдж числа связей — маленькая пилюля в правом-верхнем углу аватарки */ -.fg-node-badge { - position: absolute; - top: -2px; - right: -2px; - min-width: 16px; - height: 16px; - padding: 0 4px; - border-radius: 999px; - background: rgba(16, 24, 40, 0.92); - border: 1px solid rgba(150, 200, 255, 0.5); - color: #d9ecff; - font-size: 9px; - font-weight: 700; - line-height: 14px; - text-align: center; - pointer-events: none; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); -} -.fg-node.is-focus .fg-node-badge { - background: rgba(61, 196, 223, 0.95); - border-color: rgba(220, 245, 255, 0.8); - color: #06131c; -} -.fg-node.is-tier2 .fg-node-badge { transform: scale(0.85); } +/* === Партия 2: поиск, хлебные крошки, цветовые кластеры ============================== */ /* Кластерная аура по категории удалена (цветной фон/обводка узла убраны). Сияющим/фокусу box-shadow не навязываем — у них свой эффект свечения (is-shine/is-focus выше). */ -.fg-node.is-shine .node-dot, .fg-node.is-focus .node-dot { box-shadow: none; } +.fg-node.is-focus:not(.is-shine) .node-dot { box-shadow: none; } /* Строка поиска (оверлей вверху, под панелью фильтров) */ .fg-search { diff --git a/shine-solana-arweave-viewer-v2/README.md b/shine-solana-arweave-viewer-v2/README.md new file mode 100644 index 00000000..48c54e9c --- /dev/null +++ b/shine-solana-arweave-viewer-v2/README.md @@ -0,0 +1,45 @@ +# SHiNE Arweave Viewer v2 — ANS-104 / Frame v1 + +Новая версия viewer для формата, где каждый пользовательский блок хранится в Arweave как отдельный ANS-104 DataItem, а `data` DataItem содержит один `SHiNE Frame v1`. + +Основной файл: `index.html`. + +## Быстрый запуск + +Через логин (ключ берётся из Solana PDA): + +```text +index.html?login=aidarkc&channel=books +``` + +Напрямую через blockchain public key, без Solana: + +```text +index.html?key=&channel=books +``` + +Дополнительные параметры: + +```text +&arweaveGateway=https://arweave.net +&solanaRpc=https://api.mainnet-beta.solana.com +``` + +`channel` можно не передавать. Тогда viewer ищет все DataItem пользователя с `App=test5590` и строит список каналов из найденных блоков. + +## Что делает viewer + +1. Если передан `login`, вычисляет PDA `user_login=` программы `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` и читает `blockchainKey`. +2. Если передан `key`, использует его напрямую и не обращается к Solana. +3. Из Ed25519/Solana public key вычисляет Arweave owner address как `base64url(SHA-256(pubkey32))`. +4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c_test5590=`. +5. Загружает payload каждого DataItem через gateway `/`. +6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes). +7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1. +8. Восстанавливает канал/сообщения тем же body parser, который использовал старый viewer. + +## Важно + +При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c_test5590`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально. + +Старый `shine-solana-arweave-viewer/` не изменяется; новая версия лежит отдельно в `shine-solana-arweave-viewer-v2/`. diff --git a/shine-solana-arweave-viewer-v2/history/v1/README.md b/shine-solana-arweave-viewer-v2/history/v1/README.md new file mode 100644 index 00000000..01f0fcab --- /dev/null +++ b/shine-solana-arweave-viewer-v2/history/v1/README.md @@ -0,0 +1,11 @@ +# SHiNE Solana Arweave Viewer + +Основной рабочий файл: + +- `index.html` + +Подробное описание работы, параметров и готовых ссылок: + +- `КАК_ЭТО_РАБОТАЕТ.md` + +Viewer собран как автономный single-file HTML и запускается одним файлом без соседних зависимостей. diff --git a/shine-solana-arweave-viewer-v2/history/v1/index.html b/shine-solana-arweave-viewer-v2/history/v1/index.html new file mode 100644 index 00000000..72484911 --- /dev/null +++ b/shine-solana-arweave-viewer-v2/history/v1/index.html @@ -0,0 +1,2358 @@ + + + + + + SHiNE Solana Arweave Viewer + + + + + +
+
+
SHiNE prototype viewer
+

SHiNE Solana Arweave Viewer

+

+ Статическая страница для прямого чтения shine_users из Solana, + с настройками RPC и gateway, которые можно передавать через URL и сохранять в браузере. +

+
+ +
+
+
+

Настройки доступа

+

Параметры можно сохранить локально и переиспользовать при следующем открытии.

+
+ +
+ + + + + + + + + + + + +
+ Быстрые действия +
+ + + +
+
+ +
+ Проверка доступности +
+ + + + +
+
+
+ +
Готово к проверке и загрузке.
+
+ +
+
+

Текущее состояние

+

Короткая сводка по найденной записи пользователя.

+
+
+
+ Логин + — +
+
+ PDA + — +
+
+ Версия записи + — +
+
+ Блокчейн + — +
+
+ Arweave TX + — +
+
+ Последний блок + — +
+
+
+ +
+
+

Полезные ссылки

+

Ссылки формируются из текущих настроек и содержимого PDA.

+
+ +
+ +
+
+

Содержимое PDA

+

Распарсенные данные записи пользователя из программы shine_users.

+
+
Пока ничего не загружено.
+
+ +
+
+

Содержимое блокчейна

+

Viewer скачивает закреплённый блокчейн пользователя из Arweave и локально собирает каналы без сервера SHiNE.

+
+
Сначала загрузите PDA пользователя, затем нажмите «Показать содержимое».
+ +
+
+
+ + diff --git a/shine-solana-arweave-viewer-v2/history/v1/КАК_ЭТО_РАБОТАЕТ.md b/shine-solana-arweave-viewer-v2/history/v1/КАК_ЭТО_РАБОТАЕТ.md new file mode 100644 index 00000000..0fb69106 --- /dev/null +++ b/shine-solana-arweave-viewer-v2/history/v1/КАК_ЭТО_РАБОТАЕТ.md @@ -0,0 +1,114 @@ +# SHiNE Solana Arweave Viewer: как это работает + +## Что лежит в папке + +Основной и единственный рабочий файл viewer: + +- `index.html` + +Это автономный single-file viewer: + +- весь HTML внутри одного файла; +- все стили внутри этого же файла; +- весь JavaScript внутри этого же файла; +- локальная копия `solana-web3` тоже встроена внутрь этого же файла. + +Для запуска viewer достаточно открыть один `index.html`. + +## Что делает viewer + +Viewer открывается как обычная HTML-страница и работает полностью в браузере. + +Он умеет: + +- принимать логин пользователя SHiNE; +- принимать `Solana RPC` через форму или через URL-параметр; +- принимать `Arweave gateway` через форму или через URL-параметр; +- сохранять эти значения в `localStorage`; +- вычислять `user_pda` по логину для программы `shine_users`; +- читать `PDA` напрямую из `Solana`; +- распарсивать содержимое записи `shine_users`; +- показывать ключи, лимиты, номер записи, блокчейн пользователя и `Arweave tx id`; +- строить прямую ссылку на `Arweave`, если `tx id` есть в записи. + +## Какие URL-параметры поддерживаются + +Viewer понимает такие параметры: + +- `login` +- `solanaRpc` +- `arweaveGateway` + +Пример: + +```text +index.html?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com&arweaveGateway=https%3A%2F%2Farweave.net +``` + +## Предустановленные адреса + +### Solana RPC + +- `https://solana-rpc.publicnode.com` +- `https://public.rpc.solanavibestation.com/` + +### Arweave gateway + +- `https://arweave.net` + +## Почему одна из RPC может не работать + +Адрес: + +- `https://public.rpc.solanavibestation.com/` + +может возвращать браузерную ошибку `403` с текстом про `Origin is not whitelisted`. + +Это означает, что проблема обычно не в SHiNE viewer, а в CORS-политике самого RPC. +Для HTML-страницы, открытой прямо в браузере, это обычно не лечится со стороны клиента. + +Практически для viewer лучше использовать: + +- `https://solana-rpc.publicnode.com` + +## Ссылки на загруженный файл в Arweave + +Основная ссылка: + +- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI` + +Та же ссылка с тестовым логином `aidartest`: + +- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest` + +Та же ссылка с тестовым логином `aidartest` и явным рабочим RPC: + +- `https://isvg77xrihxyzqnjex3j4uldyao7xbsfda4uhoydtigeugovjvza.arweave.net/RKpv_vFB74zBqSX2nlFjwB37hkUYOUO7A5oMShnVTXI?login=aidartest&solanaRpc=https%3A%2F%2Fsolana-rpc.publicnode.com` + +## Что будет, если поставить devnet RPC + +Официальный публичный `Solana devnet RPC`: + +- `https://api.devnet.solana.com` + +Если подставить `devnet` RPC, viewer начнёт читать именно сеть `devnet`, а не `mainnet`. + +Но это не означает автоматически, что вся система SHiNE "стала тестовой". +Для реальной работы в `devnet` должны совпасть сразу несколько условий: + +- программа `shine_users` должна быть задеплоена в `devnet`; +- в `devnet` должен существовать нужный `PDA` для логина; +- формат записи должен совпадать; +- данные пользователя должны реально лежать в этой тестовой сети. + +Если этого нет, viewer просто не найдёт запись. + +## Источник по devnet RPC + +Официальная документация Solana: + +- `https://solana.com/docs/references/clusters` + +Там указан devnet endpoint: + +- `https://api.devnet.solana.com` diff --git a/shine-solana-arweave-viewer-v2/index.html b/shine-solana-arweave-viewer-v2/index.html new file mode 100644 index 00000000..42f16135 --- /dev/null +++ b/shine-solana-arweave-viewer-v2/index.html @@ -0,0 +1,3231 @@ + + + + + + SHiNE Arweave Viewer v2 — Frame v1 + + + + + +
+
+ + + + + +
+ +
+ + +
+ +
Готово к загрузке.
+ +
+
+
Viewer читает новый формат Arweave: ?login=...&channel=... или ?key=...&channel=...
+
+ + +
+
+ + diff --git a/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md b/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md new file mode 100644 index 00000000..26b2ecaa --- /dev/null +++ b/shine-solana-arweave-viewer-v2/КАК_ЭТО_РАБОТАЕТ.md @@ -0,0 +1,39 @@ +# Как работает Viewer v2 + +Viewer рассчитан на новый архив SHiNE: **один пользовательский блок = один ANS-104 DataItem**. + +### Режим 1: login + channel + +`?login=&channel=` + +Viewer читает Solana PDA пользователя, получает `blockchainKey`, затем ищет Arweave DataItem этого владельца с тегами `App=test5590` и `c_test5590=`. + +### Режим 2: key + channel + +`?key=&channel=` + +Viewer пропускает Solana и сразу выполняет поиск в Arweave. + +### Режим без channel + +`?login=` или `?key=` + +Viewer получает все доступные DataItem владельца с `App=test5590`, разбирает их и показывает восстановленные каналы. + +### Новый блок + +Payload DataItem: + +```text +uint16be frameCode = 0x0001 +bytes[32] prevHash = SHA-256(previous Frame v1) +int32be blockSize +int32be blockNumber +int64be timestamp seconds +uint16be type +uint16be subType +uint16be version +bytes[N] body +``` + +Подпись пользователя, owner и tags находятся в оболочке ANS-104 DataItem, а не в SHiNE Frame v1. diff --git a/shine-solana-arweave-viewer-v3/README.md b/shine-solana-arweave-viewer-v3/README.md new file mode 100644 index 00000000..4e9c5523 --- /dev/null +++ b/shine-solana-arweave-viewer-v3/README.md @@ -0,0 +1,57 @@ +# SHiNE Arweave Viewer v2 — ANS-104 / Frame v1 + +Новая версия viewer для формата, где каждый пользовательский блок хранится в Arweave как отдельный ANS-104 DataItem, а `data` DataItem содержит один `SHiNE Frame v1`. + +Основной файл: `index.html`. + +## Быстрый запуск + +Через логин (ключ берётся из Solana PDA): + +```text +index.html?login=aidarkc&channel=books +``` + +Напрямую через blockchain public key, без Solana: + +```text +index.html?key=&channel=books +``` + +Дополнительные параметры: + +```text +&arweaveGateway=https://arweave.net +&solanaRpc=https://api.mainnet-beta.solana.com +``` + +`channel` можно не передавать. Тогда viewer ищет все DataItem пользователя с `App=test5590` и строит список каналов из найденных блоков. + +## Что делает viewer + +1. Если передан `login`, вычисляет PDA `user_login=` программы `SHiNEPr1APdAgNBteUyBXcNovaHctpSjUu8oH2ZJdN6` и читает `blockchainKey`. +2. Если передан `key`, использует его напрямую и не обращается к Solana. +3. Из Ed25519/Solana public key вычисляет Arweave owner address как `base64url(SHA-256(pubkey32))`. +4. Делает Arweave GraphQL запрос по owner + `App=test5590`; при наличии `channel` добавляет тег `c_test5590=`. +5. Загружает payload каждого DataItem через gateway `/`. +6. Разбирает payload как SHiNE Frame v1 (`frameCode=0x0001`, header 56 bytes). +7. Дедуплицирует повторно опубликованные одинаковые пользовательские блоки по SHA-256 Frame v1. +8. Восстанавливает канал/сообщения тем же body parser, который использовал старый viewer. + +## Важно + +При фильтрации по конкретному `channel` viewer получает только блоки с соответствующим тегом `c_test5590`. Поэтому он не заявляет полную проверку глобальной `prevHash`-цепочки пользователя: между двумя блоками одного канала могут существовать блоки других каналов. SHA-256 каждого загруженного Frame вычисляется локально. + +Старый `shine-solana-arweave-viewer/` не изменяется; новая версия лежит отдельно в `shine-solana-arweave-viewer-v2/`. + + +## Проверка данных (v3.3) +Нижняя строка показывает краткий итог доступных проверок. Нажатие на неё или `⋮ → Проверка данных` открывает отдельный экран: +- локально вычисленный SHA-256 каждого SHiNE Frame; +- `prevHash` общей цепочки, когда предыдущий глобальный блок загружен; +- `prevLineHash` канальной цепочки, когда указанный предыдущий блок загружен; +- наличие DataItem в выборке Arweave owner соответствующего blockchain key; +- разрывы «нет данных» отдельно от доказанных несовпадений; +- честный статус ANS-104 signature: gateway payload/GraphQL не содержит сырой подписи, поэтому она не помечается как локально перепроверенная. + +`○ нет данных` не является ошибкой. `⚠` появляется только при фактическом несовпадении доступных хэшей. diff --git a/shine-solana-arweave-viewer-v3/README_V3.md b/shine-solana-arweave-viewer-v3/README_V3.md new file mode 100644 index 00000000..5511797a --- /dev/null +++ b/shine-solana-arweave-viewer-v3/README_V3.md @@ -0,0 +1,26 @@ +# SHiNE Arweave Viewer v3 + +Viewer нового per-action Arweave формата SHiNE. + +## URL +- `?login=&channel=` — получает blockchain key через Solana PDA, затем читает Arweave. +- `?key=&channel=` — Arweave запускается напрямую; Solana не является блокирующей зависимостью. +- без параметров — стартовая форма (логин или blockchain key + необязательный канал). + +## v3 +- переключатель `Канал / Блоки`; +- рекурсивные комментарии скрыты до раскрытия, на любой глубине; +- light/dark тема; +- меню `⋮`: кэш, Solana RPC, Arweave gateway; +- выключение кэша удаляет сохранённые данные каналов; +- одна нижняя status bar, по нажатию — подробный журнал; +- GraphQL читает от новых Arweave записей к старым (`HEIGHT_DESC`); +- до 6 параллельных загрузок Frame; +- отображение известных типов и raw-представление любых неизвестных блоков в режиме `Блоки`; +- лайки намеренно не отображаются. + +Кэш использует browser localStorage и сериализует BigInt-поля Frame явно. Тема/RPC/gateway являются настройками и не удаляются при отключении кэша. + +## V3.1 — streaming reverse loading + +Arweave history is now processed page-by-page in `HEIGHT_DESC` order. The first GraphQL page (up to 100 DataItems) is downloaded, parsed and rendered immediately. Older pages are then fetched and merged in the background. The bottom status line shows the currently available block range while history expands toward the beginning. Each completed page is also persisted when cache is enabled. diff --git a/shine-solana-arweave-viewer-v3/history/v1/README.md b/shine-solana-arweave-viewer-v3/history/v1/README.md new file mode 100644 index 00000000..01f0fcab --- /dev/null +++ b/shine-solana-arweave-viewer-v3/history/v1/README.md @@ -0,0 +1,11 @@ +# SHiNE Solana Arweave Viewer + +Основной рабочий файл: + +- `index.html` + +Подробное описание работы, параметров и готовых ссылок: + +- `КАК_ЭТО_РАБОТАЕТ.md` + +Viewer собран как автономный single-file HTML и запускается одним файлом без соседних зависимостей. diff --git a/shine-solana-arweave-viewer-v3/history/v1/index.html b/shine-solana-arweave-viewer-v3/history/v1/index.html new file mode 100644 index 00000000..72484911 --- /dev/null +++ b/shine-solana-arweave-viewer-v3/history/v1/index.html @@ -0,0 +1,2358 @@ + + + + + + SHiNE Solana Arweave Viewer + + + + + +
+
+
SHiNE prototype viewer
+

SHiNE Solana Arweave Viewer

+

+ Статическая страница для прямого чтения shine_users из Solana, + с настройками RPC и gateway, которые можно передавать через URL и сохранять в браузере. +

+
+ +
+
+
+

Настройки доступа

+

Параметры можно сохранить локально и переиспользовать при следующем открытии.

+
+ +
+ + + + + + + + + + + + +
+ Быстрые действия +
+ + + +
+
+ +
+ Проверка доступности +
+ + + + +
+
+
+ +
Готово к проверке и загрузке.
+
+ +
+
+

Текущее состояние

+

Короткая сводка по найденной записи пользователя.

+
+
+
+ Логин + — +
+
+ PDA + — +
+
+ Версия записи + — +
+
+ Блокчейн + — +
+
+ Arweave TX + — +
+
+ Последний блок + — +
+
+
+ +
+
+

Полезные ссылки

+

Ссылки формируются из текущих настроек и содержимого PDA.

+
+ +
+ +
+
+

Содержимое PDA

+

Распарсенные данные записи пользователя из программы shine_users.

+
+
Пока ничего не загружено.
+
+ +
+
+

Содержимое блокчейна

+

Viewer скачивает закреплённый блокчейн пользователя из Arweave и локально собирает каналы без сервера SHiNE.

+
+
Сначала загрузите PDA пользователя, затем нажмите «Показать содержимое».
+ +
+
+
+ + diff --git a/shine-solana-arweave-viewer-v3/index.html b/shine-solana-arweave-viewer-v3/index.html new file mode 100644 index 00000000..9d1a5efc --- /dev/null +++ b/shine-solana-arweave-viewer-v3/index.html @@ -0,0 +1,3306 @@ + + + + + + SHiNE Arweave Viewer v3 + + + + + +
+ +
SHiNE Viewer
+
+
Готово
+ + diff --git a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md index 877a827f..ac2e64c0 100644 --- a/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md +++ b/shine-solana/shine/doc/formats/shine-user-pda-format-v.1.0.md @@ -94,16 +94,17 @@ UserPdaRecordV1 | `40` | `AccessServersBlock` | Серверы доступа/relay. | | `50` | `SessionsBlock` | Опубликованные пользовательские сессии и homeserver-ы. | | `70` | `TrustedStateBlock` | Счетчик trusted-связей. | +| `100` | `ArchiveHeadBlock` | Текущая голова серверного SHINE-ARCHIVE: Arweave TX ID + SHA-256 архива. | | `255` | `ReservedBlock` | Зарезервировано, пока не используется. | Правила: - неизвестный `block_type` в `format_major = 1` считается ошибкой; - обязательные блоки: `RecoveryKeyBlock`, `RootKeyBlock`, `ClientKeyBlock`, `BlockchainRegistryBlock`; -- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`; +- необязательные блоки: `ServerProfileBlock`, `AccessServersBlock`, `SessionsBlock`, `TrustedStateBlock`, `ArchiveHeadBlock`; - каждый обязательный блок должен встречаться ровно один раз; - порядок блоков в записи фиксируется для простоты проверки: - `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`. + `RecoveryKey`, `RootKey`, `ClientKey`, `BlockchainRegistry`, `ServerProfile`, `AccessServers`, `Sessions`, `TrustedState`, `ArchiveHead`. ## 6. RecoveryKeyBlock @@ -359,6 +360,28 @@ TrustedStateBlock Пока блок с доверенными лицами не реализуется, потому что полный формат trusted-логики еще не составлен. В будущем trusted-связи, очереди, таймеры и подтверждения должны быть вынесены в отдельный формат. +## 15.1. ArchiveHeadBlock + +Необязательный блок текущей головы серверного архива. Он используется archive-capable сервером и хранится в том же User PDA. + +```text +ArchiveHeadBlock +- block_type: u8 = 100 +- block_version: u8 = 0 +- archive_tx_id: [u8; 32] +- archive_hash: [u8; 32] +``` + +Семантика: + +- `archive_tx_id` — raw 32-byte Arweave transaction id последнего опубликованного большого `SHINE-ARCHIVE`; текстовая Base64URL-форма получается вне PDA; +- `archive_hash` — SHA-256 большого archive block по правилам `docs/Archive/01_PROTOCOL_v1.0.md`; +- отсутствие block `100` означает, что аккаунт ещё не объявлял archive head; +- обычный legacy `update_user_pda`, в instruction которого archive extension отсутствует, **обязан сохранить существующий ArchiveHeadBlock без изменений**; +- расширенный `update_user_pda` может заменить archive head или явно очистить его; отдельной Solana instruction для архива нет. + +`ArchiveHeadBlock` входит в unsigned bytes User PDA и тем самым покрывается обычной root-подписью записи. + ## 16. Подпись user_pda Подписывается не вся PDA целиком, а unsigned-часть записи: @@ -392,6 +415,7 @@ Solana-программа проверяет подпись через встр - обязательные блоки присутствуют; - создается минимум один `BlockchainRecord`; - новый `SessionsBlock` может присутствовать, но при обычной регистрации сейчас записывается пустой список с `sessions_mode = 1`; +- `ArchiveHeadBlock` при регистрации не обязателен; обычный пользователь/сервер может начать публиковать архив позже; - стартовый `paid_limit_bytes` равен стартовому бонусу плюс оплаченный дополнительный лимит; - `used_bytes <= paid_limit_bytes`; - пользователь платит регистрационную комиссию; @@ -408,6 +432,7 @@ Solana-программа проверяет подпись через встр - `prev_record_hash` равен хэшу unsigned-части предыдущей записи; - `updated_at_ms` обновляется; - unsigned-часть новой записи подписана `root_key`; +- если archive extension в instruction отсутствует (legacy client), старый `ArchiveHeadBlock` сохраняется; если extension присутствует, применяется переданное `archive_head_update`; - лимиты блокчейнов могут только увеличиваться; - занятый размер и номер последнего блока не могут уменьшаться; - при увеличении оплаченного лимита пользователь доплачивает комиссию; diff --git a/shine-solana/shine/doc/programs/shine_users.md b/shine-solana/shine/doc/programs/shine_users.md index f65b07a9..aaea7bbd 100644 --- a/shine-solana/shine/doc/programs/shine_users.md +++ b/shine-solana/shine/doc/programs/shine_users.md @@ -753,3 +753,23 @@ signature = Ed25519(blockchain_private_key, message_hash) - Anchor discriminator'ы и Anchor-ABI инструкций; - старые seed'ы, которые конфликтовали с уже существующим Anchor-состоянием в devnet; - внутренние helper-функции старой реализации. + +## ArchiveHeadBlock и серверный SHINE-ARCHIVE + +Формат User PDA поддерживает необязательный `ArchiveHeadBlock` (`block_type = 100`, `block_version = 0`): + +```text +archive_tx_id [32] +archive_hash [32] +``` + +Он хранит текущую голову архива конкретного SHiNE-аккаунта: raw Arweave TX ID и SHA-256 соответствующего большого `SHINE-ARCHIVE`. Подробный бинарный формат и серверный workflow находятся в `docs/Archive/01_PROTOCOL_v1.0.md`. + +Отдельной инструкции программы для архива нет. Используется существующий `update_user_pda`. Парсер update instruction обратно совместим: + +- legacy payload без archive extension сохраняет старый block `100`; +- новый payload может заменить/очистить archive head; +- итоговая полная User PDA запись, включая block `100`, покрывается обычной root-подписью. + +Это позволяет обычным старым клиентским обновлениям профиля не стирать archive head серверного publisher-а. + diff --git a/shine-solana/shine/programs/shine_users/src/lib.rs b/shine-solana/shine/programs/shine_users/src/lib.rs index 3e64daaa..9c469fe0 100644 --- a/shine-solana/shine/programs/shine_users/src/lib.rs +++ b/shine-solana/shine/programs/shine_users/src/lib.rs @@ -36,6 +36,7 @@ const BLOCK_TYPE_SERVER_PROFILE: u8 = 30; const BLOCK_TYPE_ACCESS_SERVERS: u8 = 40; const BLOCK_TYPE_SESSIONS: u8 = 50; const BLOCK_TYPE_TRUSTED_STATE: u8 = 70; +const BLOCK_TYPE_ARCHIVE_HEAD: u8 = 100; const BLOCK_VERSION_0: u8 = 0; const BLOCKCHAIN_TYPE_MAIN_USER: u8 = 1; const SESSIONS_MODE_MIXED: u8 = 1; @@ -163,6 +164,8 @@ pub struct UpdateUserPdaArgs { pub prev_hash: [u8; 32], pub additional_limit: u64, pub fields: UserMutableFields, + /// None = legacy instruction, archive head сохранить; Some(None) = очистить; Some(Some) = заменить. + pub archive_head_update: Option>, pub signature: [u8; 64], } @@ -210,6 +213,12 @@ pub struct BlockchainRecord { pub arweave_tx_id: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ArchiveHeadRecord { + pub arweave_tx_id: [u8; 32], + pub archive_hash: [u8; 32], +} + #[derive(Clone, Debug)] pub struct UserRecord { pub created_at_ms: u64, @@ -230,6 +239,7 @@ pub struct UserRecord { pub sessions_mode: u8, pub sessions: Vec, pub trusted_count: u8, + pub archive_head: Option, pub signature: [u8; 64], } @@ -349,17 +359,34 @@ fn parse_create_args(r: &mut Reader<'_>) -> Result) -> Result { + let login = r.read_string_u8()?; + let recovery_key = r.read_pubkey()?; + let root_key = r.read_pubkey()?; + let created_at_ms = r.read_u64()?; + let updated_at_ms = r.read_u64()?; + let version = r.read_u32()?; + let prev_hash = r.read_fixed_32()?; + let additional_limit = r.read_u64()?; + let fields = parse_fields(r)?; + + // Backward compatibility: legacy update после trusted_count содержит ровно 64-byte root signature. + let archive_head_update = if r.remaining() > 64 { + let present = r.read_u8()?; + match present { + 0 => Some(None), + 1 => Some(Some(ArchiveHeadRecord { + arweave_tx_id: r.read_fixed_32()?, + archive_hash: r.read_fixed_32()?, + })), + _ => return Err(ProgramError::from(ShineUsersError::InvalidInstruction)), + } + } else { + None + }; + Ok(UpdateUserPdaArgs { - login: r.read_string_u8()?, - recovery_key: r.read_pubkey()?, - root_key: r.read_pubkey()?, - created_at_ms: r.read_u64()?, - updated_at_ms: r.read_u64()?, - version: r.read_u32()?, - prev_hash: r.read_fixed_32()?, - additional_limit: r.read_u64()?, - fields: parse_fields(r)?, - signature: r.read_fixed_64()?, + login, recovery_key, root_key, created_at_ms, updated_at_ms, version, prev_hash, + additional_limit, fields, archive_head_update, signature: r.read_fixed_64()?, }) } @@ -590,6 +617,7 @@ fn process_create_user_pda<'a>(program_id: &Pubkey, accounts: &'a [AccountInfo<' sessions_mode: args.fields.sessions_mode, sessions: args.fields.sessions, trusted_count: args.fields.trusted_count, + archive_head: None, signature: [0; 64], }; validate_blockchain_limits(&record.blockchain, 0, 0, true)?; @@ -713,6 +741,10 @@ fn build_update_record(old_record: &UserRecord, args: &UpdateUserPdaArgs, new_ba sessions_mode: args.fields.sessions_mode, sessions: args.fields.sessions.clone(), trusted_count: args.fields.trusted_count, + archive_head: match &args.archive_head_update { + None => old_record.archive_head.clone(), + Some(value) => value.clone(), + }, signature: [0; 64], }) } @@ -857,6 +889,7 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { let mut sessions_mode = SESSIONS_MODE_MIXED; let mut sessions = Vec::new(); let mut trusted_count = 0u8; + let mut archive_head: Option = None; for _ in 0..blocks_count { let block_type = read_u8_from(useful, &mut cursor)?; @@ -907,6 +940,13 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { BLOCK_TYPE_TRUSTED_STATE => { trusted_count = read_u8_from(useful, &mut cursor)?; } + BLOCK_TYPE_ARCHIVE_HEAD => { + require!(archive_head.is_none(), ShineUsersError::InvalidRecordData); + archive_head = Some(ArchiveHeadRecord { + arweave_tx_id: read_fixed_32_from(useful, &mut cursor)?, + archive_hash: read_fixed_32_from(useful, &mut cursor)?, + }); + } _ => return Err(ProgramError::from(ShineUsersError::InvalidRecordFormat)), } } @@ -933,6 +973,7 @@ fn deserialize_record_from_pda(raw: &[u8]) -> Result { sessions_mode, sessions, trusted_count, + archive_head, signature, }) } @@ -986,7 +1027,7 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result, ProgramErro out.push(login_bytes.len() as u8); out.extend_from_slice(login_bytes); - let blocks_count = if record.is_server { 8 } else { 7 }; + let blocks_count = (if record.is_server { 8 } else { 7 }) + if record.archive_head.is_some() { 1 } else { 0 }; out.push(blocks_count); write_recovery_key_block(&mut out, record); write_root_key_block(&mut out, record); @@ -996,6 +1037,7 @@ fn serialize_unsigned_record(record: &UserRecord) -> Result, ProgramErro write_access_servers_block(&mut out, record)?; write_sessions_block(&mut out, record)?; write_trusted_state_block(&mut out, record); + if let Some(archive_head) = &record.archive_head { write_archive_head_block(&mut out, archive_head); } let record_len = out.len().checked_add(64).ok_or(ProgramError::from(ShineUsersError::MathOverflow))?; require!(record_len <= u16::MAX as usize, ShineUsersError::RecordTooLarge); @@ -1045,6 +1087,7 @@ fn write_sessions_block(out: &mut Vec, record: &UserRecord) -> Result<(), Pr out.push(BLOCK_TYPE_SESSIONS); out.push(BLOCK_VERSION_0); out.push(record.sessions_mode); require!(record.sessions.len() <= MAX_SESSIONS, ShineUsersError::InvalidRecordData); out.push(record.sessions.len() as u8); for session in &record.sessions { out.push(session.session_type); out.push(session.session_version); write_len_prefixed_string(out, &session.session_name)?; out.extend_from_slice(session.session_pub_key.as_ref()); } Ok(()) } fn write_trusted_state_block(out: &mut Vec, record: &UserRecord) { out.push(BLOCK_TYPE_TRUSTED_STATE); out.push(BLOCK_VERSION_0); out.push(record.trusted_count); } +fn write_archive_head_block(out: &mut Vec, archive: &ArchiveHeadRecord) { out.push(BLOCK_TYPE_ARCHIVE_HEAD); out.push(BLOCK_VERSION_0); out.extend_from_slice(&archive.arweave_tx_id); out.extend_from_slice(&archive.archive_hash); } fn write_len_prefixed_string(out: &mut Vec, value: &str) -> Result<(), ProgramError> { let bytes = value.as_bytes(); require!(bytes.len() <= u8::MAX as usize, ShineUsersError::InvalidRecordData); out.push(bytes.len() as u8); out.extend_from_slice(bytes); Ok(()) } fn verify_record_signature_hash(instructions_sysvar: &AccountInfo, root_key: &Pubkey, signature: &[u8; 64], message_hash: &[u8]) -> Result<[u8; 64], ProgramError> { diff --git a/tools/test-publisher/AGENTS.md b/tools/test-publisher/AGENTS.md new file mode 100644 index 00000000..d4ea31e7 --- /dev/null +++ b/tools/test-publisher/AGENTS.md @@ -0,0 +1,41 @@ +# AGENTS.md — SHiNE test channels + +Эта папка — тестовый генератор реальной пользовательской активности SHiNE. Это не часть консенсуса и не отдельный протокол. + +## Назначение +Один тестовый пользователь публикует заранее подготовленные POST-блоки сразу в несколько своих каналов. Все POST должны идти через обычный SHiNE `AddBlock`, поэтому сервер проверяет их как обычные пользовательские блоки и затем использует штатную Arweave-публикацию. + +## Почему publisher один +У всех каналов одного пользователя одна глобальная blockchain-цепочка (`blockNumber + prevHash`). Нельзя запускать независимый publisher на каждый канал: они могут одновременно построить следующий глобальный блок. `publisher_multi.py` сериализует публикации всех каналов, но хранит отдельные `prevLineNumber/prevLineHash` и позиции очередей. + +## Каналы в примере +- `jokes` — короткие анекдоты; часть постов содержит 1–2 изображения. +- `mindfulness` — короткие практики осознанности. +- `positive` — позитивные и мотивирующие напоминания. +- `friends` — советы об общении и дружбе, в духе классических принципов человеческого общения, без выдачи текста за дословные цитаты. +- `wisdom` — короткие духовные/созерцательные мысли. Не придумывать цитаты и не приписывать человеку слова без источника. + +## Изображения +`prepare/prepare_all_channels.py` проходит все `channels[].queue_file`. +Для каждого локального файла: +1. вычисляет SHA-256; +2. смотрит общий `upload-state.json`; +3. если ID уже известен — проверяет `https://gateway/`; +4. если файл существует — повторно НЕ загружает; +5. если отсутствует/ID неизвестен — загружает через Arweave JWK; +6. пишет `.prepared.json`, добавляя постоянные Arweave URL в текст POST и сохраняя `images[]` как служебные метаданные. + +JWK используется только подготовщиком. Не помещать приватный JWK или blockchain key в git/архив. + +## State +`multi-state.json` содержит один global head и отдельное состояние каждого канала. Pending DataItem записывается до `AddBlock`; при неопределённом состоянии глобальной цепочки publisher обязан остановиться, а не строить форк. + +## Не менять +- SHiNE Frame v1 и его SHA-256. +- `prevHash` глобальной цепочки. +- `prevLineNumber/prevLineHash` канальной линии. +- ANS-104 signing/deepHash без сверки с серверной реализацией. +- `App=test5590` и `c=` без согласованного изменения импортера/Viewer. + +## Viewer +Viewer распознаёт Arweave URL в тексте POST и показывает изображения inline. Экран «Проверка данных» независимо считает SHA-256 Frame, проверяет доступные `prevHash` и `prevLineHash`, показывает отсутствие соседнего блока как «нет данных», а доказанное несовпадение — как ошибку. Подпись ANS-104 нельзя помечать локально проверенной, если gateway не предоставил сырую подпись DataItem. diff --git a/tools/test-publisher/README.md b/tools/test-publisher/README.md new file mode 100644 index 00000000..d62d4824 --- /dev/null +++ b/tools/test-publisher/README.md @@ -0,0 +1,21 @@ +# SHiNE multi-channel test publisher v3 + +В комплекте сохранены v1 и v2. Основной режим v3 — файлы `v2/publisher/publisher_multi.py` и `v2/prepare/prepare_all_channels.py`. + +## Быстрый порядок +1. Создать тестового пользователя и пять каналов на сервере. +2. Заполнить реальные `channel_root_block_number` в `config.multi.json`. +3. Положить blockchain key в `keys/blockchain.json`. +4. Только для подготовки картинок положить JWK в `keys/arweave-wallet.json`. +5. Скопировать `prepare.multi.example.json` → `prepare.multi.json`, затем: + `python prepare/prepare_all_channels.py --config prepare.multi.json` +6. Проверить созданные `channels/*.prepared.json`. +7. Скопировать `config.multi.example.json` → `config.multi.json`. +8. Проверка: `python publisher/publisher_multi.py status --config config.multi.json` +9. Один тестовый пост: `python publisher/publisher_multi.py publish-now --channel jokes --config config.multi.json` +10. Постоянный режим: `python publisher/publisher_multi.py run --config config.multi.json` + +Повторный запуск подготовки безопасен: известные картинки сначала проверяются на gateway. Существующие не загружаются повторно. + +## v3: media + расписание + VPS +У всех пяти каналов теперь есть media-примеры: в обучающих каналах по 18 иллюстрированных постов (12×1, 4×2, 2×3 картинки). По умолчанию обучающие каналы публикуют раз в 3–7 активных часов, `jokes` — раз в 1–3 активных часа. Quiet hours 22:00–08:00 не расходуют интервал. Инструкция автономной установки: `v2/CODEX_VPS_SETUP.md`. diff --git a/tools/test-publisher/v1/README.md b/tools/test-publisher/v1/README.md new file mode 100644 index 00000000..774a1baf --- /dev/null +++ b/tools/test-publisher/v1/README.md @@ -0,0 +1,90 @@ +# SHiNE Test Publisher + +Автономный publisher заранее подготовленных постов в существующий канал SHiNE. + +## Что делает + +- публикует следующий элемент `queue/jokes.json` через случайный интервал; +- создаёт настоящий SHiNE Frame v1 + подписанный Ed25519 ANS-104 DataItem; +- отправляет его обычной операцией `AddBlock` на SHiNE-server; +- хранит прогресс в `state.json` и продолжает после рестарта; +- перед отправкой сохраняет `pending`, поэтому падение между отправкой и записью state не должно съедать или дублировать элемент очереди; +- при расхождении head цепочки с state останавливается вместо создания форка; +- очередь по умолчанию НЕ зациклена. + +В комплекте 60 заранее подготовленных нейтральных коротких анекдотов/шуток. + +## Требования + +Python 3.11+. + +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +``` + +## Настройка + +```bash +cp config.example.json config.json +``` + +Заполнить: + +- `server_ws` — WebSocket SHiNE-server; +- `blockchain_name` — активная цепочка тестового пользователя; +- `channel_name` — имя существующего канала (его canonical slug = lowercase); +- `channel_root_block_number` — номер CREATE_CHANNEL этого канала; +- `key_file` — Solana JSON blockchain key пользователя; +- интервалы публикации. + +Ключ должен соответствовать owner активной blockchain. Реальный ключ не класть в git, права рекомендуется `chmod 600`. + +При первом запуске `state.json` отсутствует: publisher читает head цепочки и последний POST канала с сервера и создаёт state автоматически. + +## Проверка + +```bash +.venv/bin/python publisher/publisher.py status --config config.json +``` + +`status` при первом запуске также инициализирует state. + +Принудительно опубликовать следующий пост: + +```bash +.venv/bin/python publisher/publisher.py publish-now --config config.json +``` + +Обычный режим: + +```bash +.venv/bin/python publisher/publisher.py run --config config.json +``` + +## systemd + +Скопировать каталог в `/opt/shine-test-publisher`, создать venv и настроить `config.json`, затем: + +```bash +sudo cp systemd/shine-test-publisher.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now shine-test-publisher +journalctl -u shine-test-publisher -f +``` + +Service предполагает пользователя `shine`; поменяйте `User=` при необходимости. + +## state.json + +Не редактировать при запущенном publisher. В нём сохраняются: + +- следующий индекс очереди; +- последний global block/hash; +- последний line number/hash канала; +- время следующей публикации; +- `pending` для crash recovery; +- общее число опубликованных элементов. + +Если очередь закончилась и `loop_queue=false`, новые посты не создаются. Можно дописать новые элементы в JSON; `next_index` продолжит движение дальше. diff --git a/tools/test-publisher/v1/config.example.json b/tools/test-publisher/v1/config.example.json new file mode 100644 index 00000000..329cdb2a --- /dev/null +++ b/tools/test-publisher/v1/config.example.json @@ -0,0 +1,12 @@ +{ + "server_ws": "wss://t3.shineup.me/ws", + "blockchain_name": "testjokes-001", + "channel_name": "jokes", + "channel_root_block_number": 1, + "key_file": "keys/blockchain.json", + "queue_file": "queue/jokes.json", + "state_file": "state.json", + "min_interval_seconds": 1800, + "max_interval_seconds": 10800, + "loop_queue": false +} diff --git a/tools/test-publisher/v1/keys/README.txt b/tools/test-publisher/v1/keys/README.txt new file mode 100644 index 00000000..3b6fa3f5 --- /dev/null +++ b/tools/test-publisher/v1/keys/README.txt @@ -0,0 +1,4 @@ +Put the Solana-format blockchain key JSON here, for example blockchain.json. +The file is an array of 64 bytes [private seed + public key]. +Recommended permissions: chmod 600 keys/blockchain.json +Never commit the real key. diff --git a/tools/test-publisher/v1/publisher/publisher.py b/tools/test-publisher/v1/publisher/publisher.py new file mode 100644 index 00000000..013a87fb --- /dev/null +++ b/tools/test-publisher/v1/publisher/publisher.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +import argparse, asyncio, base64, hashlib, json, os, random, struct, sys, time, uuid +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from websockets.asyncio.client import connect + +ZERO32 = bytes(32) +FRAME_CODE_V1=1; TEXT_TYPE=1; TEXT_POST=10; VERSION=1 + +def atomic_json(path, obj): + path=Path(path); path.parent.mkdir(parents=True, exist_ok=True) + tmp=path.with_suffix(path.suffix+'.tmp') + tmp.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8') + os.replace(tmp,path) + +def load_json(path, default=None): + p=Path(path) + return json.loads(p.read_text(encoding='utf-8')) if p.exists() else default + +def avro_long(n): + n=(n<<1) ^ (n>>63); out=bytearray() + while n & ~0x7f: out.append((n&0x7f)|0x80); n >>= 7 + out.append(n); return bytes(out) + +def avro_tags(tags): + out=bytearray() + if tags: + out += avro_long(len(tags)) + for k,v in tags: + kb=k.encode(); vb=v.encode(); out+=avro_long(len(kb))+kb+avro_long(len(vb))+vb + out += avro_long(0); return bytes(out) + +def deep_hash(x): + H=lambda b: hashlib.sha384(b).digest() + if isinstance(x,(bytes,bytearray)): + b=bytes(x); return H(H(f'blob{len(b)}'.encode())+H(b)) + acc=H(f'list{len(x)}'.encode()) + for child in x: acc=H(acc+deep_hash(child)) + return acc + +def signing_message(owner,tags,data): + raw=avro_tags(tags) + return deep_hash([b'dataitem',b'1',b'2',owner,b'',b'',raw,data]) + +def data_item(priv64,tags,data): + if len(priv64) not in (32,64): raise ValueError('Solana key JSON must contain 32 or 64 bytes') + seed=priv64[:32]; owner=priv64[32:64] if len(priv64)==64 else Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw() + sig=Ed25519PrivateKey.from_private_bytes(seed).sign(signing_message(owner,tags,data)) + rawtags=avro_tags(tags) + return struct.pack('65535: raise ValueError('post text too long') + return struct.pack('>ii32siH',line_code,prev_line_num,prev_line_hash,this_line_num,len(tb))+tb + +def frame(prev_hash, block_num, body, ts=None): + ts=int(time.time()) if ts is None else int(ts); size=56+len(body) + return struct.pack('>H32siiqHHH',FRAME_CODE_V1,prev_hash,size,block_num,ts,TEXT_TYPE,TEXT_POST,VERSION)+body + +def h32(b): return hashlib.sha256(b).digest() +def hx(b): return b.hex() + +async def ws_call(url, op, payload, timeout=20): + req={'op':op,'requestId':str(uuid.uuid4()),'payload':payload} + async with connect(url, open_timeout=timeout, close_timeout=5) as ws: + await ws.send(json.dumps(req,separators=(',',':'))) + end=time.monotonic()+timeout + while True: + left=end-time.monotonic() + if left<=0: raise TimeoutError(op) + msg=await asyncio.wait_for(ws.recv(),left) + obj=json.loads(msg) + if obj.get('requestId')==req['requestId']: return obj + +async def head(cfg): + r=await ws_call(cfg['server_ws'],'ListBlockchainHeads',{}) + if r.get('status')!=200: raise RuntimeError(f'ListBlockchainHeads: {r}') + for x in r.get('payload',{}).get('blockchains',[]): + if x.get('blockchainName')==cfg['blockchain_name']: + return int(x.get('lastBlockNumber',-1)), x.get('lastBlockHash','') + raise RuntimeError('blockchain not found on server') + +async def block(cfg,n): + r=await ws_call(cfg['server_ws'],'GetBlockchainBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':n}) + if r.get('status')!=200: raise RuntimeError(f'GetBlockchainBlock({n}): {r}') + p=r.get('payload',r) + return p.get('blockHash') or r.get('blockHash'), p.get('blockBytesB64') or r.get('blockBytesB64') + +async def channel_tail(cfg, root_hash): + r=await ws_call(cfg['server_ws'],'GetChannelMessages',{'channel':{'ownerBlockchainName':cfg['blockchain_name'],'channelRootBlockNumber':cfg['channel_root_block_number'],'channelRootBlockHash':root_hash},'limit':1,'sort':'desc'}) + if r.get('status')!=200: raise RuntimeError(f'GetChannelMessages: {r}') + items=r.get('payload',{}).get('messages',[]) + if not items: return 0, ZERO32 + m=items[0]; ref=m.get('messageRef') or {} + return int(m.get('lineStep') or 0), bytes.fromhex(ref.get('blockHash','00'*32)) + +def read_key(path): + a=load_json(path) + if not isinstance(a,list): raise ValueError('key file must be Solana JSON byte array') + return bytes(int(x)&255 for x in a) + +def queue_items(cfg): + q=load_json(cfg['queue_file']) + if not isinstance(q,list) or not q: raise ValueError('queue must be non-empty JSON array') + for x in q: + if not isinstance(x,dict) or not str(x.get('text','')).strip(): raise ValueError('each queue item needs text') + return q + +def delay(cfg): return random.randint(int(cfg['min_interval_seconds']),int(cfg['max_interval_seconds'])) + +async def init_state(cfg): + bn,bhash=await head(cfg) + root_hash,_=await block(cfg,int(cfg['channel_root_block_number'])) + line_num,line_hash=await channel_tail(cfg,root_hash) + st={'version':1,'next_index':0,'last_block_number':bn,'last_block_hash':bhash,'last_line_number':line_num,'last_line_hash':hx(line_hash),'next_publish_at':int(time.time())+delay(cfg),'pending':None,'published_total':0} + atomic_json(cfg['state_file'],st); return st + +async def reconcile(cfg,st): + p=st.get('pending') + bn,bhash=await head(cfg) + if p: + if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower(): + st['last_block_number']=bn; st['last_block_hash']=bhash; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st); print('Recovered accepted pending block',bn) + elif bn==p['previous_block_number'] and bhash.lower()==p['previous_block_hash'].lower(): + print('Pending block was not accepted; it will be retried') + else: raise RuntimeError(f'chain changed while pending: server={bn}:{bhash} state={p}') + else: + if bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): raise RuntimeError('server chain head differs from publisher state; refusing to fork') + return st + +async def publish_one(cfg,st,items): + if st.get('pending'): + p=st['pending']; raw=base64.b64decode(p['data_item_b64']); block_num=p['block_number']; prev=p['previous_block_hash'] + else: + idx=int(st['next_index']) + if idx>=len(items): + if cfg.get('loop_queue',False): idx=0 + else: print('Queue exhausted. Nothing to publish.'); return False + text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1; prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(st['last_line_number'])+1 + body=post_body(int(cfg['channel_root_block_number']),int(st['last_line_number']),bytes.fromhex(st['last_line_hash']),this_line,text) + fr=frame(prev_hash,block_num,body); block_hash=h32(fr); tags=[('App','test5590'),('c',cfg['channel_name'].strip().lower())] + raw=data_item(read_key(cfg['key_file']),tags,fr) + nextidx=idx+1 + p={'queue_index':idx,'next_index_after':nextidx,'block_number':block_num,'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()} + st['pending']=p; atomic_json(cfg['state_file'],st); prev=st['last_block_hash'] + r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,'prevBlockHash':prev,'blockBytesB64':base64.b64encode(raw).decode()},timeout=30) + if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}') + server_hash=(r.get('payload') or {}).get('serverLastBlockHash') or (r.get('payload') or {}).get('serverLastGlobalHash') + if server_hash and server_hash.lower()!=p['block_hash'].lower(): raise RuntimeError('server accepted different hash') + st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st) + print(f"Published queue[{p['queue_index']}] as block #{block_num}; next at {time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(st['next_publish_at']))}") + return True + +async def main_async(args): + cfg=load_json(args.config); required=['server_ws','blockchain_name','channel_name','channel_root_block_number','key_file','queue_file','state_file','min_interval_seconds','max_interval_seconds'] + for k in required: + if k not in cfg: raise ValueError('missing config: '+k) + base=Path(args.config).resolve().parent + for k in ('key_file','queue_file','state_file'): + p=Path(cfg[k]); cfg[k]=str(p if p.is_absolute() else base/p) + items=queue_items(cfg) + st=load_json(cfg['state_file']) + if st is None: st=await init_state(cfg); print('Initialized state from server/channel') + st=await reconcile(cfg,st) + if args.command=='status': print(json.dumps(st,ensure_ascii=False,indent=2)); return + if args.command=='publish-now': await publish_one(cfg,st,items); return + while True: + st=await reconcile(cfg,load_json(cfg['state_file'])) + wait=max(0,int(st.get('next_publish_at',0))-int(time.time())) + if wait: print(f'Waiting {wait}s'); await asyncio.sleep(wait) + ok=await publish_one(cfg,st,items) + if not ok: await asyncio.sleep(3600) + +def main(): + ap=argparse.ArgumentParser(description='SHiNE test channel publisher') + ap.add_argument('command',choices=['run','status','publish-now'],nargs='?',default='run'); ap.add_argument('--config',default='config.json') + a=ap.parse_args() + try: asyncio.run(main_async(a)) + except KeyboardInterrupt: pass + except Exception as e: print('ERROR:',e,file=sys.stderr); sys.exit(1) +if __name__=='__main__': main() diff --git a/tools/test-publisher/v1/queue/jokes.json b/tools/test-publisher/v1/queue/jokes.json new file mode 100644 index 00000000..bcdb0b0d --- /dev/null +++ b/tools/test-publisher/v1/queue/jokes.json @@ -0,0 +1,182 @@ +[ + { + "text": "— Почему программист поставил чайник рядом с компьютером? — Чтобы не отходить от Java." + }, + { + "text": "Купил умную лампочку. Теперь она знает, что я опять лёг спать в три ночи. Неловко перед техникой." + }, + { + "text": "— У вас есть книга «Как перестать всё откладывать»? — Есть. — Отлично, зайду завтра." + }, + { + "text": "Будильник — это устройство, которое каждое утро начинает переговоры с человеком с позиции силы." + }, + { + "text": "— Ты почему опоздал? — Боролся со сном. — И кто победил? — Судя по времени, была ничья." + }, + { + "text": "Кот сел на клавиатуру и за минуту написал код без ошибок. Команда решила не трогать продакшен." + }, + { + "text": "Самый оптимистичный предмет дома — зарядка на 1%. Она всё ещё показывает, что жизнь продолжается." + }, + { + "text": "— Как твой новый режим дня? — Отлично. Я уже третий день собираюсь начать его соблюдать." + }, + { + "text": "Поставил пароль «неправильный». Теперь компьютер сам подсказывает: «Ваш пароль неправильный»." + }, + { + "text": "Холодильник ночью светится не потому, что ему нужно освещение. Он просто рад тебя видеть." + }, + { + "text": "— Ты читаешь инструкции? — Конечно. Обычно сразу после того, как всё перестало работать." + }, + { + "text": "Если долго смотреть на список дел, список дел начинает смотреть на тебя." + }, + { + "text": "Кофе не решает проблемы. Но проблемы без кофе почему-то выглядят значительно крупнее." + }, + { + "text": "— Почему ты разговариваешь с растениями? — Они хотя бы не перебивают и растут над собой." + }, + { + "text": "Телефон сообщил, что экранное время выросло. Спасибо, телефон. Без тебя я бы чувствовал себя прекрасно." + }, + { + "text": "— Ты умеешь быстро принимать решения? — Да. Иногда даже несколько разных подряд." + }, + { + "text": "Навёл порядок на рабочем столе. Теперь ничего не могу найти, зато потеряно очень организованно." + }, + { + "text": "Есть два типа людей: те, кто делает резервные копии, и те, кто уже начал их делать." + }, + { + "text": "— Что делаешь? — Ничего. — А вчера? — Не закончил." + }, + { + "text": "Умные часы предложили мне встать и пройтись. Очень смело для устройства, которое весь день сидит у меня на руке." + }, + { + "text": "План на день был прекрасен. Жаль, что день о нём ничего не знал." + }, + { + "text": "— Почему у тебя открыто 47 вкладок? — Это не вкладки. Это планы на будущее." + }, + { + "text": "Иногда лучший способ решить задачу — закрыть ноутбук. Иногда после этого задача пишет сама и спрашивает, где ты." + }, + { + "text": "Купил ежедневник для продуктивности. Первый пункт: не забыть найти ежедневник." + }, + { + "text": "— Ты сова или жаворонок? — Я человек, который утром обещает вечером лечь раньше." + }, + { + "text": "Wi‑Fi исчез на пять минут. Семья познакомилась. Вроде приятные люди." + }, + { + "text": "Мой компьютер настолько быстрый, что успевает обновиться именно тогда, когда я спешу." + }, + { + "text": "— Ты медитируешь? — Да. Иногда минут двадцать смотрю на загрузку обновления." + }, + { + "text": "Если задача занимает две минуты, сделай её сразу. Если занимает два часа — сначала приготовь чай. Это наука." + }, + { + "text": "Кот не мешает работать. Он проводит независимый аудит клавиатуры." + }, + { + "text": "— Почему ты улыбаешься телефону? — Он написал «обновление успешно установлено». Хоть у кого-то всё получилось." + }, + { + "text": "Взрослая жизнь — это когда радуешься новой губке для посуды и немного пугаешься этого факта." + }, + { + "text": "Пылесос-робот застрял под стулом. Первый сотрудник в доме, который официально запросил помощь." + }, + { + "text": "— Что такое дедлайн? — Момент, когда вдохновение внезапно узнаёт твой адрес." + }, + { + "text": "Мой список покупок состоит из двух частей: что нужно купить и что я всё равно забуду." + }, + { + "text": "Сделал перерыв на пять минут. Очень качественный получился перерыв — почти два часа." + }, + { + "text": "— У тебя хорошая память? — Конечно. Просто я не помню, где её оставил." + }, + { + "text": "Человек может бесконечно смотреть на три вещи: огонь, воду и процент установки обновления на 99%." + }, + { + "text": "Купил растение, которое «почти невозможно погубить». Наконец-то достойный соперник." + }, + { + "text": "— Почему ты не отвечал? — Телефон был на беззвучном. — Три дня? — Очень беззвучном." + }, + { + "text": "В инструкции было написано «собирается за 15 минут». Не уточнили, за чьи 15 минут." + }, + { + "text": "— Ты умеешь хранить секреты? — Да. Особенно пароли. Иногда даже от себя." + }, + { + "text": "Собрался экономить. Для начала купил красивую коробку, в которой буду хранить деньги, когда они появятся." + }, + { + "text": "Самая сложная часть удалённой работы — объяснить коту, что созвон не является коллективным мероприятием." + }, + { + "text": "— Как прошла уборка? — Нашёл зарядку, три монеты и причину больше ничего не убирать." + }, + { + "text": "Прогноз погоды сказал «ощущается как +5». Хотелось бы узнать, кем именно ощущается." + }, + { + "text": "Календарь — очень оптимистичная вещь: он уверен, что через месяц у тебя всё ещё будут планы." + }, + { + "text": "— Ты выключил утюг? — Теперь это главная интрига поездки." + }, + { + "text": "На коробке написано «легко открыть». Коробка явно ничего обо мне не знает." + }, + { + "text": "Поставил приложение для концентрации. Полчаса выбирал в нём идеальную тему оформления." + }, + { + "text": "— Почему не спишь? — Думаю. — О чём? — Почему не сплю." + }, + { + "text": "Когда интернет медленный, начинаешь замечать интерьер. Оказывается, у нас есть стены." + }, + { + "text": "Микроволновка пищит так, будто лично приготовила ужин с нуля." + }, + { + "text": "— Ты всё записываешь? — Да. — И помогает? — Конечно. Теперь я точно знаю, сколько всего забыл посмотреть в записях." + }, + { + "text": "Рабочий стол компьютера чистый. Все 186 файлов аккуратно лежат в папке «Разобрать»." + }, + { + "text": "Кофемашина — единственный коллега, который понимает задачу до первого созвона." + }, + { + "text": "— Как стать организованнее? — Я записал ответ куда-то. Сейчас найду." + }, + { + "text": "Если открыть холодильник третий раз, новой еды там не появится. Но эксперимент должен быть воспроизводимым." + }, + { + "text": "Телефон попросил оценить приложение. Я попросил телефон оценить мои жизненные решения. Разошлись молча." + }, + { + "text": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло." + } +] \ No newline at end of file diff --git a/tools/test-publisher/v1/requirements.txt b/tools/test-publisher/v1/requirements.txt new file mode 100644 index 00000000..bb18ecc9 --- /dev/null +++ b/tools/test-publisher/v1/requirements.txt @@ -0,0 +1,2 @@ +cryptography>=42 +websockets>=14 diff --git a/tools/test-publisher/v1/systemd/shine-test-publisher.service b/tools/test-publisher/v1/systemd/shine-test-publisher.service new file mode 100644 index 00000000..fd7456aa --- /dev/null +++ b/tools/test-publisher/v1/systemd/shine-test-publisher.service @@ -0,0 +1,17 @@ +[Unit] +Description=SHiNE test channel publisher +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=shine +WorkingDirectory=/opt/shine-test-publisher +ExecStart=/opt/shine-test-publisher/.venv/bin/python publisher/publisher.py run --config config.json +Restart=on-failure +RestartSec=15 +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/tools/test-publisher/v2/.gitignore b/tools/test-publisher/v2/.gitignore new file mode 100644 index 00000000..24b25a85 --- /dev/null +++ b/tools/test-publisher/v2/.gitignore @@ -0,0 +1,12 @@ +keys/*.json +!keys/README.txt +state.json +multi-state.json +upload-state.json +channels/*.prepared.json +config.json +config.multi.json +prepare.config.json +prepare.multi.json +__pycache__/ +.venv/ diff --git a/tools/test-publisher/v2/CODEX_VPS_SETUP.md b/tools/test-publisher/v2/CODEX_VPS_SETUP.md new file mode 100644 index 00000000..6f2dadb1 --- /dev/null +++ b/tools/test-publisher/v2/CODEX_VPS_SETUP.md @@ -0,0 +1,87 @@ +# CODEX_VPS_SETUP.md — развернуть автономный SHiNE test publisher + +Цель: установить test publisher на Ubuntu VPS так, чтобы он постоянно работал через systemd, переживал reboot/сбой сети, публиковал в несколько каналов одного тестового пользователя и не требовал ручного запуска. + +## Что агент должен получить от оператора +Никогда не печатай приватные ключи в лог. +1. Реальный `blockchain_name` тестового пользователя. +2. Solana-format JSON blockchain key → `keys/blockchain.json`. +3. Arweave JWK → `keys/arweave-wallet.json` (нужен только для prepare). +4. Для каждого канала реальный `channel_root_block_number` CREATE_CHANNEL. +5. Подтверждение WebSocket URL сервера (example: `wss://t3.shineup.me/ws`). + +## Установка +Предполагаемый каталог: `/opt/shine-test-publisher/v2`, Linux user: `shine`. + +```bash +sudo useradd --system --home /opt/shine-test-publisher --shell /usr/sbin/nologin shine 2>/dev/null || true +sudo mkdir -p /opt/shine-test-publisher +sudo chown -R shine:shine /opt/shine-test-publisher +# скопировать содержимое tools/test-publisher/v2 в /opt/shine-test-publisher/v2 + +cd /opt/shine-test-publisher +sudo -u shine python3 -m venv .venv +sudo -u shine .venv/bin/pip install -r v2/requirements.txt +sudo chmod 700 v2/keys +sudo chmod 600 v2/keys/*.json +``` + +`requirements.txt` должен содержать зависимости publisher и prepare (`cryptography`, `websockets`, `requests`, `arweave-python-client`). + +## Конфиги +Скопировать: +- `config.multi.example.json` → `config.multi.json` +- `prepare.multi.example.json` → `prepare.multi.json` + +Заменить placeholders. Не угадывать CREATE_CHANNEL block numbers. + +Расписание по умолчанию: +- jokes: случайно 1–3 активных часа; +- остальные: случайно 3–7 активных часов; +- timezone: Europe/Warsaw; +- quiet: 22:00–08:00. + +Quiet hours НЕ расходуют интервал. Если из 5 часов до ночи прошло 2 часа, после 08:00 должно остаться 3 часа. + +## Подготовка media +Один раз и затем после добавления новых картинок: +```bash +cd /opt/shine-test-publisher/v2 +../.venv/bin/python prepare/prepare_all_channels.py --config prepare.multi.json +``` +Скрипт обязан пройти ВСЕ channels. Известный Arweave ID сначала проверяется через gateway. Существующий файл повторно не загружается. Отсутствующий — загружается и state обновляется атомарно. + +После успешной подготовки JWK не нужен постоянному publisher. Для минимизации риска можно перенести JWK в защищённое offline-хранилище; тогда повторный prepare потребует вернуть его. + +## Проверка до systemd +```bash +../.venv/bin/python publisher/publisher_multi.py status --config config.multi.json +../.venv/bin/python publisher/publisher_multi.py publish-now --channel jokes --config config.multi.json +``` +Проверить появление блока на SHiNE server и затем в Viewer. Не запускать два экземпляра publisher для одного blockchain key. + +## systemd +```bash +sudo cp systemd/shine-test-publisher-multi.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now shine-test-publisher-multi +sudo systemctl status shine-test-publisher-multi +journalctl -u shine-test-publisher-multi -f +``` + +Unit использует `Restart=on-failure`; publisher хранит pending/state на диске и после рестарта сверяет global head перед продолжением. При конфликте global head он обязан остановиться, а не создавать форк. + +## После настройки +Проверить: +- `systemctl is-enabled` = enabled; +- `systemctl is-active` = active; +- `multi-state.json` создаётся и меняется; +- в quiet hours нет публикаций; +- reboot VPS → service возвращается в active; +- приватные JSON имеют mode 600; +- prepared queues существуют для всех пяти каналов. + +## Запреты +Не коммитить/архивировать реальные keys, JWK, `multi-state.json`, `upload-state.json`. +Не запускать параллельно v1/v2 single publisher и multi publisher с одним ключом. +Не сбрасывать state ради устранения ошибки chain-head: сначала выяснить расхождение. diff --git a/tools/test-publisher/v2/assets/channel-art/friends-coffee.webp b/tools/test-publisher/v2/assets/channel-art/friends-coffee.webp new file mode 100644 index 00000000..ceae66d9 Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/friends-coffee.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/friends-hello.webp b/tools/test-publisher/v2/assets/channel-art/friends-hello.webp new file mode 100644 index 00000000..149a9809 Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/friends-hello.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/friends-listen.webp b/tools/test-publisher/v2/assets/channel-art/friends-listen.webp new file mode 100644 index 00000000..4e6bf9ea Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/friends-listen.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/friends-talk.webp b/tools/test-publisher/v2/assets/channel-art/friends-talk.webp new file mode 100644 index 00000000..2597630e Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/friends-talk.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/mindfulness-breath.webp b/tools/test-publisher/v2/assets/channel-art/mindfulness-breath.webp new file mode 100644 index 00000000..3a46d5be Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/mindfulness-breath.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/mindfulness-tea.webp b/tools/test-publisher/v2/assets/channel-art/mindfulness-tea.webp new file mode 100644 index 00000000..b346d2e7 Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/mindfulness-tea.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/mindfulness-walk.webp b/tools/test-publisher/v2/assets/channel-art/mindfulness-walk.webp new file mode 100644 index 00000000..12c358dd Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/mindfulness-walk.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/mindfulness-window.webp b/tools/test-publisher/v2/assets/channel-art/mindfulness-window.webp new file mode 100644 index 00000000..0843b13e Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/mindfulness-window.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/positive-rest.webp b/tools/test-publisher/v2/assets/channel-art/positive-rest.webp new file mode 100644 index 00000000..da962306 Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/positive-rest.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/positive-spark.webp b/tools/test-publisher/v2/assets/channel-art/positive-spark.webp new file mode 100644 index 00000000..d6dfd03f Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/positive-spark.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/positive-step.webp b/tools/test-publisher/v2/assets/channel-art/positive-step.webp new file mode 100644 index 00000000..e2f79d09 Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/positive-step.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/positive-sun.webp b/tools/test-publisher/v2/assets/channel-art/positive-sun.webp new file mode 100644 index 00000000..6ffd8766 Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/positive-sun.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/wisdom-light.webp b/tools/test-publisher/v2/assets/channel-art/wisdom-light.webp new file mode 100644 index 00000000..5c1efcc1 Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/wisdom-light.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/wisdom-moon.webp b/tools/test-publisher/v2/assets/channel-art/wisdom-moon.webp new file mode 100644 index 00000000..d2d44d13 Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/wisdom-moon.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/wisdom-path.webp b/tools/test-publisher/v2/assets/channel-art/wisdom-path.webp new file mode 100644 index 00000000..4a68b8e8 Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/wisdom-path.webp differ diff --git a/tools/test-publisher/v2/assets/channel-art/wisdom-water.webp b/tools/test-publisher/v2/assets/channel-art/wisdom-water.webp new file mode 100644 index 00000000..d13a3183 Binary files /dev/null and b/tools/test-publisher/v2/assets/channel-art/wisdom-water.webp differ diff --git a/tools/test-publisher/v2/channels/friends.raw.json b/tools/test-publisher/v2/channels/friends.raw.json new file mode 100644 index 00000000..1c8572f5 --- /dev/null +++ b/tools/test-publisher/v2/channels/friends.raw.json @@ -0,0 +1,271 @@ +[ + { + "id": "friend-001", + "text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку.", + "images": [ + "../assets/channel-art/friends-coffee.webp" + ] + }, + { + "id": "friend-002", + "text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно.", + "images": [] + }, + { + "id": "friend-003", + "text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно.", + "images": [ + "../assets/channel-art/friends-hello.webp" + ] + }, + { + "id": "friend-004", + "text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой.", + "images": [] + }, + { + "id": "friend-005", + "text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя.", + "images": [ + "../assets/channel-art/friends-listen.webp" + ] + }, + { + "id": "friend-006", + "text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный».", + "images": [] + }, + { + "id": "friend-007", + "text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное.", + "images": [ + "../assets/channel-art/friends-talk.webp" + ] + }, + { + "id": "friend-008", + "text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты.", + "images": [] + }, + { + "id": "friend-009", + "text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?»", + "images": [ + "../assets/channel-art/friends-coffee.webp" + ] + }, + { + "id": "friend-010", + "text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности.", + "images": [] + }, + { + "id": "friend-011", + "text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #11", + "images": [ + "../assets/channel-art/friends-hello.webp" + ] + }, + { + "id": "friend-012", + "text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #12", + "images": [] + }, + { + "id": "friend-013", + "text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #13", + "images": [ + "../assets/channel-art/friends-listen.webp" + ] + }, + { + "id": "friend-014", + "text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #14", + "images": [] + }, + { + "id": "friend-015", + "text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #15", + "images": [ + "../assets/channel-art/friends-talk.webp" + ] + }, + { + "id": "friend-016", + "text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный». · Напоминание #16", + "images": [] + }, + { + "id": "friend-017", + "text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное. · Напоминание #17", + "images": [ + "../assets/channel-art/friends-coffee.webp" + ] + }, + { + "id": "friend-018", + "text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты. · Напоминание #18", + "images": [] + }, + { + "id": "friend-019", + "text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?» · Напоминание #19", + "images": [ + "../assets/channel-art/friends-hello.webp" + ] + }, + { + "id": "friend-020", + "text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности. · Напоминание #20", + "images": [] + }, + { + "id": "friend-021", + "text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #21", + "images": [ + "../assets/channel-art/friends-listen.webp" + ] + }, + { + "id": "friend-022", + "text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #22", + "images": [] + }, + { + "id": "friend-023", + "text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #23", + "images": [ + "../assets/channel-art/friends-talk.webp" + ] + }, + { + "id": "friend-024", + "text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #24", + "images": [] + }, + { + "id": "friend-025", + "text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #25", + "images": [ + "../assets/channel-art/friends-coffee.webp", + "../assets/channel-art/friends-hello.webp" + ] + }, + { + "id": "friend-026", + "text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный». · Напоминание #26", + "images": [] + }, + { + "id": "friend-027", + "text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное. · Напоминание #27", + "images": [ + "../assets/channel-art/friends-hello.webp", + "../assets/channel-art/friends-listen.webp" + ] + }, + { + "id": "friend-028", + "text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты. · Напоминание #28", + "images": [] + }, + { + "id": "friend-029", + "text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?» · Напоминание #29", + "images": [ + "../assets/channel-art/friends-listen.webp", + "../assets/channel-art/friends-talk.webp" + ] + }, + { + "id": "friend-030", + "text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности. · Напоминание #30", + "images": [] + }, + { + "id": "friend-031", + "text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #31", + "images": [ + "../assets/channel-art/friends-talk.webp", + "../assets/channel-art/friends-coffee.webp" + ] + }, + { + "id": "friend-032", + "text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #32", + "images": [] + }, + { + "id": "friend-033", + "text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #33", + "images": [ + "../assets/channel-art/friends-coffee.webp", + "../assets/channel-art/friends-hello.webp", + "../assets/channel-art/friends-listen.webp" + ] + }, + { + "id": "friend-034", + "text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #34", + "images": [] + }, + { + "id": "friend-035", + "text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #35", + "images": [ + "../assets/channel-art/friends-hello.webp", + "../assets/channel-art/friends-listen.webp", + "../assets/channel-art/friends-talk.webp" + ] + }, + { + "id": "friend-036", + "text": "Конкретный комплимент звучит теплее общего: лучше отметить поступок, вкус или идею, чем просто сказать «ты классный». · Напоминание #36", + "images": [] + }, + { + "id": "friend-037", + "text": "Чтобы дружба появилась, нужен повторный контакт. После хорошего знакомства напиши первой и предложи что-нибудь конкретное. · Напоминание #37", + "images": [] + }, + { + "id": "friend-038", + "text": "Если ошиблась — короткое искреннее признание обычно работает лучше длинной защиты. · Напоминание #38", + "images": [] + }, + { + "id": "friend-039", + "text": "Людям приятно чувствовать, что их услышали. Иногда достаточно переспросить: «Правильно понимаю, что…?» · Напоминание #39", + "images": [] + }, + { + "id": "friend-040", + "text": "Не пытайся понравиться всем. Дружба строится не на идеальном впечатлении, а на взаимном интересе и регулярности. · Напоминание #40", + "images": [] + }, + { + "id": "friend-041", + "text": "Хочешь легче знакомиться — начинай не с попытки впечатлить, а с искреннего интереса к человеку. · Напоминание #41", + "images": [] + }, + { + "id": "friend-042", + "text": "Запоминай имя собеседника и используй его естественно. Для большинства людей собственное имя важно. · Напоминание #42", + "images": [] + }, + { + "id": "friend-043", + "text": "Хороший вопрос обычно лучше длинного рассказа о себе. Спроси о том, что человеку действительно интересно. · Напоминание #43", + "images": [] + }, + { + "id": "friend-044", + "text": "Не спорь ради победы в мелочах. Отношения часто ценнее ощущения, что последнее слово осталось за тобой. · Напоминание #44", + "images": [] + }, + { + "id": "friend-045", + "text": "Если человек рассказывает о важном для него событии, задай ещё один вопрос вместо мгновенного перевода разговора на себя. · Напоминание #45", + "images": [] + } +] \ No newline at end of file diff --git a/tools/test-publisher/v2/channels/jokes.raw.json b/tools/test-publisher/v2/channels/jokes.raw.json new file mode 100644 index 00000000..4dc39fd1 --- /dev/null +++ b/tools/test-publisher/v2/channels/jokes.raw.json @@ -0,0 +1,345 @@ +[ + { + "id": "001", + "text": "— Почему программист поставил чайник рядом с компьютером? — Чтобы не отходить от Java.", + "images": [] + }, + { + "id": "002", + "text": "Купил умную лампочку. Теперь она знает, что я опять лёг спать в три ночи. Неловко перед техникой.", + "images": [ + "../queue/images/joke_02.webp" + ] + }, + { + "id": "003", + "text": "— У вас есть книга «Как перестать всё откладывать»? — Есть. — Отлично, зайду завтра.", + "images": [] + }, + { + "id": "004", + "text": "Будильник — это устройство, которое каждое утро начинает переговоры с человеком с позиции силы.", + "images": [] + }, + { + "id": "005", + "text": "— Ты почему опоздал? — Боролся со сном. — И кто победил? — Судя по времени, была ничья.", + "images": [ + "../queue/images/joke_05.webp" + ] + }, + { + "id": "006", + "text": "Кот сел на клавиатуру и за минуту написал код без ошибок. Команда решила не трогать продакшен.", + "images": [ + "../queue/images/joke_06.webp", + "../queue/images/joke_09.webp" + ] + }, + { + "id": "007", + "text": "Самый оптимистичный предмет дома — зарядка на 1%. Она всё ещё показывает, что жизнь продолжается.", + "images": [] + }, + { + "id": "008", + "text": "— Как твой новый режим дня? — Отлично. Я уже третий день собираюсь начать его соблюдать.", + "images": [ + "../queue/images/joke_08.webp" + ] + }, + { + "id": "009", + "text": "Поставил пароль «неправильный». Теперь компьютер сам подсказывает: «Ваш пароль неправильный».", + "images": [] + }, + { + "id": "010", + "text": "Холодильник ночью светится не потому, что ему нужно освещение. Он просто рад тебя видеть.", + "images": [] + }, + { + "id": "011", + "text": "— Ты читаешь инструкции? — Конечно. Обычно сразу после того, как всё перестало работать.", + "images": [ + "../queue/images/joke_01.webp" + ] + }, + { + "id": "012", + "text": "Если долго смотреть на список дел, список дел начинает смотреть на тебя.", + "images": [] + }, + { + "id": "013", + "text": "Кофе не решает проблемы. Но проблемы без кофе почему-то выглядят значительно крупнее.", + "images": [] + }, + { + "id": "014", + "text": "— Почему ты разговариваешь с растениями? — Они хотя бы не перебивают и растут над собой.", + "images": [ + "../queue/images/joke_04.webp" + ] + }, + { + "id": "015", + "text": "Телефон сообщил, что экранное время выросло. Спасибо, телефон. Без тебя я бы чувствовал себя прекрасно.", + "images": [] + }, + { + "id": "016", + "text": "— Ты умеешь быстро принимать решения? — Да. Иногда даже несколько разных подряд.", + "images": [] + }, + { + "id": "017", + "text": "Навёл порядок на рабочем столе. Теперь ничего не могу найти, зато потеряно очень организованно.", + "images": [ + "../queue/images/joke_07.webp" + ] + }, + { + "id": "018", + "text": "Есть два типа людей: те, кто делает резервные копии, и те, кто уже начал их делать.", + "images": [ + "../queue/images/joke_08.webp", + "../queue/images/joke_01.webp" + ] + }, + { + "id": "019", + "text": "— Что делаешь? — Ничего. — А вчера? — Не закончил.", + "images": [] + }, + { + "id": "020", + "text": "Умные часы предложили мне встать и пройтись. Очень смело для устройства, которое весь день сидит у меня на руке.", + "images": [ + "../queue/images/joke_10.webp" + ] + }, + { + "id": "021", + "text": "План на день был прекрасен. Жаль, что день о нём ничего не знал.", + "images": [] + }, + { + "id": "022", + "text": "— Почему у тебя открыто 47 вкладок? — Это не вкладки. Это планы на будущее.", + "images": [] + }, + { + "id": "023", + "text": "Иногда лучший способ решить задачу — закрыть ноутбук. Иногда после этого задача пишет сама и спрашивает, где ты.", + "images": [ + "../queue/images/joke_03.webp" + ] + }, + { + "id": "024", + "text": "Купил ежедневник для продуктивности. Первый пункт: не забыть найти ежедневник.", + "images": [] + }, + { + "id": "025", + "text": "— Ты сова или жаворонок? — Я человек, который утром обещает вечером лечь раньше.", + "images": [] + }, + { + "id": "026", + "text": "Wi‑Fi исчез на пять минут. Семья познакомилась. Вроде приятные люди.", + "images": [ + "../queue/images/joke_06.webp" + ] + }, + { + "id": "027", + "text": "Мой компьютер настолько быстрый, что успевает обновиться именно тогда, когда я спешу.", + "images": [] + }, + { + "id": "028", + "text": "— Ты медитируешь? — Да. Иногда минут двадцать смотрю на загрузку обновления.", + "images": [] + }, + { + "id": "029", + "text": "Если задача занимает две минуты, сделай её сразу. Если занимает два часа — сначала приготовь чай. Это наука.", + "images": [ + "../queue/images/joke_09.webp" + ] + }, + { + "id": "030", + "text": "Кот не мешает работать. Он проводит независимый аудит клавиатуры.", + "images": [ + "../queue/images/joke_10.webp", + "../queue/images/joke_03.webp" + ] + }, + { + "id": "031", + "text": "— Почему ты улыбаешься телефону? — Он написал «обновление успешно установлено». Хоть у кого-то всё получилось.", + "images": [] + }, + { + "id": "032", + "text": "Взрослая жизнь — это когда радуешься новой губке для посуды и немного пугаешься этого факта.", + "images": [ + "../queue/images/joke_02.webp" + ] + }, + { + "id": "033", + "text": "Пылесос-робот застрял под стулом. Первый сотрудник в доме, который официально запросил помощь.", + "images": [] + }, + { + "id": "034", + "text": "— Что такое дедлайн? — Момент, когда вдохновение внезапно узнаёт твой адрес.", + "images": [] + }, + { + "id": "035", + "text": "Мой список покупок состоит из двух частей: что нужно купить и что я всё равно забуду.", + "images": [ + "../queue/images/joke_05.webp" + ] + }, + { + "id": "036", + "text": "Сделал перерыв на пять минут. Очень качественный получился перерыв — почти два часа.", + "images": [] + }, + { + "id": "037", + "text": "— У тебя хорошая память? — Конечно. Просто я не помню, где её оставил.", + "images": [] + }, + { + "id": "038", + "text": "Человек может бесконечно смотреть на три вещи: огонь, воду и процент установки обновления на 99%.", + "images": [ + "../queue/images/joke_08.webp" + ] + }, + { + "id": "039", + "text": "Купил растение, которое «почти невозможно погубить». Наконец-то достойный соперник.", + "images": [] + }, + { + "id": "040", + "text": "— Почему ты не отвечал? — Телефон был на беззвучном. — Три дня? — Очень беззвучном.", + "images": [] + }, + { + "id": "041", + "text": "В инструкции было написано «собирается за 15 минут». Не уточнили, за чьи 15 минут.", + "images": [ + "../queue/images/joke_01.webp" + ] + }, + { + "id": "042", + "text": "— Ты умеешь хранить секреты? — Да. Особенно пароли. Иногда даже от себя.", + "images": [ + "../queue/images/joke_02.webp", + "../queue/images/joke_05.webp" + ] + }, + { + "id": "043", + "text": "Собрался экономить. Для начала купил красивую коробку, в которой буду хранить деньги, когда они появятся.", + "images": [] + }, + { + "id": "044", + "text": "Самая сложная часть удалённой работы — объяснить коту, что созвон не является коллективным мероприятием.", + "images": [] + }, + { + "id": "045", + "text": "— Как прошла уборка? — Нашёл зарядку, три монеты и причину больше ничего не убирать.", + "images": [] + }, + { + "id": "046", + "text": "Прогноз погоды сказал «ощущается как +5». Хотелось бы узнать, кем именно ощущается.", + "images": [] + }, + { + "id": "047", + "text": "Календарь — очень оптимистичная вещь: он уверен, что через месяц у тебя всё ещё будут планы.", + "images": [] + }, + { + "id": "048", + "text": "— Ты выключил утюг? — Теперь это главная интрига поездки.", + "images": [] + }, + { + "id": "049", + "text": "На коробке написано «легко открыть». Коробка явно ничего обо мне не знает.", + "images": [] + }, + { + "id": "050", + "text": "Поставил приложение для концентрации. Полчаса выбирал в нём идеальную тему оформления.", + "images": [] + }, + { + "id": "051", + "text": "— Почему не спишь? — Думаю. — О чём? — Почему не сплю.", + "images": [] + }, + { + "id": "052", + "text": "Когда интернет медленный, начинаешь замечать интерьер. Оказывается, у нас есть стены.", + "images": [] + }, + { + "id": "053", + "text": "Микроволновка пищит так, будто лично приготовила ужин с нуля.", + "images": [] + }, + { + "id": "054", + "text": "— Ты всё записываешь? — Да. — И помогает? — Конечно. Теперь я точно знаю, сколько всего забыл посмотреть в записях.", + "images": [ + "../queue/images/joke_04.webp", + "../queue/images/joke_07.webp" + ] + }, + { + "id": "055", + "text": "Рабочий стол компьютера чистый. Все 186 файлов аккуратно лежат в папке «Разобрать».", + "images": [] + }, + { + "id": "056", + "text": "Кофемашина — единственный коллега, который понимает задачу до первого созвона.", + "images": [] + }, + { + "id": "057", + "text": "— Как стать организованнее? — Я записал ответ куда-то. Сейчас найду.", + "images": [] + }, + { + "id": "058", + "text": "Если открыть холодильник третий раз, новой еды там не появится. Но эксперимент должен быть воспроизводимым.", + "images": [] + }, + { + "id": "059", + "text": "Телефон попросил оценить приложение. Я попросил телефон оценить мои жизненные решения. Разошлись молча.", + "images": [] + }, + { + "id": "060", + "text": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло.", + "images": [] + } +] \ No newline at end of file diff --git a/tools/test-publisher/v2/channels/mindfulness.raw.json b/tools/test-publisher/v2/channels/mindfulness.raw.json new file mode 100644 index 00000000..f31a4726 --- /dev/null +++ b/tools/test-publisher/v2/channels/mindfulness.raw.json @@ -0,0 +1,271 @@ +[ + { + "id": "mind-001", + "text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай.", + "images": [ + "../assets/channel-art/mindfulness-breath.webp" + ] + }, + { + "id": "mind-002", + "text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно.", + "images": [] + }, + { + "id": "mind-003", + "text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией.", + "images": [ + "../assets/channel-art/mindfulness-tea.webp" + ] + }, + { + "id": "mind-004", + "text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас.", + "images": [] + }, + { + "id": "mind-005", + "text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные.", + "images": [ + "../assets/channel-art/mindfulness-walk.webp" + ] + }, + { + "id": "mind-006", + "text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца.", + "images": [] + }, + { + "id": "mind-007", + "text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание.", + "images": [ + "../assets/channel-art/mindfulness-window.webp" + ] + }, + { + "id": "mind-008", + "text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу.", + "images": [] + }, + { + "id": "mind-009", + "text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел.", + "images": [ + "../assets/channel-art/mindfulness-breath.webp" + ] + }, + { + "id": "mind-010", + "text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически.", + "images": [] + }, + { + "id": "mind-011", + "text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #11", + "images": [ + "../assets/channel-art/mindfulness-tea.webp" + ] + }, + { + "id": "mind-012", + "text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #12", + "images": [] + }, + { + "id": "mind-013", + "text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #13", + "images": [ + "../assets/channel-art/mindfulness-walk.webp" + ] + }, + { + "id": "mind-014", + "text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #14", + "images": [] + }, + { + "id": "mind-015", + "text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #15", + "images": [ + "../assets/channel-art/mindfulness-window.webp" + ] + }, + { + "id": "mind-016", + "text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца. · Напоминание #16", + "images": [] + }, + { + "id": "mind-017", + "text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание. · Напоминание #17", + "images": [ + "../assets/channel-art/mindfulness-breath.webp" + ] + }, + { + "id": "mind-018", + "text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу. · Напоминание #18", + "images": [] + }, + { + "id": "mind-019", + "text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел. · Напоминание #19", + "images": [ + "../assets/channel-art/mindfulness-tea.webp" + ] + }, + { + "id": "mind-020", + "text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически. · Напоминание #20", + "images": [] + }, + { + "id": "mind-021", + "text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #21", + "images": [ + "../assets/channel-art/mindfulness-walk.webp" + ] + }, + { + "id": "mind-022", + "text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #22", + "images": [] + }, + { + "id": "mind-023", + "text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #23", + "images": [ + "../assets/channel-art/mindfulness-window.webp" + ] + }, + { + "id": "mind-024", + "text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #24", + "images": [] + }, + { + "id": "mind-025", + "text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #25", + "images": [ + "../assets/channel-art/mindfulness-breath.webp", + "../assets/channel-art/mindfulness-tea.webp" + ] + }, + { + "id": "mind-026", + "text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца. · Напоминание #26", + "images": [] + }, + { + "id": "mind-027", + "text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание. · Напоминание #27", + "images": [ + "../assets/channel-art/mindfulness-tea.webp", + "../assets/channel-art/mindfulness-walk.webp" + ] + }, + { + "id": "mind-028", + "text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу. · Напоминание #28", + "images": [] + }, + { + "id": "mind-029", + "text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел. · Напоминание #29", + "images": [ + "../assets/channel-art/mindfulness-walk.webp", + "../assets/channel-art/mindfulness-window.webp" + ] + }, + { + "id": "mind-030", + "text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически. · Напоминание #30", + "images": [] + }, + { + "id": "mind-031", + "text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #31", + "images": [ + "../assets/channel-art/mindfulness-window.webp", + "../assets/channel-art/mindfulness-breath.webp" + ] + }, + { + "id": "mind-032", + "text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #32", + "images": [] + }, + { + "id": "mind-033", + "text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #33", + "images": [ + "../assets/channel-art/mindfulness-breath.webp", + "../assets/channel-art/mindfulness-tea.webp", + "../assets/channel-art/mindfulness-walk.webp" + ] + }, + { + "id": "mind-034", + "text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #34", + "images": [] + }, + { + "id": "mind-035", + "text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #35", + "images": [ + "../assets/channel-art/mindfulness-tea.webp", + "../assets/channel-art/mindfulness-walk.webp", + "../assets/channel-art/mindfulness-window.webp" + ] + }, + { + "id": "mind-036", + "text": "Во время разговора попробуй не готовить ответ заранее, а дослушать человека до конца. · Напоминание #36", + "images": [] + }, + { + "id": "mind-037", + "text": "Заметь три телесных ощущения прямо сейчас: температуру, опору и дыхание. · Напоминание #37", + "images": [] + }, + { + "id": "mind-038", + "text": "Если мысли бегут слишком быстро, не спорь с ними. Заметь: «сейчас много мыслей» — и верни внимание к телу. · Напоминание #38", + "images": [] + }, + { + "id": "mind-039", + "text": "Сегодня хотя бы один раз поешь без телефона и параллельных дел. · Напоминание #39", + "images": [] + }, + { + "id": "mind-040", + "text": "Перед сном вспомни один момент дня, в котором ты действительно присутствовала, а не действовала автоматически. · Напоминание #40", + "images": [] + }, + { + "id": "mind-041", + "text": "На минуту остановись и просто заметь дыхание. Не исправляй его — наблюдай. · Напоминание #41", + "images": [] + }, + { + "id": "mind-042", + "text": "Попробуй сегодня сделать одно привычное действие медленнее: выпить чай, пройти по комнате, открыть окно. · Напоминание #42", + "images": [] + }, + { + "id": "mind-043", + "text": "Когда появляется сильная эмоция, сначала назови её про себя. Наблюдение создаёт немного пространства между тобой и реакцией. · Напоминание #43", + "images": [] + }, + { + "id": "mind-044", + "text": "Не превращай осознанность в ещё одну обязанность. Достаточно иногда возвращаться к тому, что происходит прямо сейчас. · Напоминание #44", + "images": [] + }, + { + "id": "mind-045", + "text": "Послушай окружающие звуки одну минуту, не деля их на приятные и неприятные. · Напоминание #45", + "images": [] + } +] \ No newline at end of file diff --git a/tools/test-publisher/v2/channels/positive.raw.json b/tools/test-publisher/v2/channels/positive.raw.json new file mode 100644 index 00000000..140c0281 --- /dev/null +++ b/tools/test-publisher/v2/channels/positive.raw.json @@ -0,0 +1,271 @@ +[ + { + "id": "pos-001", + "text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут.", + "images": [ + "../assets/channel-art/positive-rest.webp" + ] + }, + { + "id": "pos-002", + "text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое.", + "images": [] + }, + { + "id": "pos-003", + "text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас?", + "images": [ + "../assets/channel-art/positive-spark.webp" + ] + }, + { + "id": "pos-004", + "text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом.", + "images": [] + }, + { + "id": "pos-005", + "text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой.", + "images": [ + "../assets/channel-art/positive-step.webp" + ] + }, + { + "id": "pos-006", + "text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно.", + "images": [] + }, + { + "id": "pos-007", + "text": "Если план сорвался, уменьши задачу, а не отменяй направление.", + "images": [ + "../assets/channel-art/positive-sun.webp" + ] + }, + { + "id": "pos-008", + "text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше.", + "images": [] + }, + { + "id": "pos-009", + "text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?»", + "images": [ + "../assets/channel-art/positive-rest.webp" + ] + }, + { + "id": "pos-010", + "text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила.", + "images": [] + }, + { + "id": "pos-011", + "text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #11", + "images": [ + "../assets/channel-art/positive-spark.webp" + ] + }, + { + "id": "pos-012", + "text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #12", + "images": [] + }, + { + "id": "pos-013", + "text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #13", + "images": [ + "../assets/channel-art/positive-step.webp" + ] + }, + { + "id": "pos-014", + "text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #14", + "images": [] + }, + { + "id": "pos-015", + "text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #15", + "images": [ + "../assets/channel-art/positive-sun.webp" + ] + }, + { + "id": "pos-016", + "text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно. · Напоминание #16", + "images": [] + }, + { + "id": "pos-017", + "text": "Если план сорвался, уменьши задачу, а не отменяй направление. · Напоминание #17", + "images": [ + "../assets/channel-art/positive-rest.webp" + ] + }, + { + "id": "pos-018", + "text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше. · Напоминание #18", + "images": [] + }, + { + "id": "pos-019", + "text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?» · Напоминание #19", + "images": [ + "../assets/channel-art/positive-spark.webp" + ] + }, + { + "id": "pos-020", + "text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила. · Напоминание #20", + "images": [] + }, + { + "id": "pos-021", + "text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #21", + "images": [ + "../assets/channel-art/positive-step.webp" + ] + }, + { + "id": "pos-022", + "text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #22", + "images": [] + }, + { + "id": "pos-023", + "text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #23", + "images": [ + "../assets/channel-art/positive-sun.webp" + ] + }, + { + "id": "pos-024", + "text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #24", + "images": [] + }, + { + "id": "pos-025", + "text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #25", + "images": [ + "../assets/channel-art/positive-rest.webp", + "../assets/channel-art/positive-spark.webp" + ] + }, + { + "id": "pos-026", + "text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно. · Напоминание #26", + "images": [] + }, + { + "id": "pos-027", + "text": "Если план сорвался, уменьши задачу, а не отменяй направление. · Напоминание #27", + "images": [ + "../assets/channel-art/positive-spark.webp", + "../assets/channel-art/positive-step.webp" + ] + }, + { + "id": "pos-028", + "text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше. · Напоминание #28", + "images": [] + }, + { + "id": "pos-029", + "text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?» · Напоминание #29", + "images": [ + "../assets/channel-art/positive-step.webp", + "../assets/channel-art/positive-sun.webp" + ] + }, + { + "id": "pos-030", + "text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила. · Напоминание #30", + "images": [] + }, + { + "id": "pos-031", + "text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #31", + "images": [ + "../assets/channel-art/positive-sun.webp", + "../assets/channel-art/positive-rest.webp" + ] + }, + { + "id": "pos-032", + "text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #32", + "images": [] + }, + { + "id": "pos-033", + "text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #33", + "images": [ + "../assets/channel-art/positive-rest.webp", + "../assets/channel-art/positive-spark.webp", + "../assets/channel-art/positive-step.webp" + ] + }, + { + "id": "pos-034", + "text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #34", + "images": [] + }, + { + "id": "pos-035", + "text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #35", + "images": [ + "../assets/channel-art/positive-spark.webp", + "../assets/channel-art/positive-step.webp", + "../assets/channel-art/positive-sun.webp" + ] + }, + { + "id": "pos-036", + "text": "Оставь сегодня немного места для чего-то просто приятного, даже если это не продуктивно. · Напоминание #36", + "images": [] + }, + { + "id": "pos-037", + "text": "Если план сорвался, уменьши задачу, а не отменяй направление. · Напоминание #37", + "images": [] + }, + { + "id": "pos-038", + "text": "Поблагодари себя за одно решение, которое сделало жизнь хоть немного лучше. · Напоминание #38", + "images": [] + }, + { + "id": "pos-039", + "text": "Попробуй заменить «мне ещё столько надо» на «что из этого действительно важно сегодня?» · Напоминание #39", + "images": [] + }, + { + "id": "pos-040", + "text": "Иногда прогресс выглядит не как рывок, а как то, что ты не бросила. · Напоминание #40", + "images": [] + }, + { + "id": "pos-041", + "text": "Не обязательно улучшать весь день. Иногда достаточно улучшить следующие десять минут. · Напоминание #41", + "images": [] + }, + { + "id": "pos-042", + "text": "Запиши одну маленькую вещь, которая сегодня получилась. Мозг легко замечает незавершённое и забывает завершённое. · Напоминание #42", + "images": [] + }, + { + "id": "pos-043", + "text": "Большая цель становится легче, если спросить: какой самый маленький шаг я могу сделать прямо сейчас? · Напоминание #43", + "images": [] + }, + { + "id": "pos-044", + "text": "Хорошее настроение не обязано появляться до действия. Иногда сначала идёт прогулка, звонок или маленькое дело — а настроение приходит следом. · Напоминание #44", + "images": [] + }, + { + "id": "pos-045", + "text": "Сравнивай сегодняшний шаг не с чужим результатом, а со своей вчерашней точкой. · Напоминание #45", + "images": [] + } +] \ No newline at end of file diff --git a/tools/test-publisher/v2/channels/wisdom.raw.json b/tools/test-publisher/v2/channels/wisdom.raw.json new file mode 100644 index 00000000..e6205779 --- /dev/null +++ b/tools/test-publisher/v2/channels/wisdom.raw.json @@ -0,0 +1,271 @@ +[ + { + "id": "wise-001", + "text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит.", + "images": [ + "../assets/channel-art/wisdom-light.webp" + ] + }, + { + "id": "wise-002", + "text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля.", + "images": [] + }, + { + "id": "wise-003", + "text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме.", + "images": [ + "../assets/channel-art/wisdom-moon.webp" + ] + }, + { + "id": "wise-004", + "text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна.", + "images": [] + }, + { + "id": "wise-005", + "text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории.", + "images": [ + "../assets/channel-art/wisdom-path.webp" + ] + }, + { + "id": "wise-006", + "text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас.", + "images": [] + }, + { + "id": "wise-007", + "text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить.", + "images": [ + "../assets/channel-art/wisdom-water.webp" + ] + }, + { + "id": "wise-008", + "text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее?", + "images": [] + }, + { + "id": "wise-009", + "text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития.", + "images": [ + "../assets/channel-art/wisdom-light.webp" + ] + }, + { + "id": "wise-010", + "text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день.", + "images": [] + }, + { + "id": "wise-011", + "text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #11", + "images": [ + "../assets/channel-art/wisdom-moon.webp" + ] + }, + { + "id": "wise-012", + "text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #12", + "images": [] + }, + { + "id": "wise-013", + "text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #13", + "images": [ + "../assets/channel-art/wisdom-path.webp" + ] + }, + { + "id": "wise-014", + "text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #14", + "images": [] + }, + { + "id": "wise-015", + "text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #15", + "images": [ + "../assets/channel-art/wisdom-water.webp" + ] + }, + { + "id": "wise-016", + "text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас. · Напоминание #16", + "images": [] + }, + { + "id": "wise-017", + "text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить. · Напоминание #17", + "images": [ + "../assets/channel-art/wisdom-light.webp" + ] + }, + { + "id": "wise-018", + "text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее? · Напоминание #18", + "images": [] + }, + { + "id": "wise-019", + "text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития. · Напоминание #19", + "images": [ + "../assets/channel-art/wisdom-moon.webp" + ] + }, + { + "id": "wise-020", + "text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день. · Напоминание #20", + "images": [] + }, + { + "id": "wise-021", + "text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #21", + "images": [ + "../assets/channel-art/wisdom-path.webp" + ] + }, + { + "id": "wise-022", + "text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #22", + "images": [] + }, + { + "id": "wise-023", + "text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #23", + "images": [ + "../assets/channel-art/wisdom-water.webp" + ] + }, + { + "id": "wise-024", + "text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #24", + "images": [] + }, + { + "id": "wise-025", + "text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #25", + "images": [ + "../assets/channel-art/wisdom-light.webp", + "../assets/channel-art/wisdom-moon.webp" + ] + }, + { + "id": "wise-026", + "text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас. · Напоминание #26", + "images": [] + }, + { + "id": "wise-027", + "text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить. · Напоминание #27", + "images": [ + "../assets/channel-art/wisdom-moon.webp", + "../assets/channel-art/wisdom-path.webp" + ] + }, + { + "id": "wise-028", + "text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее? · Напоминание #28", + "images": [] + }, + { + "id": "wise-029", + "text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития. · Напоминание #29", + "images": [ + "../assets/channel-art/wisdom-path.webp", + "../assets/channel-art/wisdom-water.webp" + ] + }, + { + "id": "wise-030", + "text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день. · Напоминание #30", + "images": [] + }, + { + "id": "wise-031", + "text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #31", + "images": [ + "../assets/channel-art/wisdom-water.webp", + "../assets/channel-art/wisdom-light.webp" + ] + }, + { + "id": "wise-032", + "text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #32", + "images": [] + }, + { + "id": "wise-033", + "text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #33", + "images": [ + "../assets/channel-art/wisdom-light.webp", + "../assets/channel-art/wisdom-moon.webp", + "../assets/channel-art/wisdom-path.webp" + ] + }, + { + "id": "wise-034", + "text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #34", + "images": [] + }, + { + "id": "wise-035", + "text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #35", + "images": [ + "../assets/channel-art/wisdom-moon.webp", + "../assets/channel-art/wisdom-path.webp", + "../assets/channel-art/wisdom-water.webp" + ] + }, + { + "id": "wise-036", + "text": "Многие медитативные традиции используют дыхание как якорь внимания именно потому, что оно всегда происходит сейчас. · Напоминание #36", + "images": [] + }, + { + "id": "wise-037", + "text": "Не обязательно принимать каждую мысль за истину. Мысль можно увидеть, признать и отпустить. · Напоминание #37", + "images": [] + }, + { + "id": "wise-038", + "text": "Полезный духовный вопрос на день: какое действие сегодня сделает меня немного внимательнее и добрее? · Напоминание #38", + "images": [] + }, + { + "id": "wise-039", + "text": "Простота — тоже практика. Иногда убрать одно лишнее обязательство полезнее, чем добавить новую технику саморазвития. · Напоминание #39", + "images": [] + }, + { + "id": "wise-040", + "text": "Внутренняя практика проверяется не красивыми идеями, а тем, как мы разговариваем с людьми в обычный день. · Напоминание #40", + "images": [] + }, + { + "id": "wise-041", + "text": "Буддийская практика напоминает: всё меняется. Полезно замечать не только то, что приходит, но и то, как оно проходит. · Напоминание #41", + "images": [] + }, + { + "id": "wise-042", + "text": "Стоическая мысль: отделяй то, на что можешь повлиять, от того, что сейчас вне твоего контроля. · Напоминание #42", + "images": [] + }, + { + "id": "wise-043", + "text": "В традициях созерцания тишина — не пустота, а возможность заметить то, что теряется в постоянном шуме. · Напоминание #43", + "images": [] + }, + { + "id": "wise-044", + "text": "Практика благодарности встречается во многих духовных традициях: сегодня назови три конкретные вещи, за которые благодарна. · Напоминание #44", + "images": [] + }, + { + "id": "wise-045", + "text": "Сострадание можно начать с малого: прежде чем осудить человека, допусти, что ты не знаешь всей его истории. · Напоминание #45", + "images": [] + } +] \ No newline at end of file diff --git a/tools/test-publisher/v2/config.example.json b/tools/test-publisher/v2/config.example.json new file mode 100644 index 00000000..9f90c6b7 --- /dev/null +++ b/tools/test-publisher/v2/config.example.json @@ -0,0 +1,12 @@ +{ + "server_ws": "ws://127.0.0.1:8080/ws", + "blockchain_name": "PUT_TEST_USER_BLOCKCHAIN_NAME_HERE", + "channel_name": "jokes", + "channel_root_block_number": 123, + "key_file": "keys/blockchain.json", + "queue_file": "queue/jokes.prepared.json", + "state_file": "state.json", + "min_interval_seconds": 1800, + "max_interval_seconds": 10800, + "loop_queue": false +} diff --git a/tools/test-publisher/v2/config.multi.example.json b/tools/test-publisher/v2/config.multi.example.json new file mode 100644 index 00000000..7d003983 --- /dev/null +++ b/tools/test-publisher/v2/config.multi.example.json @@ -0,0 +1,52 @@ +{ + "server_ws": "wss://t3.shineup.me/ws", + "blockchain_name": "PUT_TEST_USER_BLOCKCHAIN_NAME_HERE", + "key_file": "keys/blockchain.json", + "state_file": "multi-state.json", + "min_interval_seconds": 1800, + "max_interval_seconds": 10800, + "loop_queue": false, + "channels": [ + { + "channel_name": "jokes", + "channel_root_block_number": 101, + "queue_file": "channels/jokes.prepared.json", + "min_interval_seconds": 3600, + "max_interval_seconds": 10800 + }, + { + "channel_name": "mindfulness", + "channel_root_block_number": 102, + "queue_file": "channels/mindfulness.prepared.json", + "min_interval_seconds": 10800, + "max_interval_seconds": 25200 + }, + { + "channel_name": "positive", + "channel_root_block_number": 103, + "queue_file": "channels/positive.prepared.json", + "min_interval_seconds": 10800, + "max_interval_seconds": 25200 + }, + { + "channel_name": "friends", + "channel_root_block_number": 104, + "queue_file": "channels/friends.prepared.json", + "min_interval_seconds": 10800, + "max_interval_seconds": 25200 + }, + { + "channel_name": "wisdom", + "channel_root_block_number": 105, + "queue_file": "channels/wisdom.prepared.json", + "min_interval_seconds": 10800, + "max_interval_seconds": 25200 + } + ], + "timezone": "Europe/Warsaw", + "quiet_hours": { + "enabled": true, + "start": "22:00", + "end": "08:00" + } +} \ No newline at end of file diff --git a/tools/test-publisher/v2/keys/README.txt b/tools/test-publisher/v2/keys/README.txt new file mode 100644 index 00000000..fc6739ce --- /dev/null +++ b/tools/test-publisher/v2/keys/README.txt @@ -0,0 +1,4 @@ +Put secrets here locally only; never commit them. +blockchain.json - SHiNE/Solana 64-byte JSON key array used by publisher +arweave-wallet.json - Arweave JWK used ONLY by prepare_queue.py to upload images +Recommended: chmod 600 keys/*.json diff --git a/tools/test-publisher/v2/prepare.config.example.json b/tools/test-publisher/v2/prepare.config.example.json new file mode 100644 index 00000000..345ecaca --- /dev/null +++ b/tools/test-publisher/v2/prepare.config.example.json @@ -0,0 +1,9 @@ +{ + "queue_file": "queue/jokes.raw.json", + "prepared_queue_file": "queue/jokes.prepared.json", + "upload_state_file": "upload-state.json", + "arweave": { + "wallet_file": "keys/arweave-wallet.json", + "gateway": "https://arweave.net" + } +} diff --git a/tools/test-publisher/v2/prepare.multi.example.json b/tools/test-publisher/v2/prepare.multi.example.json new file mode 100644 index 00000000..6833b648 --- /dev/null +++ b/tools/test-publisher/v2/prepare.multi.example.json @@ -0,0 +1,34 @@ +{ + "upload_state_file": "upload-state.json", + "arweave": { + "wallet_file": "keys/arweave-wallet.json", + "gateway": "https://arweave.net" + }, + "channels": [ + { + "name": "jokes", + "queue_file": "channels/jokes.raw.json", + "prepared_queue_file": "channels/jokes.prepared.json" + }, + { + "name": "mindfulness", + "queue_file": "channels/mindfulness.raw.json", + "prepared_queue_file": "channels/mindfulness.prepared.json" + }, + { + "name": "positive", + "queue_file": "channels/positive.raw.json", + "prepared_queue_file": "channels/positive.prepared.json" + }, + { + "name": "friends", + "queue_file": "channels/friends.raw.json", + "prepared_queue_file": "channels/friends.prepared.json" + }, + { + "name": "wisdom", + "queue_file": "channels/wisdom.raw.json", + "prepared_queue_file": "channels/wisdom.prepared.json" + } + ] +} \ No newline at end of file diff --git a/tools/test-publisher/v2/prepare/prepare_all_channels.py b/tools/test-publisher/v2/prepare/prepare_all_channels.py new file mode 100644 index 00000000..ee91cab6 --- /dev/null +++ b/tools/test-publisher/v2/prepare/prepare_all_channels.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +import argparse,json,hashlib,mimetypes,time +from pathlib import Path +import requests +try: import arweave +except ImportError: arweave=None + +def atomic(path,obj): + p=Path(path); p.parent.mkdir(parents=True,exist_ok=True) + t=p.with_suffix(p.suffix+'.tmp'); t.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf8'); t.replace(p) + +def exists(gateway,txid,timeout=15): + url=f"{gateway.rstrip('/')}/{txid}" + try: + r=requests.head(url,allow_redirects=True,timeout=timeout) + if r.status_code in (200,204): return True + if r.status_code in (405,501): + r=requests.get(url,headers={'Range':'bytes=0-0'},timeout=timeout) + return r.status_code in (200,206) + return False + except requests.RequestException: return False + +def main(): + ap=argparse.ArgumentParser(description='Prepare images for all SHiNE test channels') + ap.add_argument('--config',default='prepare.config.json'); a=ap.parse_args() + cfg=json.load(open(a.config,encoding='utf8')); base=Path(a.config).resolve().parent + rp=lambda s:(Path(s) if Path(s).is_absolute() else (base/s).resolve()) + gateway=cfg['arweave'].get('gateway','https://arweave.net').rstrip('/') + statep=rp(cfg.get('upload_state_file','upload-state.json')) + state=json.loads(statep.read_text()) if statep.exists() else {'version':2,'files':{}} + wallet=None + def ensure_wallet(): + nonlocal wallet + if wallet is None: + if arweave is None: raise SystemExit('pip install -r requirements.txt') + wallet=arweave.Wallet(str(rp(cfg['arweave']['wallet_file']))) + return wallet + + summary={'channels':0,'posts':0,'images':0,'verified_existing':0,'uploaded':0} + for ch in cfg['channels']: + rawp=rp(ch['queue_file']); outp=rp(ch['prepared_queue_file']) + items=json.loads(rawp.read_text(encoding='utf8')); prepared=[]; summary['channels']+=1 + for n,item in enumerate(items,1): + refs=[] + for rel in item.get('images',[]): + fp=(rawp.parent/rel).resolve(); data=fp.read_bytes(); sha=hashlib.sha256(data).hexdigest() + summary['images']+=1; rec=state['files'].get(sha); txid=None + if rec and exists(gateway,rec['id']): + txid=rec['id']; summary['verified_existing']+=1 + print(f"[{ch['name']}:{n}] verified {fp.name} -> {txid}") + else: + ctype=mimetypes.guess_type(fp.name)[0] or 'application/octet-stream' + tx=arweave.Transaction(ensure_wallet(),data=data) + tx.add_tag('Content-Type',ctype); tx.add_tag('App-Name','SHiNE-Test-Publisher'); tx.add_tag('SHA-256',sha) + tx.sign(); tx.send(); txid=tx.id; summary['uploaded']+=1 + state['files'][sha]={'id':txid,'file':str(fp),'uploaded_at':int(time.time())}; atomic(statep,state) + print(f"[{ch['name']}:{n}] uploaded {fp.name} -> {txid}") + refs.append({'arweave_id':txid,'url':f'{gateway}/{txid}','sha256':sha}) + text=item['text'].rstrip() + if refs: text += '\n\n' + '\n'.join(x['url'] for x in refs) + prepared.append({'id':item.get('id',str(n)),'text':text,'images':refs}) + summary['posts']+=1 + atomic(outp,prepared) + print(f"Ready {ch['name']}: {outp} ({len(prepared)} posts)") + print(json.dumps(summary,ensure_ascii=False,indent=2)) +if __name__=='__main__': main() diff --git a/tools/test-publisher/v2/prepare/prepare_queue.py b/tools/test-publisher/v2/prepare/prepare_queue.py new file mode 100644 index 00000000..ef0bf554 --- /dev/null +++ b/tools/test-publisher/v2/prepare/prepare_queue.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +import argparse,json,hashlib,mimetypes,sys,time +from pathlib import Path +import requests +try: + import arweave +except ImportError: + arweave=None + +def atomic(path,obj): + p=Path(path); t=p.with_suffix(p.suffix+'.tmp'); t.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf8'); t.replace(p) + +def main(): + ap=argparse.ArgumentParser(description='Upload all queue images to Arweave and build prepared queue') + ap.add_argument('--config',default='prepare.config.json'); a=ap.parse_args() + cfg=json.load(open(a.config,encoding='utf8')); base=Path(a.config).resolve().parent + def rp(s): + p=Path(s); return p if p.is_absolute() else (base/p).resolve() + rawp=rp(cfg['queue_file']); outp=rp(cfg['prepared_queue_file']); walletp=rp(cfg['arweave']['wallet_file']); gateway=cfg['arweave'].get('gateway','https://arweave.net').rstrip('/') + statep=rp(cfg.get('upload_state_file','upload-state.json')); state=json.loads(statep.read_text()) if statep.exists() else {'files':{}} + if arweave is None: raise SystemExit('Install requirements first: pip install -r requirements.txt') + wallet=arweave.Wallet(str(walletp)) + items=json.loads(rawp.read_text(encoding='utf8')); prepared=[] + for n,item in enumerate(items,1): + urls=[]; ids=[] + for rel in item.get('images',[]): + fp=(rawp.parent/rel).resolve(); data=fp.read_bytes(); sha=hashlib.sha256(data).hexdigest(); old=state['files'].get(sha) + if old: txid=old['id']; print(f'[{n}] cached {fp.name} -> {txid}') + else: + ctype=mimetypes.guess_type(fp.name)[0] or 'application/octet-stream' + tx=arweave.Transaction(wallet, data=data); tx.add_tag('Content-Type',ctype); tx.add_tag('App-Name','SHiNE-Test-Publisher'); tx.add_tag('SHA-256',sha); tx.sign(); tx.send(); txid=tx.id + state['files'][sha]={'id':txid,'file':fp.name,'uploaded_at':int(time.time())}; atomic(statep,state); print(f'[{n}] uploaded {fp.name} -> {txid}') + ids.append(txid); urls.append(f'{gateway}/{txid}') + text=item['text'].rstrip() + if urls: + # Current SHiNE POST payload is text, so URLs are appended as ordinary lines. + text += '\n\n' + '\n'.join(urls) + prepared.append({'id':item.get('id',str(n)),'text':text,'images':[{'arweave_id':i,'url':u} for i,u in zip(ids,urls)]}) + atomic(outp,prepared) + print(f'Ready: {outp} ({len(prepared)} posts)') +if __name__=='__main__': main() diff --git a/tools/test-publisher/v2/publisher/publisher.py b/tools/test-publisher/v2/publisher/publisher.py new file mode 100644 index 00000000..013a87fb --- /dev/null +++ b/tools/test-publisher/v2/publisher/publisher.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +import argparse, asyncio, base64, hashlib, json, os, random, struct, sys, time, uuid +from pathlib import Path +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from websockets.asyncio.client import connect + +ZERO32 = bytes(32) +FRAME_CODE_V1=1; TEXT_TYPE=1; TEXT_POST=10; VERSION=1 + +def atomic_json(path, obj): + path=Path(path); path.parent.mkdir(parents=True, exist_ok=True) + tmp=path.with_suffix(path.suffix+'.tmp') + tmp.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8') + os.replace(tmp,path) + +def load_json(path, default=None): + p=Path(path) + return json.loads(p.read_text(encoding='utf-8')) if p.exists() else default + +def avro_long(n): + n=(n<<1) ^ (n>>63); out=bytearray() + while n & ~0x7f: out.append((n&0x7f)|0x80); n >>= 7 + out.append(n); return bytes(out) + +def avro_tags(tags): + out=bytearray() + if tags: + out += avro_long(len(tags)) + for k,v in tags: + kb=k.encode(); vb=v.encode(); out+=avro_long(len(kb))+kb+avro_long(len(vb))+vb + out += avro_long(0); return bytes(out) + +def deep_hash(x): + H=lambda b: hashlib.sha384(b).digest() + if isinstance(x,(bytes,bytearray)): + b=bytes(x); return H(H(f'blob{len(b)}'.encode())+H(b)) + acc=H(f'list{len(x)}'.encode()) + for child in x: acc=H(acc+deep_hash(child)) + return acc + +def signing_message(owner,tags,data): + raw=avro_tags(tags) + return deep_hash([b'dataitem',b'1',b'2',owner,b'',b'',raw,data]) + +def data_item(priv64,tags,data): + if len(priv64) not in (32,64): raise ValueError('Solana key JSON must contain 32 or 64 bytes') + seed=priv64[:32]; owner=priv64[32:64] if len(priv64)==64 else Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw() + sig=Ed25519PrivateKey.from_private_bytes(seed).sign(signing_message(owner,tags,data)) + rawtags=avro_tags(tags) + return struct.pack('65535: raise ValueError('post text too long') + return struct.pack('>ii32siH',line_code,prev_line_num,prev_line_hash,this_line_num,len(tb))+tb + +def frame(prev_hash, block_num, body, ts=None): + ts=int(time.time()) if ts is None else int(ts); size=56+len(body) + return struct.pack('>H32siiqHHH',FRAME_CODE_V1,prev_hash,size,block_num,ts,TEXT_TYPE,TEXT_POST,VERSION)+body + +def h32(b): return hashlib.sha256(b).digest() +def hx(b): return b.hex() + +async def ws_call(url, op, payload, timeout=20): + req={'op':op,'requestId':str(uuid.uuid4()),'payload':payload} + async with connect(url, open_timeout=timeout, close_timeout=5) as ws: + await ws.send(json.dumps(req,separators=(',',':'))) + end=time.monotonic()+timeout + while True: + left=end-time.monotonic() + if left<=0: raise TimeoutError(op) + msg=await asyncio.wait_for(ws.recv(),left) + obj=json.loads(msg) + if obj.get('requestId')==req['requestId']: return obj + +async def head(cfg): + r=await ws_call(cfg['server_ws'],'ListBlockchainHeads',{}) + if r.get('status')!=200: raise RuntimeError(f'ListBlockchainHeads: {r}') + for x in r.get('payload',{}).get('blockchains',[]): + if x.get('blockchainName')==cfg['blockchain_name']: + return int(x.get('lastBlockNumber',-1)), x.get('lastBlockHash','') + raise RuntimeError('blockchain not found on server') + +async def block(cfg,n): + r=await ws_call(cfg['server_ws'],'GetBlockchainBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':n}) + if r.get('status')!=200: raise RuntimeError(f'GetBlockchainBlock({n}): {r}') + p=r.get('payload',r) + return p.get('blockHash') or r.get('blockHash'), p.get('blockBytesB64') or r.get('blockBytesB64') + +async def channel_tail(cfg, root_hash): + r=await ws_call(cfg['server_ws'],'GetChannelMessages',{'channel':{'ownerBlockchainName':cfg['blockchain_name'],'channelRootBlockNumber':cfg['channel_root_block_number'],'channelRootBlockHash':root_hash},'limit':1,'sort':'desc'}) + if r.get('status')!=200: raise RuntimeError(f'GetChannelMessages: {r}') + items=r.get('payload',{}).get('messages',[]) + if not items: return 0, ZERO32 + m=items[0]; ref=m.get('messageRef') or {} + return int(m.get('lineStep') or 0), bytes.fromhex(ref.get('blockHash','00'*32)) + +def read_key(path): + a=load_json(path) + if not isinstance(a,list): raise ValueError('key file must be Solana JSON byte array') + return bytes(int(x)&255 for x in a) + +def queue_items(cfg): + q=load_json(cfg['queue_file']) + if not isinstance(q,list) or not q: raise ValueError('queue must be non-empty JSON array') + for x in q: + if not isinstance(x,dict) or not str(x.get('text','')).strip(): raise ValueError('each queue item needs text') + return q + +def delay(cfg): return random.randint(int(cfg['min_interval_seconds']),int(cfg['max_interval_seconds'])) + +async def init_state(cfg): + bn,bhash=await head(cfg) + root_hash,_=await block(cfg,int(cfg['channel_root_block_number'])) + line_num,line_hash=await channel_tail(cfg,root_hash) + st={'version':1,'next_index':0,'last_block_number':bn,'last_block_hash':bhash,'last_line_number':line_num,'last_line_hash':hx(line_hash),'next_publish_at':int(time.time())+delay(cfg),'pending':None,'published_total':0} + atomic_json(cfg['state_file'],st); return st + +async def reconcile(cfg,st): + p=st.get('pending') + bn,bhash=await head(cfg) + if p: + if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower(): + st['last_block_number']=bn; st['last_block_hash']=bhash; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st); print('Recovered accepted pending block',bn) + elif bn==p['previous_block_number'] and bhash.lower()==p['previous_block_hash'].lower(): + print('Pending block was not accepted; it will be retried') + else: raise RuntimeError(f'chain changed while pending: server={bn}:{bhash} state={p}') + else: + if bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): raise RuntimeError('server chain head differs from publisher state; refusing to fork') + return st + +async def publish_one(cfg,st,items): + if st.get('pending'): + p=st['pending']; raw=base64.b64decode(p['data_item_b64']); block_num=p['block_number']; prev=p['previous_block_hash'] + else: + idx=int(st['next_index']) + if idx>=len(items): + if cfg.get('loop_queue',False): idx=0 + else: print('Queue exhausted. Nothing to publish.'); return False + text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1; prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(st['last_line_number'])+1 + body=post_body(int(cfg['channel_root_block_number']),int(st['last_line_number']),bytes.fromhex(st['last_line_hash']),this_line,text) + fr=frame(prev_hash,block_num,body); block_hash=h32(fr); tags=[('App','test5590'),('c',cfg['channel_name'].strip().lower())] + raw=data_item(read_key(cfg['key_file']),tags,fr) + nextidx=idx+1 + p={'queue_index':idx,'next_index_after':nextidx,'block_number':block_num,'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()} + st['pending']=p; atomic_json(cfg['state_file'],st); prev=st['last_block_hash'] + r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,'prevBlockHash':prev,'blockBytesB64':base64.b64encode(raw).decode()},timeout=30) + if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}') + server_hash=(r.get('payload') or {}).get('serverLastBlockHash') or (r.get('payload') or {}).get('serverLastGlobalHash') + if server_hash and server_hash.lower()!=p['block_hash'].lower(): raise RuntimeError('server accepted different hash') + st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st) + print(f"Published queue[{p['queue_index']}] as block #{block_num}; next at {time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(st['next_publish_at']))}") + return True + +async def main_async(args): + cfg=load_json(args.config); required=['server_ws','blockchain_name','channel_name','channel_root_block_number','key_file','queue_file','state_file','min_interval_seconds','max_interval_seconds'] + for k in required: + if k not in cfg: raise ValueError('missing config: '+k) + base=Path(args.config).resolve().parent + for k in ('key_file','queue_file','state_file'): + p=Path(cfg[k]); cfg[k]=str(p if p.is_absolute() else base/p) + items=queue_items(cfg) + st=load_json(cfg['state_file']) + if st is None: st=await init_state(cfg); print('Initialized state from server/channel') + st=await reconcile(cfg,st) + if args.command=='status': print(json.dumps(st,ensure_ascii=False,indent=2)); return + if args.command=='publish-now': await publish_one(cfg,st,items); return + while True: + st=await reconcile(cfg,load_json(cfg['state_file'])) + wait=max(0,int(st.get('next_publish_at',0))-int(time.time())) + if wait: print(f'Waiting {wait}s'); await asyncio.sleep(wait) + ok=await publish_one(cfg,st,items) + if not ok: await asyncio.sleep(3600) + +def main(): + ap=argparse.ArgumentParser(description='SHiNE test channel publisher') + ap.add_argument('command',choices=['run','status','publish-now'],nargs='?',default='run'); ap.add_argument('--config',default='config.json') + a=ap.parse_args() + try: asyncio.run(main_async(a)) + except KeyboardInterrupt: pass + except Exception as e: print('ERROR:',e,file=sys.stderr); sys.exit(1) +if __name__=='__main__': main() diff --git a/tools/test-publisher/v2/publisher/publisher_multi.py b/tools/test-publisher/v2/publisher/publisher_multi.py new file mode 100644 index 00000000..fe33f91c --- /dev/null +++ b/tools/test-publisher/v2/publisher/publisher_multi.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +import argparse, asyncio, base64, hashlib, json, os, random, struct, sys, time, uuid +from pathlib import Path +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from websockets.asyncio.client import connect + +ZERO32 = bytes(32) +FRAME_CODE_V1=1; TEXT_TYPE=1; TEXT_POST=10; VERSION=1 + +def atomic_json(path, obj): + path=Path(path); path.parent.mkdir(parents=True, exist_ok=True) + tmp=path.with_suffix(path.suffix+'.tmp') + tmp.write_text(json.dumps(obj,ensure_ascii=False,indent=2),encoding='utf-8') + os.replace(tmp,path) + +def load_json(path, default=None): + p=Path(path) + return json.loads(p.read_text(encoding='utf-8')) if p.exists() else default + +def avro_long(n): + n=(n<<1) ^ (n>>63); out=bytearray() + while n & ~0x7f: out.append((n&0x7f)|0x80); n >>= 7 + out.append(n); return bytes(out) + +def avro_tags(tags): + out=bytearray() + if tags: + out += avro_long(len(tags)) + for k,v in tags: + kb=k.encode(); vb=v.encode(); out+=avro_long(len(kb))+kb+avro_long(len(vb))+vb + out += avro_long(0); return bytes(out) + +def deep_hash(x): + H=lambda b: hashlib.sha384(b).digest() + if isinstance(x,(bytes,bytearray)): + b=bytes(x); return H(H(f'blob{len(b)}'.encode())+H(b)) + acc=H(f'list{len(x)}'.encode()) + for child in x: acc=H(acc+deep_hash(child)) + return acc + +def signing_message(owner,tags,data): + raw=avro_tags(tags) + return deep_hash([b'dataitem',b'1',b'2',owner,b'',b'',raw,data]) + +def data_item(priv64,tags,data): + if len(priv64) not in (32,64): raise ValueError('Solana key JSON must contain 32 or 64 bytes') + seed=priv64[:32]; owner=priv64[32:64] if len(priv64)==64 else Ed25519PrivateKey.from_private_bytes(seed).public_key().public_bytes_raw() + sig=Ed25519PrivateKey.from_private_bytes(seed).sign(signing_message(owner,tags,data)) + rawtags=avro_tags(tags) + return struct.pack('65535: raise ValueError('post text too long') + return struct.pack('>ii32siH',line_code,prev_line_num,prev_line_hash,this_line_num,len(tb))+tb + +def frame(prev_hash, block_num, body, ts=None): + ts=int(time.time()) if ts is None else int(ts); size=56+len(body) + return struct.pack('>H32siiqHHH',FRAME_CODE_V1,prev_hash,size,block_num,ts,TEXT_TYPE,TEXT_POST,VERSION)+body + +def h32(b): return hashlib.sha256(b).digest() +def hx(b): return b.hex() + +async def ws_call(url, op, payload, timeout=20): + req={'op':op,'requestId':str(uuid.uuid4()),'payload':payload} + async with connect(url, open_timeout=timeout, close_timeout=5) as ws: + await ws.send(json.dumps(req,separators=(',',':'))) + end=time.monotonic()+timeout + while True: + left=end-time.monotonic() + if left<=0: raise TimeoutError(op) + msg=await asyncio.wait_for(ws.recv(),left) + obj=json.loads(msg) + if obj.get('requestId')==req['requestId']: return obj + +async def head(cfg): + r=await ws_call(cfg['server_ws'],'ListBlockchainHeads',{}) + if r.get('status')!=200: raise RuntimeError(f'ListBlockchainHeads: {r}') + for x in r.get('payload',{}).get('blockchains',[]): + if x.get('blockchainName')==cfg['blockchain_name']: + return int(x.get('lastBlockNumber',-1)), x.get('lastBlockHash','') + raise RuntimeError('blockchain not found on server') + +async def block(cfg,n): + r=await ws_call(cfg['server_ws'],'GetBlockchainBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':n}) + if r.get('status')!=200: raise RuntimeError(f'GetBlockchainBlock({n}): {r}') + p=r.get('payload',r) + return p.get('blockHash') or r.get('blockHash'), p.get('blockBytesB64') or r.get('blockBytesB64') + +async def channel_tail(cfg, root_hash): + r=await ws_call(cfg['server_ws'],'GetChannelMessages',{'channel':{'ownerBlockchainName':cfg['blockchain_name'],'channelRootBlockNumber':cfg['channel_root_block_number'],'channelRootBlockHash':root_hash},'limit':1,'sort':'desc'}) + if r.get('status')!=200: raise RuntimeError(f'GetChannelMessages: {r}') + items=r.get('payload',{}).get('messages',[]) + if not items: return int(cfg['channel_root_block_number']), bytes.fromhex(root_hash), -1 + m=items[0]; ref=m.get('messageRef') or {} + return int(ref.get('blockNumber')), bytes.fromhex(ref.get('blockHash','00'*32)), int(m.get('lineStep') or 0) + +def read_key(path): + a=load_json(path) + if not isinstance(a,list): raise ValueError('key file must be Solana JSON byte array') + return bytes(int(x)&255 for x in a) + +def queue_items(cfg): + q=load_json(cfg['queue_file']) + if not isinstance(q,list) or not q: raise ValueError('queue must be non-empty JSON array') + for x in q: + if not isinstance(x,dict) or not str(x.get('text','')).strip(): raise ValueError('each queue item needs text') + return q + +def delay(cfg): return random.randint(int(cfg['min_interval_seconds']),int(cfg['max_interval_seconds'])) + +async def init_state(cfg): + bn,bhash=await head(cfg) + root_hash,_=await block(cfg,int(cfg['channel_root_block_number'])) + line_num,line_hash=await channel_tail(cfg,root_hash) + st={'version':1,'next_index':0,'last_block_number':bn,'last_block_hash':bhash,'last_line_number':line_num,'last_line_hash':hx(line_hash),'next_publish_at':int(time.time())+delay(cfg),'pending':None,'published_total':0} + atomic_json(cfg['state_file'],st); return st + +async def reconcile(cfg,st): + p=st.get('pending') + bn,bhash=await head(cfg) + if p: + if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower(): + st['last_block_number']=bn; st['last_block_hash']=bhash; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st); print('Recovered accepted pending block',bn) + elif bn==p['previous_block_number'] and bhash.lower()==p['previous_block_hash'].lower(): + print('Pending block was not accepted; it will be retried') + else: raise RuntimeError(f'chain changed while pending: server={bn}:{bhash} state={p}') + else: + if bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): raise RuntimeError('server chain head differs from publisher state; refusing to fork') + return st + +async def publish_one(cfg,st,items): + if st.get('pending'): + p=st['pending']; raw=base64.b64decode(p['data_item_b64']); block_num=p['block_number']; prev=p['previous_block_hash'] + else: + idx=int(st['next_index']) + if idx>=len(items): + if cfg.get('loop_queue',False): idx=0 + else: print('Queue exhausted. Nothing to publish.'); return False + text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1; prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(st['last_line_number'])+1 + body=post_body(int(cfg['channel_root_block_number']),int(st['last_line_number']),bytes.fromhex(st['last_line_hash']),this_line,text) + fr=frame(prev_hash,block_num,body); block_hash=h32(fr); tags=[('App','test5590'),('c',cfg['channel_name'].strip().lower())] + raw=data_item(read_key(cfg['key_file']),tags,fr) + nextidx=idx+1 + p={'queue_index':idx,'next_index_after':nextidx,'block_number':block_num,'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'],'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()} + st['pending']=p; atomic_json(cfg['state_file'],st); prev=st['last_block_hash'] + r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num,'prevBlockHash':prev,'blockBytesB64':base64.b64encode(raw).decode()},timeout=30) + if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}') + server_hash=(r.get('payload') or {}).get('serverLastBlockHash') or (r.get('payload') or {}).get('serverLastGlobalHash') + if server_hash and server_hash.lower()!=p['block_hash'].lower(): raise RuntimeError('server accepted different hash') + st['last_block_number']=block_num; st['last_block_hash']=p['block_hash']; st['last_line_number']=p['this_line_number']; st['last_line_hash']=p['block_hash']; st['next_index']=p['next_index_after']; st['published_total']=int(st.get('published_total',0))+1; st['pending']=None; st['next_publish_at']=int(time.time())+delay(cfg); atomic_json(cfg['state_file'],st) + print(f"Published queue[{p['queue_index']}] as block #{block_num}; next at {time.strftime('%Y-%m-%d %H:%M:%S',time.localtime(st['next_publish_at']))}") + return True + + +async def init_multi_state(cfg): + bn,bhash=await head(cfg) + chans={} + now=int(time.time()) + for ch in cfg['channels']: + local=dict(cfg); local.update(ch) + root_hash,_=await block(local,int(ch['channel_root_block_number'])) + line_block,line_hash,line_step=await channel_tail(local,root_hash) + chans[ch['channel_name']]={'next_index':0,'last_line_block_number':line_block, + 'last_line_number':line_step,'last_line_hash':hx(line_hash), + 'next_publish_at':next_channel_time(cfg,ch,now), + 'published_total':0} + st={'version':2,'last_block_number':bn,'last_block_hash':bhash,'pending':None,'channels':chans} + atomic_json(cfg['state_file'],st); return st + +def channel_delay(cfg,ch): + return random.randint(int(ch.get('min_interval_seconds',cfg['min_interval_seconds'])),int(ch.get('max_interval_seconds',cfg['max_interval_seconds']))) + +def _hm(v): + h,m=str(v).split(':',1); return int(h),int(m) + +def _quiet(dt,ch,cfg): + q=ch.get('quiet_hours',cfg.get('quiet_hours',{})) + if not q or not q.get('enabled',True): return False + sh,sm=_hm(q.get('start','22:00')); eh,em=_hm(q.get('end','08:00')) + t=dt.hour*60+dt.minute; a=sh*60+sm; b=eh*60+em + return (a<=t or tb else (a<=t0: + q=ch.get('quiet_hours',cfg.get('quiet_hours',{})) + if not q or not q.get('enabled',True): + return int((dt+timedelta(seconds=remain)).timestamp()) + sh,sm=_hm(q.get('start','22:00')); eh,em=_hm(q.get('end','08:00')) + if _quiet(dt,ch,cfg): + end=dt.replace(hour=eh,minute=em,second=0,microsecond=0) + if end<=dt: end+=timedelta(days=1) + dt=end; continue + qs=dt.replace(hour=sh,minute=sm,second=0,microsecond=0) + if qs<=dt: qs+=timedelta(days=1) + usable=max(0,int((qs-dt).total_seconds())) + if remain<=usable: return int((dt+timedelta(seconds=remain)).timestamp()) + remain-=usable; dt=qs + return int(dt.timestamp()) + +def next_channel_time(cfg,ch,start_ts=None): + return active_deadline(cfg,ch,channel_delay(cfg,ch),start_ts) + +async def reconcile_multi(cfg,st): + bn,bhash=await head(cfg); p=st.get('pending') + if p: + cs=st['channels'][p['channel_name']] + if bn==p['block_number'] and bhash.lower()==p['block_hash'].lower(): + st['last_block_number']=bn; st['last_block_hash']=bhash + cs['last_line_block_number']=p['block_number']; cs['last_line_number']=p['this_line_number']; cs['last_line_hash']=p['block_hash'] + cs['next_index']=p['next_index_after']; cs['published_total']=int(cs.get('published_total',0))+1 + ch=next(x for x in cfg['channels'] if x['channel_name']==p['channel_name']) + cs['next_publish_at']=next_channel_time(cfg,ch); st['pending']=None; atomic_json(cfg['state_file'],st) + elif bn!=p['previous_block_number'] or bhash.lower()!=p['previous_block_hash'].lower(): + raise RuntimeError('global chain changed while a pending block exists; refusing to fork') + elif bn!=st['last_block_number'] or bhash.lower()!=str(st['last_block_hash']).lower(): + raise RuntimeError('server global chain head differs from publisher state; refusing to fork') + return st + +async def publish_channel(cfg,st,ch,force=False): + cs=st['channels'][ch['channel_name']]; items=queue_items(ch) + idx=int(cs['next_index']) + if idx>=len(items): + if ch.get('loop_queue',cfg.get('loop_queue',False)): idx=0 + else: + cs['next_publish_at']=int(time.time())+3600; atomic_json(cfg['state_file'],st); return False + text=items[idx]['text'].strip(); block_num=int(st['last_block_number'])+1 + prev_hash=bytes.fromhex(st['last_block_hash']); this_line=int(cs['last_line_number'])+1 + prev_line_block=int(cs.get('last_line_block_number', ch['channel_root_block_number'])) + body=post_body(int(ch['channel_root_block_number']),prev_line_block,bytes.fromhex(cs['last_line_hash']),this_line,text) + fr=frame(prev_hash,block_num,body); block_hash=h32(fr) + tags=[('App','test5590'),('c',ch['channel_name'].strip().lower())] + raw=data_item(read_key(cfg['key_file']),tags,fr) + p={'channel_name':ch['channel_name'],'queue_index':idx,'next_index_after':idx+1,'block_number':block_num, + 'block_hash':hx(block_hash),'previous_block_number':st['last_block_number'],'previous_block_hash':st['last_block_hash'], + 'this_line_number':this_line,'data_item_b64':base64.b64encode(raw).decode()} + st['pending']=p; atomic_json(cfg['state_file'],st) + r=await ws_call(cfg['server_ws'],'AddBlock',{'blockchainName':cfg['blockchain_name'],'blockNumber':block_num, + 'prevBlockHash':st['last_block_hash'],'blockBytesB64':base64.b64encode(raw).decode()},timeout=30) + if r.get('status')!=200: raise RuntimeError(f'AddBlock failed: {r}') + st['last_block_number']=block_num; st['last_block_hash']=p['block_hash'] + cs['last_line_block_number']=block_num; cs['last_line_number']=this_line; cs['last_line_hash']=p['block_hash']; cs['next_index']=idx+1 + cs['published_total']=int(cs.get('published_total',0))+1; cs['next_publish_at']=next_channel_time(cfg,ch) + st['pending']=None; atomic_json(cfg['state_file'],st) + print(f"Published {ch['channel_name']}[{idx}] as global block #{block_num}") + return True + +async def main_async(args): + cfg=load_json(args.config); base=Path(args.config).resolve().parent + for k in ('key_file','state_file'): + p=Path(cfg[k]); cfg[k]=str(p if p.is_absolute() else base/p) + if not isinstance(cfg.get('channels'),list) or not cfg['channels']: raise ValueError('channels[] required') + for ch in cfg['channels']: + p=Path(ch['queue_file']); ch['queue_file']=str(p if p.is_absolute() else base/p) + for k in ('channel_name','channel_root_block_number','queue_file'): + if k not in ch: raise ValueError('channel missing '+k) + st=load_json(cfg['state_file']) + if st is None: st=await init_multi_state(cfg) + st=await reconcile_multi(cfg,st) + if args.command=='status': print(json.dumps(st,ensure_ascii=False,indent=2)); return + if args.command=='publish-now': + name=args.channel or cfg['channels'][0]['channel_name'] + ch=next((x for x in cfg['channels'] if x['channel_name']==name),None) + if not ch: raise ValueError('unknown channel '+name) + await publish_channel(cfg,st,ch,True); return + while True: + st=await reconcile_multi(cfg,load_json(cfg['state_file'])) + now=int(time.time()) + due=sorted(cfg['channels'],key=lambda c:st['channels'][c['channel_name']]['next_publish_at']) + ch=due[0]; wait=max(0,int(st['channels'][ch['channel_name']]['next_publish_at'])-now) + if wait: await asyncio.sleep(wait) + await publish_channel(cfg,st,ch) + +def main(): + ap=argparse.ArgumentParser(description='SHiNE multi-channel test publisher') + ap.add_argument('command',choices=['run','status','publish-now'],nargs='?',default='run') + ap.add_argument('--config',default='config.json'); ap.add_argument('--channel',default='') + a=ap.parse_args() + try: asyncio.run(main_async(a)) + except KeyboardInterrupt: pass + except Exception as e: print('ERROR:',e,file=sys.stderr); sys.exit(1) +if __name__=='__main__': main() diff --git a/tools/test-publisher/v2/queue/images/joke_01.webp b/tools/test-publisher/v2/queue/images/joke_01.webp new file mode 100644 index 00000000..ede94155 Binary files /dev/null and b/tools/test-publisher/v2/queue/images/joke_01.webp differ diff --git a/tools/test-publisher/v2/queue/images/joke_02.webp b/tools/test-publisher/v2/queue/images/joke_02.webp new file mode 100644 index 00000000..98b95024 Binary files /dev/null and b/tools/test-publisher/v2/queue/images/joke_02.webp differ diff --git a/tools/test-publisher/v2/queue/images/joke_03.webp b/tools/test-publisher/v2/queue/images/joke_03.webp new file mode 100644 index 00000000..88c7bc86 Binary files /dev/null and b/tools/test-publisher/v2/queue/images/joke_03.webp differ diff --git a/tools/test-publisher/v2/queue/images/joke_04.webp b/tools/test-publisher/v2/queue/images/joke_04.webp new file mode 100644 index 00000000..43e69716 Binary files /dev/null and b/tools/test-publisher/v2/queue/images/joke_04.webp differ diff --git a/tools/test-publisher/v2/queue/images/joke_05.webp b/tools/test-publisher/v2/queue/images/joke_05.webp new file mode 100644 index 00000000..39ee42cf Binary files /dev/null and b/tools/test-publisher/v2/queue/images/joke_05.webp differ diff --git a/tools/test-publisher/v2/queue/images/joke_06.webp b/tools/test-publisher/v2/queue/images/joke_06.webp new file mode 100644 index 00000000..9a60ecd9 Binary files /dev/null and b/tools/test-publisher/v2/queue/images/joke_06.webp differ diff --git a/tools/test-publisher/v2/queue/images/joke_07.webp b/tools/test-publisher/v2/queue/images/joke_07.webp new file mode 100644 index 00000000..32b4a27b Binary files /dev/null and b/tools/test-publisher/v2/queue/images/joke_07.webp differ diff --git a/tools/test-publisher/v2/queue/images/joke_08.webp b/tools/test-publisher/v2/queue/images/joke_08.webp new file mode 100644 index 00000000..9a8ef308 Binary files /dev/null and b/tools/test-publisher/v2/queue/images/joke_08.webp differ diff --git a/tools/test-publisher/v2/queue/images/joke_09.webp b/tools/test-publisher/v2/queue/images/joke_09.webp new file mode 100644 index 00000000..d0e667a4 Binary files /dev/null and b/tools/test-publisher/v2/queue/images/joke_09.webp differ diff --git a/tools/test-publisher/v2/queue/images/joke_10.webp b/tools/test-publisher/v2/queue/images/joke_10.webp new file mode 100644 index 00000000..6273c934 Binary files /dev/null and b/tools/test-publisher/v2/queue/images/joke_10.webp differ diff --git a/tools/test-publisher/v2/queue/jokes.raw.json b/tools/test-publisher/v2/queue/jokes.raw.json new file mode 100644 index 00000000..f44f403f --- /dev/null +++ b/tools/test-publisher/v2/queue/jokes.raw.json @@ -0,0 +1,345 @@ +[ + { + "id": "001", + "text": "— Почему программист поставил чайник рядом с компьютером? — Чтобы не отходить от Java.", + "images": [] + }, + { + "id": "002", + "text": "Купил умную лампочку. Теперь она знает, что я опять лёг спать в три ночи. Неловко перед техникой.", + "images": [ + "images/joke_02.webp" + ] + }, + { + "id": "003", + "text": "— У вас есть книга «Как перестать всё откладывать»? — Есть. — Отлично, зайду завтра.", + "images": [] + }, + { + "id": "004", + "text": "Будильник — это устройство, которое каждое утро начинает переговоры с человеком с позиции силы.", + "images": [] + }, + { + "id": "005", + "text": "— Ты почему опоздал? — Боролся со сном. — И кто победил? — Судя по времени, была ничья.", + "images": [ + "images/joke_05.webp" + ] + }, + { + "id": "006", + "text": "Кот сел на клавиатуру и за минуту написал код без ошибок. Команда решила не трогать продакшен.", + "images": [ + "images/joke_06.webp", + "images/joke_09.webp" + ] + }, + { + "id": "007", + "text": "Самый оптимистичный предмет дома — зарядка на 1%. Она всё ещё показывает, что жизнь продолжается.", + "images": [] + }, + { + "id": "008", + "text": "— Как твой новый режим дня? — Отлично. Я уже третий день собираюсь начать его соблюдать.", + "images": [ + "images/joke_08.webp" + ] + }, + { + "id": "009", + "text": "Поставил пароль «неправильный». Теперь компьютер сам подсказывает: «Ваш пароль неправильный».", + "images": [] + }, + { + "id": "010", + "text": "Холодильник ночью светится не потому, что ему нужно освещение. Он просто рад тебя видеть.", + "images": [] + }, + { + "id": "011", + "text": "— Ты читаешь инструкции? — Конечно. Обычно сразу после того, как всё перестало работать.", + "images": [ + "images/joke_01.webp" + ] + }, + { + "id": "012", + "text": "Если долго смотреть на список дел, список дел начинает смотреть на тебя.", + "images": [] + }, + { + "id": "013", + "text": "Кофе не решает проблемы. Но проблемы без кофе почему-то выглядят значительно крупнее.", + "images": [] + }, + { + "id": "014", + "text": "— Почему ты разговариваешь с растениями? — Они хотя бы не перебивают и растут над собой.", + "images": [ + "images/joke_04.webp" + ] + }, + { + "id": "015", + "text": "Телефон сообщил, что экранное время выросло. Спасибо, телефон. Без тебя я бы чувствовал себя прекрасно.", + "images": [] + }, + { + "id": "016", + "text": "— Ты умеешь быстро принимать решения? — Да. Иногда даже несколько разных подряд.", + "images": [] + }, + { + "id": "017", + "text": "Навёл порядок на рабочем столе. Теперь ничего не могу найти, зато потеряно очень организованно.", + "images": [ + "images/joke_07.webp" + ] + }, + { + "id": "018", + "text": "Есть два типа людей: те, кто делает резервные копии, и те, кто уже начал их делать.", + "images": [ + "images/joke_08.webp", + "images/joke_01.webp" + ] + }, + { + "id": "019", + "text": "— Что делаешь? — Ничего. — А вчера? — Не закончил.", + "images": [] + }, + { + "id": "020", + "text": "Умные часы предложили мне встать и пройтись. Очень смело для устройства, которое весь день сидит у меня на руке.", + "images": [ + "images/joke_10.webp" + ] + }, + { + "id": "021", + "text": "План на день был прекрасен. Жаль, что день о нём ничего не знал.", + "images": [] + }, + { + "id": "022", + "text": "— Почему у тебя открыто 47 вкладок? — Это не вкладки. Это планы на будущее.", + "images": [] + }, + { + "id": "023", + "text": "Иногда лучший способ решить задачу — закрыть ноутбук. Иногда после этого задача пишет сама и спрашивает, где ты.", + "images": [ + "images/joke_03.webp" + ] + }, + { + "id": "024", + "text": "Купил ежедневник для продуктивности. Первый пункт: не забыть найти ежедневник.", + "images": [] + }, + { + "id": "025", + "text": "— Ты сова или жаворонок? — Я человек, который утром обещает вечером лечь раньше.", + "images": [] + }, + { + "id": "026", + "text": "Wi‑Fi исчез на пять минут. Семья познакомилась. Вроде приятные люди.", + "images": [ + "images/joke_06.webp" + ] + }, + { + "id": "027", + "text": "Мой компьютер настолько быстрый, что успевает обновиться именно тогда, когда я спешу.", + "images": [] + }, + { + "id": "028", + "text": "— Ты медитируешь? — Да. Иногда минут двадцать смотрю на загрузку обновления.", + "images": [] + }, + { + "id": "029", + "text": "Если задача занимает две минуты, сделай её сразу. Если занимает два часа — сначала приготовь чай. Это наука.", + "images": [ + "images/joke_09.webp" + ] + }, + { + "id": "030", + "text": "Кот не мешает работать. Он проводит независимый аудит клавиатуры.", + "images": [ + "images/joke_10.webp", + "images/joke_03.webp" + ] + }, + { + "id": "031", + "text": "— Почему ты улыбаешься телефону? — Он написал «обновление успешно установлено». Хоть у кого-то всё получилось.", + "images": [] + }, + { + "id": "032", + "text": "Взрослая жизнь — это когда радуешься новой губке для посуды и немного пугаешься этого факта.", + "images": [ + "images/joke_02.webp" + ] + }, + { + "id": "033", + "text": "Пылесос-робот застрял под стулом. Первый сотрудник в доме, который официально запросил помощь.", + "images": [] + }, + { + "id": "034", + "text": "— Что такое дедлайн? — Момент, когда вдохновение внезапно узнаёт твой адрес.", + "images": [] + }, + { + "id": "035", + "text": "Мой список покупок состоит из двух частей: что нужно купить и что я всё равно забуду.", + "images": [ + "images/joke_05.webp" + ] + }, + { + "id": "036", + "text": "Сделал перерыв на пять минут. Очень качественный получился перерыв — почти два часа.", + "images": [] + }, + { + "id": "037", + "text": "— У тебя хорошая память? — Конечно. Просто я не помню, где её оставил.", + "images": [] + }, + { + "id": "038", + "text": "Человек может бесконечно смотреть на три вещи: огонь, воду и процент установки обновления на 99%.", + "images": [ + "images/joke_08.webp" + ] + }, + { + "id": "039", + "text": "Купил растение, которое «почти невозможно погубить». Наконец-то достойный соперник.", + "images": [] + }, + { + "id": "040", + "text": "— Почему ты не отвечал? — Телефон был на беззвучном. — Три дня? — Очень беззвучном.", + "images": [] + }, + { + "id": "041", + "text": "В инструкции было написано «собирается за 15 минут». Не уточнили, за чьи 15 минут.", + "images": [ + "images/joke_01.webp" + ] + }, + { + "id": "042", + "text": "— Ты умеешь хранить секреты? — Да. Особенно пароли. Иногда даже от себя.", + "images": [ + "images/joke_02.webp", + "images/joke_05.webp" + ] + }, + { + "id": "043", + "text": "Собрался экономить. Для начала купил красивую коробку, в которой буду хранить деньги, когда они появятся.", + "images": [] + }, + { + "id": "044", + "text": "Самая сложная часть удалённой работы — объяснить коту, что созвон не является коллективным мероприятием.", + "images": [] + }, + { + "id": "045", + "text": "— Как прошла уборка? — Нашёл зарядку, три монеты и причину больше ничего не убирать.", + "images": [] + }, + { + "id": "046", + "text": "Прогноз погоды сказал «ощущается как +5». Хотелось бы узнать, кем именно ощущается.", + "images": [] + }, + { + "id": "047", + "text": "Календарь — очень оптимистичная вещь: он уверен, что через месяц у тебя всё ещё будут планы.", + "images": [] + }, + { + "id": "048", + "text": "— Ты выключил утюг? — Теперь это главная интрига поездки.", + "images": [] + }, + { + "id": "049", + "text": "На коробке написано «легко открыть». Коробка явно ничего обо мне не знает.", + "images": [] + }, + { + "id": "050", + "text": "Поставил приложение для концентрации. Полчаса выбирал в нём идеальную тему оформления.", + "images": [] + }, + { + "id": "051", + "text": "— Почему не спишь? — Думаю. — О чём? — Почему не сплю.", + "images": [] + }, + { + "id": "052", + "text": "Когда интернет медленный, начинаешь замечать интерьер. Оказывается, у нас есть стены.", + "images": [] + }, + { + "id": "053", + "text": "Микроволновка пищит так, будто лично приготовила ужин с нуля.", + "images": [] + }, + { + "id": "054", + "text": "— Ты всё записываешь? — Да. — И помогает? — Конечно. Теперь я точно знаю, сколько всего забыл посмотреть в записях.", + "images": [ + "images/joke_04.webp", + "images/joke_07.webp" + ] + }, + { + "id": "055", + "text": "Рабочий стол компьютера чистый. Все 186 файлов аккуратно лежат в папке «Разобрать».", + "images": [] + }, + { + "id": "056", + "text": "Кофемашина — единственный коллега, который понимает задачу до первого созвона.", + "images": [] + }, + { + "id": "057", + "text": "— Как стать организованнее? — Я записал ответ куда-то. Сейчас найду.", + "images": [] + }, + { + "id": "058", + "text": "Если открыть холодильник третий раз, новой еды там не появится. Но эксперимент должен быть воспроизводимым.", + "images": [] + }, + { + "id": "059", + "text": "Телефон попросил оценить приложение. Я попросил телефон оценить мои жизненные решения. Разошлись молча.", + "images": [] + }, + { + "id": "060", + "text": "Сегодня решил ничего не откладывать на завтра. Завтра разберусь, как прошло.", + "images": [] + } +] \ No newline at end of file diff --git a/tools/test-publisher/v2/requirements.txt b/tools/test-publisher/v2/requirements.txt new file mode 100644 index 00000000..4a68366b --- /dev/null +++ b/tools/test-publisher/v2/requirements.txt @@ -0,0 +1,4 @@ +cryptography>=43,<47 +websockets>=14,<17 +requests>=2.32,<3 +arweave-python-client>=1.0.18 diff --git a/tools/test-publisher/v2/systemd/shine-test-publisher-multi.service b/tools/test-publisher/v2/systemd/shine-test-publisher-multi.service new file mode 100644 index 00000000..be51c611 --- /dev/null +++ b/tools/test-publisher/v2/systemd/shine-test-publisher-multi.service @@ -0,0 +1,17 @@ +[Unit] +Description=SHiNE multi-channel test publisher +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=shine +WorkingDirectory=/opt/shine-test-publisher/v2 +ExecStart=/opt/shine-test-publisher/.venv/bin/python publisher/publisher_multi.py run --config config.multi.json +Restart=on-failure +RestartSec=15 +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/tools/test-publisher/v2/systemd/shine-test-publisher.service b/tools/test-publisher/v2/systemd/shine-test-publisher.service new file mode 100644 index 00000000..2a2106b2 --- /dev/null +++ b/tools/test-publisher/v2/systemd/shine-test-publisher.service @@ -0,0 +1,14 @@ +[Unit] +Description=SHiNE Test Publisher v2 +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/shine-test-publisher/v2 +ExecStart=/opt/shine-test-publisher/.venv/bin/python publisher/publisher.py run --config config.json +Restart=always +RestartSec=15 + +[Install] +WantedBy=multi-user.target diff --git a/tools/test-publisher/v2/upload-missing-images.sh b/tools/test-publisher/v2/upload-missing-images.sh new file mode 100755 index 00000000..a143a952 --- /dev/null +++ b/tools/test-publisher/v2/upload-missing-images.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +CONFIG_FILE="${1:-prepare.multi.json}" +VENV_DIR="$SCRIPT_DIR/.venv" +PYTHON_BIN="$VENV_DIR/bin/python" +PIP_BIN="$VENV_DIR/bin/pip" + +if [[ ! -f "$CONFIG_FILE" ]]; then + echo "ERROR: config not found: $CONFIG_FILE" >&2 + echo "Copy prepare.multi.example.json to prepare.multi.json first." >&2 + exit 1 +fi + +if [[ ! -f keys/arweave-wallet.json ]]; then + echo "ERROR: Arweave wallet not found: keys/arweave-wallet.json" >&2 + echo "Put the private JWK there. The file is ignored by git." >&2 + exit 1 +fi + +if [[ ! -x "$PYTHON_BIN" ]]; then + python3 -m venv "$VENV_DIR" + "$PIP_BIN" install -r requirements.txt +fi + +"$PYTHON_BIN" prepare/prepare_all_channels.py --config "$CONFIG_FILE"