Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ ADMIN_BOOTSTRAP_TOKEN=
INVITE_HMAC_KEY=
PUBLIC_SHARE_HMAC_KEY=

# Optional. Enables province/city/district inference for public visitor IPs.
# Create an AK with Baidu Maps' normal IP geolocation service enabled.
BAIDU_MAP_AK=

JWT_ISSUER=openlogtool-server
ACCESS_TOKEN_TTL_SECONDS=900
REFRESH_TOKEN_TTL_DAYS=30
Expand Down
94 changes: 82 additions & 12 deletions README.md

Large diffs are not rendered by default.

180 changes: 180 additions & 0 deletions deploy-docker.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
#!/bin/bash
set -euo pipefail

# OpenLogTool Server Docker Compose 一键部署脚本
# 用法: bash deploy-docker.sh [host_port]
# 或: curl -fsSL https://raw.githubusercontent.com/Mazha0309/OpenLogToolServer/main/deploy-docker.sh | bash -s -- [host_port]
# 可通过 OPENLOGTOOL_BRANCH=dev 部署其他远端分支;默认部署 main。

REQUESTED_HOST_PORT="${1:-}"
HOST_PORT="${REQUESTED_HOST_PORT:-3000}"
PROJECT_DIR="${OPENLOGTOOL_PROJECT_DIR:-$HOME/OpenLogToolServer}"
BACKUP_ROOT="${OPENLOGTOOL_BACKUP_DIR:-$HOME/OpenLogToolServer-backups}"
BRANCH="${OPENLOGTOOL_BRANCH:-main}"

fail() {
echo "错误: $*" >&2
exit 1
}

validate_port() {
local value="$1"
[[ "$value" =~ ^[0-9]+$ ]] && [ "$value" -ge 1 ] && [ "$value" -le 65535 ] \
|| fail "端口必须是 1-65535 之间的整数"
}

validate_port "$HOST_PORT"

for command_name in git docker; do
command -v "$command_name" &>/dev/null || fail "请先安装 ${command_name}"
done
docker compose version &>/dev/null || fail "需要 Docker Compose v2(docker compose)"
docker info &>/dev/null || fail "无法连接 Docker daemon,请启动 Docker 并确认当前用户有权限访问"
git check-ref-format --branch "$BRANCH" &>/dev/null || fail "无效的 Git 分支名: $BRANCH"

echo "=== 1. 克隆/更新代码 ==="
if [ -e "$PROJECT_DIR" ]; then
[ -d "$PROJECT_DIR/.git" ] || fail "$PROJECT_DIR 已存在但不是 Git 仓库,请手动处理后重试"
cd "$PROJECT_DIR"
[ -z "$(git status --porcelain --untracked-files=no)" ] || fail "仓库存在未提交的已跟踪文件修改,部署已停止"
git fetch --prune origin "$BRANCH"
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
git switch "$BRANCH"
else
git switch --track -c "$BRANCH" "origin/$BRANCH"
fi
git pull --ff-only origin "$BRANCH"
else
git clone --branch "$BRANCH" --single-branch \
https://github.com/Mazha0309/OpenLogToolServer.git "$PROJECT_DIR"
cd "$PROJECT_DIR"
fi

echo "=== 2. 配置环境与独立密钥 ==="
umask 077
if [ ! -f .env ]; then
cp .env.example .env
fi

