File size: 6,319 Bytes
262a183
0216deb
 
 
 
7bffa9f
2a2fa33
01e6a7a
ad533eb
c18cf88
 
956a293
47b9f31
2a2fa33
0216deb
 
 
 
 
9e8330a
c18cf88
 
9e8330a
 
0216deb
 
 
 
9e8330a
 
0216deb
 
 
 
 
 
 
9e8330a
 
 
 
0216deb
 
 
 
 
 
 
 
 
 
9e8330a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0216deb
 
 
9e8330a
0216deb
9e8330a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0216deb
9e8330a
 
 
 
 
 
 
 
 
 
 
 
 
 
0216deb
9e8330a
 
 
 
 
 
0216deb
9e8330a
 
 
 
 
 
 
 
 
 
 
 
0216deb
9e8330a
0216deb
9e8330a
 
 
0216deb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
FROM node:22-alpine

# 运行时需要 sqlite CLI 做一致性备份;不安装 sqlite-dev(构建依赖,生产不需要)
RUN apk add --no-cache sqlite

WORKDIR /app
RUN npm install -g omniroute

# 基础网络与环境配置
ENV PORT=7860
ENV OMNIROUTE_PORT=7860
ENV HOST=0.0.0.0
ENV NODE_ENV=production

# ⚠️ 不要在 Dockerfile 里写密码/令牌。
# 在 HF Space → Settings → Variables and secrets 中设置:
#   INITIAL_PASSWORD  (Secret)
#   DOWNLOAD_TOKEN    (Secret)
# 未设置 DOWNLOAD_TOKEN 时下载服务会拒绝启动(见 download_server.js)。

EXPOSE 7860

# 写入下载服务脚本(纯 Node.js,无需额外依赖)
RUN cat > /app/download_server.js << 'EOF'
const http   = require('http');
const fs     = require('fs');
const path   = require('path');
const crypto = require('crypto');

const PORT  = 7861;
const TOKEN = process.env.DOWNLOAD_TOKEN;

// 强制要求设置令牌,否则拒绝启动,避免使用默认值导致数据泄露
if (!TOKEN || TOKEN.length < 16) {
  console.error('[download-server] 致命错误: 必须设置 DOWNLOAD_TOKEN 且长度 >= 16。已退出。');
  process.exit(1);
}

// 允许下载的安全目录白名单
const ALLOWED_DIRS = ['/data', '/root/.omniroute'];

// 恒定时间比较,防止时序攻击且不泄露长度
function tokenValid(provided) {
  const a = Buffer.from(String(provided));
  const b = Buffer.from(TOKEN);
  // 先对两个 buffer 做固定开销的 hash,再比较,规避长度差异
  const ha = crypto.createHash('sha256').update(a).digest();
  const hb = crypto.createHash('sha256').update(b).digest();
  return crypto.timingSafeEqual(ha, hb);
}

function safeResolvePath(filename) {
  // 只允许纯文件名,拒绝任何路径分隔符(防路径穿越)
  if (!filename || filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
    return null;
  }
  for (const dir of ALLOWED_DIRS) {
    const fullPath = path.join(dir, filename);
    if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
      return fullPath;
    }
  }
  return null;
}

const server = http.createServer((req, res) => {
  const url   = new URL(req.url, `http://localhost:${PORT}`);
  const token = url.searchParams.get('token') || '';
  const route = url.pathname;

  if (!tokenValid(token)) {
    res.writeHead(403, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'Forbidden: invalid token' }));
    return;
  }

  // ── GET /list ─────────────────────────────────────────────
  if (route === '/list') {
    const result = {};
    for (const dir of ALLOWED_DIRS) {
      try {
        result[dir] = fs.readdirSync(dir).map(name => {
          const stat = fs.statSync(path.join(dir, name));
          return { name, size: stat.size, mtime: stat.mtime };
        });
      } catch (_) {
        result[dir] = 'directory not found';
      }
    }
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(result, null, 2));
    return;
  }

  // ── GET /download?token=...&file=omni_storage.sqlite ──────
  if (route === '/download') {
    const filename = url.searchParams.get('file') || '';
    const fullPath = safeResolvePath(filename);

    if (!fullPath) {
      res.writeHead(404, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'File not found' }));   // 不回显用户输入
      return;
    }

    const stat = fs.statSync(fullPath);
    res.writeHead(200, {
      'Content-Type':        'application/octet-stream',
      'Content-Disposition': `attachment; filename="${path.basename(fullPath)}"`,
      'Content-Length':      stat.size,
    });
    fs.createReadStream(fullPath).pipe(res);
    console.log(`[download] ${fullPath} (${stat.size} bytes)`);
    return;
  }

  // ── 404 ───────────────────────────────────────────────────
  res.writeHead(404, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ error: 'Unknown route', routes: ['/list', '/download'] }));
});

// 只监听回环地址更安全;若需外部访问下载,再改为 0.0.0.0 并务必用强 TOKEN
server.listen(PORT, '0.0.0.0', () => {
  console.log(`[download-server] listening on ${PORT}, routes: /list /download?file=<name>`);
});
EOF

# 写入启动脚本,避免把复杂逻辑塞进 CMD
RUN cat > /app/entrypoint.sh << 'EOF'
#!/bin/sh
set -u

echo "=== 存储诊断开始 ==="
ls -lah /data 2>&1 || echo "/data 目录不存在"
ls -lah /root/.omniroute 2>&1 || echo "OmniRoute 目录不存在"
df -h
echo "=== 存储诊断结束 ==="

mkdir -p /root/.omniroute /data

# ── 开机从持久化存储恢复数据 ──
if [ -f /data/omni_storage.sqlite ]; then
  cp /data/omni_storage.sqlite /root/.omniroute/storage.sqlite && echo "✅ 恢复 storage.sqlite 成功"
else
  echo "⚠️  /data/omni_storage.sqlite 不存在,跳过恢复"
fi
if [ -f /data/omni_settings.json ]; then
  cp /data/omni_settings.json /root/.omniroute/settings.json && echo "✅ 恢复 settings.json 成功"
else
  echo "⚠️  /data/omni_settings.json 不存在,跳过恢复"
fi

# ── 后台:每 60 秒一致性备份到 /data ──
(while true; do
  sleep 60
  if [ -f /root/.omniroute/storage.sqlite ]; then
    # 用 sqlite3 .backup 做一致性快照,避免拷到写入中途的损坏状态
    if sqlite3 /root/.omniroute/storage.sqlite ".backup '/data/omni_storage.sqlite.tmp'" 2>/dev/null; then
      mv -f /data/omni_storage.sqlite.tmp /data/omni_storage.sqlite
      echo "💾 [backup] storage.sqlite → /data"
    else
      echo "⚠️ [backup] sqlite 备份失败"
    fi
  fi
  if [ -f /root/.omniroute/settings.json ]; then
    cp /root/.omniroute/settings.json /data/omni_settings.json.tmp &&
      mv -f /data/omni_settings.json.tmp /data/omni_settings.json &&
      echo "💾 [backup] settings.json → /data"
  fi
done) &

# ── 后台:启动下载服务(7861 端口)──
node /app/download_server.js &

# ── 前台:启动主程序 ──
exec env PORT=7860 omniroute
EOF
RUN chmod +x /app/entrypoint.sh

CMD ["/app/entrypoint.sh"]