diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDialogStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDialogStateDAO.java index f214e2c7..6b22601b 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDialogStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/DmDialogStateDAO.java @@ -77,16 +77,18 @@ public final class DmDialogStateDAO { ps.setString(1, cleanOwner); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { + long lastMessageTimeMs = rs.getLong("last_message_time_ms"); + int unreadCount = rs.getInt("unread_count"); DialogSummary row = new DialogSummary( rs.getString("owner_login"), rs.getString("peer_login"), normalizeRelationFlag(rs.getString("relation_flag")), rs.getString("last_message_blob_b64"), - rs.getLong("last_message_time_ms"), - rs.getInt("unread_count"), + lastMessageTimeMs, + unreadCount, rs.getLong("last_read_receipt_time_ms"), rs.getLong("updated_at_ms"), - true + lastMessageTimeMs > 0 || unreadCount > 0 ); byPeer.put(normKey(row.peerLogin()), row); } diff --git a/bundle-offline.sh b/bundle-offline.sh new file mode 100755 index 00000000..2144f43d --- /dev/null +++ b/bundle-offline.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Build an offline-ready source bundle ZIP. +# In addition to the normal source tree, this variant can attach a local +# Gradle distribution zip and a helper script that rewrites wrapper URLs to +# that local file so the bundle can be used without internet access. +# +# Usage: +# ./bundle-offline.sh +# ./bundle-offline.sh path/to/output.zip +# +# Expected local asset: +# offline/gradle-offline.zip +# or a custom path via BUNDLE_OFFLINE_GRADLE_ZIP + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +OUT="${1:-SHiNE-bundle-offline-$(date +%Y%m%d-%H%M%S).zip}" +case "$OUT" in + /*) ;; + *) OUT="$ROOT/$OUT" ;; +esac + +if ! command -v zip >/dev/null 2>&1; then + echo "ERROR: 'zip' is required." >&2 + exit 1 +fi + +TMP="$(mktemp -d)" +LIST="$TMP/files.txt" +SAFE_LIST="$TMP/safe-files.txt" +STAGE="$TMP/stage" +trap 'rm -rf "$TMP"' EXIT + +mkdir -p "$STAGE" + +# Paths / filenames that must never be bundled. +is_denied_path() { + local p="/$1" + + case "$p" in + */.git/*|*/.git|\ + */.gradle/*|*/.gradle|\ + */.gradle-home/*|*/.gradle-home|\ + */.idea/*|*/.idea|\ + */.vscode/*|*/.vscode|\ + */node_modules/*|*/node_modules|\ + */target/*|*/target|\ + */build/*|*/build|\ + */out/*|*/out|\ + */bin/*|*/bin|\ + */logs/*|*/logs|\ + */data/*|*/data|\ + */test-ledger/*|*/test-ledger|\ + */.anchor/*|*/.anchor|\ + */.yarn/*|*/.yarn|\ + */.vendor/*|*/.vendor|\ + */.agents/*|*/.agents|\ + */.codex/*|*/.codex|\ + */.claude/*|*/.claude|\ + */deploy/backup/archive/*|\ + */scripts/*/runs/*|\ + */scripts/*/keypairs/*|\ + */keys/*|\ + */.git-local-backup/*|\ + */SHiNE-bundle-*.zip|\ + */bundle-offline*.zip) + return 0 + ;; + esac + + local base="${p##*/}" + local lower + lower="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]')" + + case "$lower" in + .env|.env.*|\ + .debug-token|\ + .npmrc|.pypirc|.netrc|\ + credentials|credentials.*|\ + secrets|secrets.*|\ + secret|secret.*|\ + id_rsa|id_dsa|id_ecdsa|id_ed25519|\ + *.pem|*.key|*.p12|*.pfx|*.jks|*.keystore|\ + *keypair*.json|\ + service-account*.json|\ + firebase-adminsdk*.json|\ + google-services.json|\ + validator.log) + return 0 + ;; + esac + + case "$lower" in + *.class|*.jar|*.war|*.ear|*.o|*.a|*.so|*.dll|*.dylib|\ + *.elf|*.map|*.uf2|*.bin|*.merged.bin|\ + *.log|*.bak|*.bak.png|*.tmp|*.swp|*.swo|\ + .ds_store) + return 0 + ;; + esac + + return 1 +} + +find_offline_gradle_zip() { + local candidate="${BUNDLE_OFFLINE_GRADLE_ZIP:-}" + if [[ -n "$candidate" && -f "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + + for candidate in \ + "$ROOT/offline/gradle-offline.zip" \ + "$ROOT/offline/gradle-8.14-bin.zip" \ + "$ROOT/offline/gradle.zip" + do + if [[ -f "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +create_offline_helper() { + local zip_name="$1" + local helper="$STAGE/offline/prepare-local-gradle.sh" + local readme="$STAGE/offline/README.txt" + + mkdir -p "$STAGE/offline" + + cat > "$helper" <&2 + exit 1 +fi + +ABS_ZIP="\$(cd -- "\$(dirname -- "\$ZIP_PATH")" && pwd -P)/\$(basename -- "\$ZIP_PATH")" +ESCAPED_ABS_ZIP="\${ABS_ZIP//\\\\/\\\\\\\\}" +ESCAPED_ABS_ZIP="\${ESCAPED_ABS_ZIP//&/\\\\&}" +ESCAPED_ABS_ZIP="\${ESCAPED_ABS_ZIP//|/\\\\|}" + +while IFS= read -r props; do + [[ -f "\$props" ]] || continue + cp -p "\$props" "\$props.bak" + sed -i -e "s|^distributionUrl=.*\$|distributionUrl=file://\$ESCAPED_ABS_ZIP|" "\$props" +done < <(find "\$ROOT" -path '*/gradle/wrapper/gradle-wrapper.properties' -type f | sort) + +cat <<'MSG' +Gradle wrapper URLs rewritten to the local offline zip. +Run now: + ./gradlew --offline test +MSG +EOF + chmod +x "$helper" + + cat > "$readme" </dev/null 2>&1 && git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + git -C "$ROOT" ls-files -co --exclude-standard -z > "$TMP/files.z" +else + find "$ROOT" -type f -print0 > "$TMP/files.z" +fi + +# Convert to project-relative paths and enforce hard deny rules. +: > "$LIST" +while IFS= read -r -d '' f; do + if [[ "$f" = /* ]]; then + rel="${f#"$ROOT"/}" + else + rel="$f" + fi + + [[ "$rel" == "$OUT" ]] && continue + [[ -z "$rel" ]] && continue + + if is_denied_path "$rel"; then + continue + fi + + printf '%s\n' "$rel" >> "$LIST" +done < "$TMP/files.z" + +sort -u "$LIST" -o "$LIST" + +# Always include Gradle wrapper bootstrap, even though generic JARs are denied. +for wrapper_jar in \ + 'SHiNE-server/gradle/wrapper/gradle-wrapper.jar' \ + 'SHiNE-browser-plugin-wallet/gradle/wrapper/gradle-wrapper.jar' +do + if [[ -f "$ROOT/$wrapper_jar" ]] && ! grep -Fxq "$wrapper_jar" "$LIST"; then + printf '%s\n' "$wrapper_jar" >> "$LIST" + fi +done + +sort -u "$LIST" -o "$LIST" + +# Content scan: fail closed on common credential/private-key patterns. +# We scan only text-ish files; grep -I skips binary data. +SECRET_RE='-----BEGIN ([A-Z0-9 ]+ )?PRIVATE KEY-----|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,}|(^|[^A-Za-z0-9])(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key)[[:space:]]*[:=][[:space:]]*["'\'']?[^${[:space:]]{][^[:space:]]{7,}' + +: > "$SAFE_LIST" +found_secret=0 + +while IFS= read -r rel; do + [[ -f "$ROOT/$rel" ]] || continue + + # Files that contain examples/templates can legitimately mention secret keys + # with placeholders. They are scanned too, but placeholder-looking values + # are less likely to match the regex above. + if LC_ALL=C grep -IEnq "$SECRET_RE" "$ROOT/$rel" 2>/dev/null; then + echo "BLOCKED: possible secret in $rel" >&2 + LC_ALL=C grep -IEn "$SECRET_RE" "$ROOT/$rel" 2>/dev/null \ + | sed -E 's/(:[[:space:]]*).*/\1[REDACTED]/' \ + | head -n 3 >&2 || true + found_secret=1 + continue + fi + + printf '%s\n' "$rel" >> "$SAFE_LIST" +done < "$LIST" + +if (( found_secret != 0 )); then + echo >&2 + echo "Bundle NOT created because possible secrets were detected." >&2 + echo "Move secrets to ignored/local files or adjust the scanner only after review." >&2 + exit 2 +fi + +if [[ ! -s "$SAFE_LIST" ]]; then + echo "ERROR: no files left to bundle." >&2 + exit 3 +fi + +OFFLINE_ZIP_SRC="" +OFFLINE_ZIP_NAME="" +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 "The bundle is not created because this variant is meant to be offline-ready." >&2 + exit 4 +fi + +rm -rf "$STAGE" +mkdir -p "$STAGE" + +while IFS= read -r rel; do + src="$ROOT/$rel" + dst="$STAGE/$rel" + mkdir -p "$(dirname -- "$dst")" + cp -p "$src" "$dst" +done < "$SAFE_LIST" + +mkdir -p "$STAGE/offline" +cp -p "$OFFLINE_ZIP_SRC" "$STAGE/offline/$OFFLINE_ZIP_NAME" +create_offline_helper "$OFFLINE_ZIP_NAME" + +rm -f -- "$OUT" + +( + cd "$STAGE" + find . -type f -print | sort | zip -q -9 "$OUT" -@ +) + +echo "Created: $OUT" +echo "Files: $(cd "$STAGE" && find . -type f | wc -l | tr -d ' ')" +echo "Size: $(du -h "$OUT" | awk '{print $1}')" diff --git a/docs/Personal_Messages/Протокол_DM_v1.md b/docs/Personal_Messages/Протокол_DM_v1.md index 0a22fe0f..1b657b29 100644 --- a/docs/Personal_Messages/Протокол_DM_v1.md +++ b/docs/Personal_Messages/Протокол_DM_v1.md @@ -203,3 +203,15 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает - Доступные группы: «Все чаты», «Близкие друзья», «Контакты», «Новые». - Фильтрация использует уже существующий признак отношения диалога: `close_friend`, `contact`, `none`; формат DM и серверный протокол при этом не меняются. - Для недавних сообщений UI показывает относительное время (секунды, минуты, часы, дни). Для более старых сообщений текущего года используется формат `DD.MM, HH:MM`, для другого года — `DD.MM.YYYY` без времени. + +## UI: видимость пустого диалога после DeleteConversation + +`DeleteConversation` (`type=7/8`) остаётся техническим tombstone и сам по себе не считается пользовательским сообщением диалога. + +Для списка личных чатов действует правило: + +- если после очистки истории у пары нет обычных DM-сообщений и пользователь не находится в `contact`, `friend` или `close_friend`, строка диалога не показывается; +- если связь `contact`, `friend` или `close_friend` сохраняется, пустой чат может оставаться в списке как чат существующей связи; +- при удалении чата с `friend`/`close_friend` UI должен отдельно предупредить, что одна очистка истории не уберёт строку чата, и при подтверждении снять социальную связь и очистить историю. + +Это правило не меняет wire/API-формат DM и не меняет байтовый формат tombstone. diff --git a/docs/Personal_Messages/Формат_DM_v1.md b/docs/Personal_Messages/Формат_DM_v1.md index 5c870d04..778ceb1f 100644 --- a/docs/Personal_Messages/Формат_DM_v1.md +++ b/docs/Personal_Messages/Формат_DM_v1.md @@ -356,3 +356,9 @@ ReadReceiptBody_v1_0 ## Примечание UI списка чатов (2026-08-28) Это изменение не меняет байтовый формат DM. В списке чатов клиент может фильтровать уже полученные диалоги по `relationFlag` (`close_friend`, `contact`, `none`) и локально форматировать время последнего сообщения: относительное для недавних, `DD.MM, HH:MM` в текущем году и `DD.MM.YYYY` для прошлых лет. + +## UI-семантика `type=7/8` в списке диалогов + +Контейнер `type=7/8` является служебным tombstone очистки истории. Его наличие без обычных сообщений `type=1/2` не должно само по себе означать, что у пары есть видимый пользовательский диалог. + +Следствие для UI/агрегата диалогов: `hasDialog` определяется наличием пользовательского содержимого (или непрочитанных пользовательских сообщений), а не наличием служебной записи состояния/tombstone. Формат контейнера при этом не изменяется. diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index ab9bfb5a..7d50279a 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -264,18 +264,30 @@ function openChatConfirmModal({ }); } -function openDeleteChatConfirmModal({ contactName = '', onConfirm }) { +function openDeleteChatConfirmModal({ contactName = '', relationType = 'none', onConfirm }) { const root = document.getElementById('modal-root'); if (!root) return; + const relation = normalizeChatRelationType(relationType); + const isCloseFriend = relation === 'close_friend'; + const isFriend = relation === 'friend'; + const isProtectedRelation = isCloseFriend || isFriend; + const relationName = isCloseFriend ? 'близких друзей' : 'друзей'; + const safeName = String(contactName || '').trim() || 'этого пользователя'; + root.innerHTML = `