diff --git a/frontend/package.json b/frontend/package.json
index 1bd74ed..04928a9 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -18,6 +18,7 @@
"vfonts": "^0.0.3"
},
"devDependencies": {
+ "@types/node": "^22.9.0",
"@vitejs/plugin-vue": "^5.1.4",
"typescript": "^5.6.3",
"vite": "^5.4.10",
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index f2f2d75..e35b658 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -4,6 +4,7 @@
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "types": ["node", "vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
diff --git a/scripts/_check_sshd.py b/scripts/_check_sshd.py
new file mode 100644
index 0000000..f1433ea
--- /dev/null
+++ b/scripts/_check_sshd.py
@@ -0,0 +1,16 @@
+import os, sys, paramiko
+PW = os.environ.get("REMOTE_PASS", "")
+c = paramiko.SSHClient()
+c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+c.connect("207.57.129.228", port=19717, username="root", password=PW, timeout=15, allow_agent=False, look_for_keys=False)
+def run(cmd):
+ si, so, se = c.exec_command(cmd, timeout=15)
+ out = so.read().decode("utf-8", "replace")
+ err = se.read().decode("utf-8", "replace")
+ print(f"$ {cmd}")
+ if out: print(out, end="")
+ if err: print("[err]", err, end="", file=sys.stderr)
+run("ls -la /root/.ssh/ && echo --- && cat /root/.ssh/authorized_keys | head -1 | cut -c1-100")
+run("sshd -T 2>/dev/null | grep -iE 'pubkeyauth|permitroot|authentic' | head -20")
+run("grep -E 'PubkeyAuthentication|PermitRootLogin|PasswordAuthentication|AuthorizedKeysFile' /etc/ssh/sshd_config 2>/dev/null; echo --- && ls -la /etc/ssh/sshd_config.d/ 2>/dev/null")
+c.close()
diff --git a/scripts/_enable_pubkey.py b/scripts/_enable_pubkey.py
new file mode 100644
index 0000000..b51dcb3
--- /dev/null
+++ b/scripts/_enable_pubkey.py
@@ -0,0 +1,37 @@
+import os, sys, paramiko
+PW = os.environ.get("REMOTE_PASS", "")
+c = paramiko.SSHClient()
+c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+c.connect("207.57.129.228", port=19717, username="root", password=PW, timeout=15, allow_agent=False, look_for_keys=False)
+def run(cmd, allow_fail=False):
+ print(f"$ {cmd}")
+ si, so, se = c.exec_command(cmd, timeout=20)
+ out = so.read().decode("utf-8", "replace")
+ err = se.read().decode("utf-8", "replace")
+ rc = so.channel.recv_exit_status()
+ if out: print(out, end="")
+ if err: print("[err]", err, end="", file=sys.stderr)
+ print(f" -> rc={rc}")
+ if rc != 0 and not allow_fail:
+ raise SystemExit(f"failed: {cmd}")
+ return out, err, rc
+
+# 1) 备份
+run("cp -a /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.$(date +%s)")
+# 2) 改 PubkeyAuthentication
+run("sed -i -E 's/^#?\\s*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config")
+# 3) 确认
+run("grep -n '^[^#]*PubkeyAuthentication' /etc/ssh/sshd_config")
+# 4) 语法检查
+run("sshd -t && echo 'sshd config OK'")
+# 5) 重启(用 service 或 systemctl,Ubuntu 24 用 systemd)
+# 先试 systemctl,失败回退 service
+out, _, _ = run("systemctl is-active ssh 2>/dev/null || systemctl is-active sshd 2>/dev/null || echo NONE", allow_fail=True)
+if "active" in out:
+ run("systemctl restart ssh || systemctl restart sshd")
+else:
+ run("service ssh restart || service sshd restart")
+# 6) 再确认 sshd 配置生效
+run("sshd -T 2>/dev/null | grep -i pubkeyauth")
+c.close()
+print("DONE")
diff --git a/scripts/_push_key.py b/scripts/_push_key.py
new file mode 100644
index 0000000..14f1412
--- /dev/null
+++ b/scripts/_push_key.py
@@ -0,0 +1,54 @@
+import os, sys, paramiko
+HOST = "207.57.129.228"
+PORT = 19717
+USER = "root"
+PW = os.environ.get("REMOTE_PASS", "")
+PUB = os.path.expanduser("~/.ssh/id_rsa.pub")
+if not PW:
+ print("REMOTE_PASS not set", file=sys.stderr); sys.exit(2)
+
+c = paramiko.SSHClient()
+c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+try:
+ c.connect(HOST, port=PORT, username=USER, password=PW, timeout=15, allow_agent=False, look_for_keys=False)
+except Exception as e:
+ print("CONNECT FAIL:", e, file=sys.stderr); sys.exit(1)
+
+def run(cmd, check=False):
+ si, so, se = c.exec_command(cmd, timeout=15)
+ out = so.read().decode("utf-8", "replace")
+ err = se.read().decode("utf-8", "replace")
+ if out: print(out, end="")
+ if err: print("[err]", err, end="", file=sys.stderr)
+ if check and (so.channel.recv_exit_status() != 0):
+ raise SystemExit(f"cmd failed: {cmd}")
+
+run("mkdir -p /root/.ssh && chmod 700 /root/.ssh")
+run("touch /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys")
+
+pub = open(PUB, encoding="utf-8").read().strip()
+marker = "news-deploy-key"
+if marker not in pub:
+ pub = pub + " " + marker
+
+# 用 sftp 写文件(避免 shell 转义)
+sftp = c.open_sftp()
+ak_path = "/root/.ssh/authorized_keys"
+existing = ""
+try:
+ with sftp.open(ak_path, "r") as f:
+ existing = f.read().decode("utf-8", "replace")
+except IOError:
+ pass
+
+if marker in existing:
+ print("[ok] public key already present, skip")
+else:
+ with sftp.open(ak_path, "a") as f:
+ f.write(pub + "\n")
+ print("[ok] appended public key to", ak_path)
+
+sftp.close()
+run("ls -la /root/.ssh/ && echo '---' && wc -l /root/.ssh/authorized_keys")
+c.close()
+print("DONE")
diff --git a/scripts/_run_deploy.py b/scripts/_run_deploy.py
new file mode 100644
index 0000000..a9387f2
--- /dev/null
+++ b/scripts/_run_deploy.py
@@ -0,0 +1,23 @@
+import os, paramiko
+HOST, PORT, USER = "207.57.129.228", 19717, "root"
+PW = os.environ["REMOTE_PASS"]
+c = paramiko.SSHClient()
+c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+c.connect(HOST, port=PORT, username=USER, password=PW, timeout=15, allow_agent=False, look_for_keys=False)
+
+# 1) 推更新后的部署脚本
+sftp = c.open_sftp()
+sftp.put("D:/selftools/diary-news/scripts/deploy_remote.sh", "/root/deploy_news.sh")
+sftp.chmod("/root/deploy_news.sh", 0o755)
+sftp.close()
+print("[ok] script pushed")
+
+# 2) 杀掉旧进程(若有)
+si, so, se = c.exec_command("pkill -f deploy_news.sh 2>/dev/null; sleep 2; echo done")
+print(so.read().decode().strip())
+
+# 3) 后台启动,设 SSHD_PORT=19717
+si, so, se = c.exec_command("nohup env SSHD_PORT=19717 bash /root/deploy_news.sh > /root/deploy_news.log 2>&1 & echo $!", timeout=10)
+pid = so.read().decode().strip()
+print(f"[ok] deploy started, PID={pid}")
+c.close()
diff --git a/scripts/deploy_remote.sh b/scripts/deploy_remote.sh
new file mode 100644
index 0000000..b0ca304
--- /dev/null
+++ b/scripts/deploy_remote.sh
@@ -0,0 +1,158 @@
+#!/usr/bin/env bash
+# 部署脚本:在远程服务器上跑一次
+# 用法: bash /root/deploy_news.sh
+set -euo pipefail
+
+# === 配置 ===
+GITEA_URL="http://124.223.26.33:3000/xiaji/diary-news.git"
+APP_DIR="/srv/news"
+DOMAIN="" # 留空走 IP
+
+log() { echo "[$(date +'%H:%M:%S')] $*"; }
+fail() { echo "[FAIL] $*" >&2; exit 1; }
+
+# === 1. 系统初始化 ===
+log "1/8 系统更新 + 基础包"
+export DEBIAN_FRONTEND=noninteractive
+apt-get update -y
+apt-get install -y --no-install-recommends curl git ufw fail2ban openssl ca-certificates
+
+# === 2. 创建非 root 用户(后续切过去) ===
+if ! id news &>/dev/null; then
+ log "2/8 创建 news 用户"
+ adduser --disabled-password --gecos "" news
+ echo "news ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/news
+fi
+
+# === 3. Docker ===
+if ! command -v docker &>/dev/null; then
+ log "3/8 安装 Docker"
+ curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
+ sh /tmp/get-docker.sh
+ usermod -aG docker news
+fi
+docker --version
+
+# === 4. 防火墙 ===
+log "4/8 防火墙"
+SSHD_PORT="${SSHD_PORT:-22}" # 自定义 SSH 端口(默认 22)
+ufw --force reset
+ufw default deny incoming
+ufw default allow outgoing
+ufw allow "${SSHD_PORT}/tcp" comment 'ssh'
+ufw allow 80/tcp comment 'http'
+ufw allow 443/tcp comment 'https'
+ufw --force enable
+ufw status verbose
+
+# === 5. 拉代码 ===
+log "5/8 拉代码到 $APP_DIR"
+mkdir -p "$APP_DIR"
+if [ ! -d "$APP_DIR/.git" ]; then
+ sudo -u news git clone "$GITEA_URL" "$APP_DIR"
+else
+ cd "$APP_DIR" && sudo -u news git pull --rebase
+fi
+chown -R news:news "$APP_DIR"
+
+# === 6. 写 .env(自动生成密码) ===
+log "6/8 生成 .env"
+if [ ! -f "$APP_DIR/.env" ]; then
+ cat > "$APP_DIR/.env" </dev/null; then
+ break
+ fi
+ sleep 2
+done
+for i in {1..30}; do
+ if dc exec -T redis redis-cli -a "$(grep ^REDIS_PASSWORD $APP_DIR/.env | cut -d= -f2)" ping 2>/dev/null | grep -q PONG; then
+ break
+ fi
+ sleep 2
+done
+
+# === 8. 初始化 ===
+log "8/8 数据库迁移 + 用户 + 源"
+dc exec -T api alembic upgrade head
+
+# 创建 owner(从 env 或自动生成,避免后台跑卡在 read)
+if [ -n "${OWNER_PASS:-}" ]; then
+ log " 使用环境变量 OWNER_PASS"
+else
+ OWNER_PASS="$(openssl rand -hex 12)"
+ log " 自动生成 owner 密码(写入 /root/.owner_pass): $OWNER_PASS"
+fi
+dc exec -T api python -m app.scripts.create_user --username owner --password "$OWNER_PASS" || true
+echo "$OWNER_PASS" > /root/.owner_pass
+chmod 600 /root/.owner_pass
+
+# 种子
+dc exec -T api python -m app.scripts.seed_sources
+
+# 健康检查
+log " 健康检查"
+sleep 3
+curl -s http://localhost/api/v1/healthz && echo
+echo
+echo "================================================"
+echo " 部署完成!"
+echo " 访问: http://$(curl -s ifconfig.me)/"
+echo " 账号: owner"
+echo " 密码: 写入到 /root/.owner_pass (chmod 600)"
+echo " 后续: docker compose -f $APP_DIR/docker-compose.yml logs -f"
+echo "================================================"
diff --git a/scripts/push_ssh_key.py b/scripts/push_ssh_key.py
new file mode 100644
index 0000000..211f542
--- /dev/null
+++ b/scripts/push_ssh_key.py
@@ -0,0 +1,79 @@
+"""推公钥到远程服务器 /root/.ssh/authorized_keys。
+
+用法:python _push_key.py
+依赖:paramiko
+"""
+import os
+import sys
+import paramiko
+
+HOST = "207.57.129.228"
+PORT = 19717
+USER = "root"
+PASSWORD = os.environ["REMOTE_PASS"] # 由调用方设置
+PUB_KEY_PATH = os.path.expanduser("~/.ssh/id_rsa.pub")
+
+
+def main() -> int:
+ pub = open(PUB_KEY_PATH, encoding="utf-8").read().strip()
+ print(f"公钥({PUB_KEY_PATH})前 60 字符: {pub[:60]}...")
+
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ try:
+ client.connect(
+ HOST, port=PORT, username=USER, password=PASSWORD,
+ timeout=15, allow_agent=False, look_for_keys=False,
+ )
+ except Exception as e:
+ print(f"连接失败: {e}", file=sys.stderr)
+ return 1
+
+ # 1) 检查 .ssh 目录
+ cmds = [
+ "mkdir -p /root/.ssh && chmod 700 /root/.ssh",
+ f"touch /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys",
+ ]
+ for c in cmds:
+ _exec(client, c)
+
+ # 2) 检查是否已经存在(去重)
+ stdin, stdout, stderr = client.exec_command("grep -F -c 'news-deploy-key' /root/.ssh/authorized_keys || true")
+ # 用一个独特注释,后续可识别
+ marker = "news-deploy-key"
+ if marker not in pub:
+ pub_with_marker = f"{pub} {marker}"
+ else:
+ pub_with_marker = pub
+ stdin, stdout, stderr = client.exec_command("cat /root/.ssh/authorized_keys | grep -F '" + marker + "' || true")
+ existing = stdout.read().decode().strip()
+ if existing:
+ print(f"已存在,跳过: {existing[:60]}...")
+ else:
+ # 写入(用 heredoc 避免转义)
+ quoted = pub_with_marker.replace("'", "'\\''")
+ cmd = f"echo '{quoted}' >> /root/.ssh/authorized_keys"
+ _exec(client, cmd)
+ print(f"已追加公钥到 /root/.ssh/authorized_keys")
+
+ # 3) 验证权限
+ _exec(client, "ls -la /root/.ssh/ /root/.ssh/authorized_keys")
+
+ # 4) 关闭密码登录(可选项,MVP 保留)
+ client.close()
+ return 0
+
+
+def _exec(client: paramiko.SSHClient, cmd: str) -> None:
+ print(f"$ {cmd}")
+ stdin, stdout, stderr = client.exec_command(cmd, timeout=15)
+ out = stdout.read().decode().strip()
+ err = stderr.read().decode().strip()
+ if out:
+ print(out)
+ if err:
+ print(f"[stderr] {err}", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ sys.exit(main())