read_env_value() {
local value
value="$(awk -v key="$1" '
{
line = $0
sub(/^[[:space:]]*/, "", line)
if (index(line, key) != 1) next
rest = substr(line, length(key) + 1)
if (rest !~ /^[[:space:]]*=/) next
sub(/^[[:space:]]*=[[:space:]]*/, "", rest)
result = rest
}
END { printf "%s", result }
' .env)"
value="${value%$'\r'}"
if [[ ${#value} -ge 2 && "$value" == \"*\" ]]; then
value="${value:1:${#value}-2}"
elif [[ ${#value} -ge 2 && "$value" == \'*\' ]]; then
value="${value:1:${#value}-2}"
fi
printf '%s' "$value"
}

write_env_value() {
local name="$1"
local value="$2"
if grep -Eq "^[[:space:]]*${name}[[:space:]]*=" .env; then
sed -i -E "s|^[[:space:]]*${name}[[:space:]]*=.*$|${name}=${value}|" .env
else
printf '%s=%s\n' "$name" "$value" >> .env
fi
}

ensure_secret() {
local name="$1"
local minimum_bytes="$2"
local generated_bytes="$3"
local value
local actual_bytes

value="$(read_env_value "$name")"
if [ -z "$value" ]; then
value="$(od -An -N "$generated_bytes" -tx1 /dev/urandom | tr -d '[:space:]')"
write_env_value "$name" "$value"
echo "已生成 ${name}"
return
fi

actual_bytes="$(LC_ALL=C printf '%s' "$value" | wc -c | tr -d '[:space:]')"
if [ "$actual_bytes" -lt "$minimum_bytes" ]; then
fail "${name} 已存在但只有 ${actual_bytes} 字节,至少需要 ${minimum_bytes} 字节;为避免静默轮换,部署已停止"
fi
}

ensure_secret JWT_SECRET 32 32
ensure_secret ADMIN_BOOTSTRAP_TOKEN 24 24
ensure_secret INVITE_HMAC_KEY 32 32
ensure_secret PUBLIC_SHARE_HMAC_KEY 32 32
if [ -z "$REQUESTED_HOST_PORT" ]; then
configured_host_port="$(read_env_value HOST_PORT)"
if [ -n "$configured_host_port" ]; then
validate_port "$configured_host_port"
HOST_PORT="$configured_host_port"
fi
fi
write_env_value HOST_PORT "$HOST_PORT"
chmod 600 .env

mkdir -p data
if ! chown -R 1000:1000 data 2>/dev/null; then
mismatched_owner="$(find data \( ! -uid 1000 -o ! -gid 1000 \) -print -quit)"
[ -z "$mismatched_owner" ] || fail "data 目录必须可由容器内 UID/GID 1000:1000 写入;请执行 sudo chown -R 1000:1000 '$PROJECT_DIR/data'"
fi
chmod 700 data

echo "=== 3. 构建 Docker 镜像 ==="
docker compose build server

if [ -f data/openlogtool.db ]; then
echo "=== 4. 停服并备份 SQLite ==="
backup_dir="$BACKUP_ROOT/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup_dir"
docker compose stop server
cp -a data/. "$backup_dir/"
echo "数据库已备份到: $backup_dir"
else
echo "=== 4. 首次部署,无现有数据库需要备份 ==="
fi

echo "=== 5. 重建并启动服务 ==="
docker compose up -d --force-recreate server

echo "=== 6. 等待健康检查 ==="
healthy=false
for _ in $(seq 1 60); do
container_id="$(docker compose ps --all --quiet server)"
if [ -n "$container_id" ]; then
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id" 2>/dev/null || true)"
if [ "$health" = "healthy" ]; then
healthy=true
break
fi
if [ "$health" = "unhealthy" ] || [ "$health" = "exited" ] || [ "$health" = "dead" ]; then
break
fi
fi
sleep 2
done

if [ "$healthy" != true ]; then
docker compose ps
docker compose logs --tail=100 server
fail "服务未通过健康检查,请根据上方日志排查"
fi

echo ""
echo "=== 部署完成 ==="
echo "分支: $BRANCH"
echo "服务器: http://localhost:$HOST_PORT"
echo "管理后台: http://localhost:$HOST_PORT/admin"
echo "Public Live Share: http://localhost:$HOST_PORT/live/<share-id>#token=<secret>"
echo "首次初始化管理员需要使用 $PROJECT_DIR/.env 中的 ADMIN_BOOTSTRAP_TOKEN"
36 changes: 31 additions & 5 deletions deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,28 @@ set -euo pipefail

# OpenLogTool Server 一键部署脚本
# 用法: bash deploy.sh [server_port]
# 或: curl -fsSL https://raw.githubusercontent.com/Mazha0309/OpenLogToolServer/main/deploy.sh | bash -s -- [server_port]
# 可通过 OPENLOGTOOL_BRANCH=dev 部署其他远端分支;默认部署 main。

PORT="${1:-3000}"
PROJECT_DIR="$HOME/OpenLogToolServer"
BRANCH="${OPENLOGTOOL_BRANCH:-main}"

if ! [[ "$PORT" =~ ^[0-9]+$ ]] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
echo "端口必须是 1-65535 之间的整数"
exit 1
fi

if ! command -v git &>/dev/null; then
echo "请先安装 Git"
exit 1
fi

if ! git check-ref-format --branch "$BRANCH" &>/dev/null; then
echo "无效的 Git 分支名: $BRANCH"
exit 1
fi

echo "=== 1. 检查 Node.js & npm ==="
if ! command -v node &>/dev/null; then
echo "请先安装 Node.js (>=24.18):"
Expand All @@ -35,9 +48,23 @@ fi
echo "=== 2. 克隆/更新代码 ==="
if [ -d "$PROJECT_DIR" ]; then
cd "$PROJECT_DIR"
git pull origin rewrite
if [ ! -d .git ]; then
echo "$PROJECT_DIR 已存在但不是 Git 仓库,请手动处理后重试。"
exit 1
fi
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "仓库存在未提交的已跟踪文件修改;为避免覆盖数据,部署已停止。"
exit 1
fi
git fetch --prune origin "$BRANCH"
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
git switch "$BRANCH"
else
git switch --track -c "$BRANCH" "origin/$BRANCH"
fi
git pull --ff-only origin "$BRANCH"
else
git clone -b rewrite https://github.com/Mazha0309/OpenLogToolServer.git "$PROJECT_DIR"
git clone --branch "$BRANCH" --single-branch https://github.com/Mazha0309/OpenLogToolServer.git "$PROJECT_DIR"
cd "$PROJECT_DIR"
fi

Expand Down Expand Up @@ -107,9 +134,7 @@ ensure_secret ADMIN_BOOTSTRAP_TOKEN 24 24
ensure_secret INVITE_HMAC_KEY 32 32
ensure_secret PUBLIC_SHARE_HMAC_KEY 32 32

if ! grep -q '^PORT=' .env; then
echo "PORT=$PORT" >> .env
fi
write_env_value PORT "$PORT"
if ! grep -q '^NODE_ENV=' .env; then
echo "NODE_ENV=production" >> .env
fi
Expand All @@ -130,6 +155,7 @@ fi

echo ""
echo "=== 部署完成 ==="
echo "分支: $BRANCH"
echo "服务器: http://localhost:$PORT"
echo "管理后台: http://localhost:$PORT/admin"
echo "Public Liveshare: http://localhost:$PORT/live/<share-id>#token=<secret>"
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ services:
ADMIN_BOOTSTRAP_TOKEN: ${ADMIN_BOOTSTRAP_TOKEN:-}
INVITE_HMAC_KEY: ${INVITE_HMAC_KEY:?INVITE_HMAC_KEY must be set}
PUBLIC_SHARE_HMAC_KEY: ${PUBLIC_SHARE_HMAC_KEY:?PUBLIC_SHARE_HMAC_KEY must be set}
BAIDU_MAP_AK: ${BAIDU_MAP_AK:-}
JWT_ISSUER: ${JWT_ISSUER:-openlogtool-server}
ACCESS_TOKEN_TTL_SECONDS: "${ACCESS_TOKEN_TTL_SECONDS:-900}"
REFRESH_TOKEN_TTL_DAYS: "${REFRESH_TOKEN_TTL_DAYS:-30}"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Personal cloud snapshot v1
# Personal Cloud Snapshot API v1

This API stores one private, account-scoped snapshot of a user's local-only
Sessions and Logs. It is deliberately independent from collaboration Sessions:
Expand All @@ -7,7 +7,7 @@ deletes rows in the collaboration `sessions`, `logs`, membership, event, draft,
invite, or Live Share tables.

Dictionary changes deliberately use the separate
[`personalDictionarySnapshots`](personal-dictionary-snapshot-v1.md) capability,
[`personalDictionarySnapshots`](personal-dictionary-snapshot-api-v1.md) capability,
table, revision, and endpoints. Keeping the protocols independent prevents an
older records-only client from erasing dictionary data with a v1 replacement.

Expand Down Expand Up @@ -116,6 +116,34 @@ the stored `snapshot` inside `personalSnapshot`. It returns
`404 PERSONAL_SNAPSHOT_NOT_FOUND` before the first upload. The response includes
the revision `ETag` and an attachment filename.

## Export one Session as a client database backup v7

`GET /api/v1/account/personal-snapshot/sessions/:sessionId/database-backup-v7`
converts exactly one Session from the current account's record snapshot into
the complete top-level JSON shape accepted by the OpenLogTool client's local
database import. The response body is the raw backup rather than a
`personalSnapshot` envelope, declares `version: 7`, and is downloaded as
`openlogtool-session-{sessionId}-r{recordRevision}-v7.json`.

The export contains one `sessions` row and only the `logs` rows whose
`session_id` matches it. It preserves tombstones, complete timestamps, remarks,
and source device IDs. Other Sessions and the account-wide personal dictionary
snapshot are deliberately excluded. `dictionary_items`, settings, oplog rows,
collaboration bindings, shadows, outbox, applied events, conflicts, live-draft
caches, and offline records are emitted as empty arrays. Consequently, the
restored Session is editable local data and never retains a server
collaboration binding.

The response includes `X-OpenLogTool-Backup-Format-Version`,
`X-Personal-Snapshot-Revision`, and `X-Personal-Snapshot-Session-Id`. A missing
record snapshot returns `404 PERSONAL_SNAPSHOT_NOT_FOUND`; a Session absent from
that snapshot returns `404 PERSONAL_SNAPSHOT_SESSION_NOT_FOUND`; corrupt record
data returns a 500 integrity error. The former account-wide route
`GET /api/v1/account/personal-snapshot/database-backup-v7` returns
`422 PERSONAL_SNAPSHOT_SESSION_REQUIRED` and never emits a combined file.
Importing a v7 file through the client replaces its current local database, so
the Web portal labels that consequence explicitly.

## Atomic dangerous replacement

`PUT /api/v1/account/personal-snapshot` replaces the entire account snapshot:
Expand Down Expand Up @@ -186,11 +214,20 @@ account or snapshot returns `404 PERSONAL_SNAPSHOT_NOT_FOUND`; invalid stored
JSON or metadata that no longer matches the validated content returns
`500 PERSONAL_SNAPSHOT_CORRUPT`.

Both endpoints require a current server administrator access token and return
All administrator endpoints in this section require a current server
administrator access token and return
`Cache-Control: no-store`. Because detail responses expose personal Log
content, each detail visit is written to the append-only governance audit as
`personal_snapshot.detail.viewed`. A UI may send one stable
`X-Admin-Access-Id` for a detail visit; repeated reads by the same
administrator, target account, access ID, and 15-minute bucket produce one
audit row. The audit stores the access ID only: it never copies snapshot
content, checksums, titles, callsigns, or remarks into audit details.

`GET /api/v1/admin/personal-snapshots/:userId/sessions/:sessionId/database-backup-v7`
returns the same client-compatible raw v7 backup for one selected Session in an
account snapshot. It performs the same integrity validation and records
`personal_snapshot.session_database_v7.exported` in the governance audit with
the personal snapshot Session ID, without copying Session titles, Log content,
checksums, callsigns, or remarks into the audit row. The former account-wide
administrator route returns `422 PERSONAL_SNAPSHOT_SESSION_REQUIRED`.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Personal dictionary snapshot v1
# Personal Dictionary Snapshot API v1

This API stores one private, account-scoped snapshot of dictionary changes.
It is independent from the personal records snapshot and from collaboration
Expand Down
Loading