Compare commits

...

7 Commits

Author SHA1 Message Date
Cuishibing
dd5d2fcd70 feat: 添加停止脚本 2026-04-26 14:47:31 +08:00
Cuishibing
384e437440 feat: 添加启动脚本支持端口、存储目录、密码参数 2026-04-26 13:00:22 +08:00
Cuishibing
f8fbd290c7 fix: 管理页面添加密码输入提示 2026-04-26 12:55:28 +08:00
Cuishibing
e53a674bff feat: 添加管理员密码保护 bootstrap 接口 2026-04-26 10:53:16 +08:00
Cuishibing
b2ed9002dd fix: 改用纯 js 实现数据库兼容 GLIBC 环境 2026-04-26 10:31:55 +08:00
Cuishibing
b885dbac0f fix: 改用 better-sqlite3 兼容 GLIBC 环境 2026-04-26 10:00:42 +08:00
Cuishibing
fdae636637 feat: 添加管理页面
- 静态页面支持
- 创建 Key、上传、下载、删除文件
2026-04-25 23:19:38 +08:00
14 changed files with 705 additions and 127 deletions

View File

@@ -12,15 +12,16 @@ npm install
npm start # 默认 3000 端口, ./storage 目录
PORT=8080 npm start # 指定端口
STORAGE_DIR=/data myoss # 指定存储目录
PORT=8080 STORAGE_DIR=/data myoss # 同时指定
# 首次启动需要设置管理员密码
ADMIN_PASSWORD=yourpassword npm start
```
## 使用方法
### 1. 创建首个 API Keybootstrap
### 初始化首个 API Key
```bash
curl -X POST http://localhost:3000/api/keys/bootstrap -H "Content-Type: application/json" -d '{"name":"root"}'
# 返回: {"key":"xxx","name":"root"}
curl -X POST http://localhost:3000/api/keys/bootstrap \
-H "Content-Type: application/json" \
-d '{"password":"yourpassword","name":"root"}'
```
### 2. 上传文件

View File

@@ -1,5 +1,8 @@
module.exports = {
port: process.env.PORT || 3000,
admin: {
password: process.env.ADMIN_PASSWORD || '',
},
storage: {
baseDir: process.env.STORAGE_DIR || './storage',
get filesDir() { return this.baseDir + '/files'; },

33
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"sequelize": "^6.35.0",
"sql.js": "^1.14.1",
"sqlite3": "^6.0.1",
"uuid": "^9.0.0"
}
@@ -278,10 +279,13 @@
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/concat-stream": {
"version": "1.6.2",
@@ -1618,6 +1622,12 @@
"simple-concat": "^1.0.0"
}
},
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"node_modules/sqlite3": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-6.0.1.tgz",
@@ -1714,6 +1724,12 @@
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-fs/node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
@@ -1744,15 +1760,6 @@
"node": ">= 6"
}
},
"node_modules/tar/node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",

View File

@@ -14,6 +14,7 @@
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"sequelize": "^6.35.0",
"sql.js": "^1.14.1",
"sqlite3": "^6.0.1",
"uuid": "^9.0.0"
}

248
public/index.html Normal file
View File

@@ -0,0 +1,248 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>对象存储管理</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; padding: 20px; }
.container { max-width: 800px; margin: 0 auto; }
h1 { text-align: center; margin-bottom: 20px; color: #333; }
.card { background: white; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.card h2 { margin-bottom: 15px; color: #444; font-size: 18px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; color: #666; }
input, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
button { background: #007aff; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 14px; }
button:hover { background: #005bb5; }
button:disabled { background: #ccc; cursor: not-allowed; }
.btn-secondary { background: #34c759; }
.btn-secondary:hover { background: #28a745; }
.btn-danger { background: #ff3b30; }
.btn-danger:hover { background: #dc3545; }
.row { display: flex; gap: 10px; }
.row input { flex: 1; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #eee; }
th { color: #666; font-weight: 500; }
.file-key { font-family: monospace; font-size: 12px; color: #666; }
.actions { display: flex; gap: 8px; }
.actions a, .actions button { padding: 6px 12px; font-size: 12px; text-decoration: none; border-radius: 4px; }
.actions a { background: #007aff; color: white; }
.actions button.danger { background: #ff3b30; color: white; border: none; }
.api-key-box { background: #f0f0f0; padding: 15px; border-radius: 4px; word-break: break-all; font-family: monospace; }
.empty { text-align: center; color: #999; padding: 20px; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="container">
<h1>对象存储管理</h1>
<div class="card" id="setup-card">
<h2>初始化</h2>
<div class="form-group">
<label>名称</label>
<input type="text" id="setup-name" placeholder="输入名称" value="root">
</div>
<button onclick="setup()">创建首个 API Key</button>
</div>
<div class="card hidden" id="main-card">
<h2>当前 API Key</h2>
<div class="api-key-box" id="current-key"></div>
<div style="margin-top: 10px;">
<button class="btn-secondary" onclick="createKey()">创建新 Key</button>
<button class="btn-danger" onclick="switchKey()">切换 Key</button>
</div>
</div>
<div class="card hidden" id="upload-card">
<h2>上传文件</h2>
<div class="form-group">
<input type="file" id="file-input" multiple>
</div>
<button onclick="uploadFile()">上传</button>
</div>
<div class="card hidden" id="files-card">
<h2>文件列表</h2>
<table>
<thead>
<tr>
<th>文件名</th>
<th>大小</th>
<th>操作</th>
</tr>
</thead>
<tbody id="files-tbody"></tbody>
</table>
</div>
</div>
<script>
let apiKey = localStorage.getItem('myoss_api_key');
const API_BASE = '';
async function request(path, options = {}) {
const headers = { ...options.headers };
if (apiKey) headers['X-API-Key'] = apiKey;
const res = await fetch(API_BASE + path, { ...options, headers });
if (res.status === 401) {
alert('API Key 无效');
localStorage.removeItem('myoss_api_key');
apiKey = null;
location.reload();
return null;
}
return res.json();
}
async function setup() {
const savedKey = localStorage.getItem('myoss_api_key');
if (savedKey) {
apiKey = savedKey;
location.reload();
return;
}
// 检查是否已有 key
const password = prompt('请输入管理员密码:');
if (!password) return;
const name = document.getElementById('setup-name').value || 'root';
const res = await fetch('/api/keys/bootstrap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password, name })
});
const data = await res.json();
if (data.error) {
alert(data.error);
return;
}
apiKey = data.key;
localStorage.setItem('myoss_api_key', apiKey);
location.reload();
}
async function createKey() {
const name = prompt('输入名称', 'new-key');
if (!name) return;
const data = await request('/api/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
alert('新 Key: ' + data.key);
apiKey = data.key;
localStorage.setItem('myoss_api_key', apiKey);
location.reload();
}
function switchKey() {
const key = prompt('输入 API Key', apiKey);
if (key) {
apiKey = key;
localStorage.setItem('myoss_api_key', apiKey);
location.reload();
}
}
async function uploadFile() {
const input = document.getElementById('file-input');
const files = input.files;
if (!files.length) return alert('请选择文件');
for (const file of files) {
const formData = new FormData();
formData.append('file', file);
const data = await request('/api/files', {
method: 'POST',
body: formData
});
if (data.error) {
alert(data.error);
return;
}
}
alert('上传成功');
loadFiles();
}
async function loadFiles() {
const files = await request('/api/files');
renderFiles(files);
}
function renderFiles(files) {
const tbody = document.getElementById('files-tbody');
if (!files || !files.length) {
tbody.innerHTML = '<tr><td colspan="3" class="empty">暂无文件</td></tr>';
return;
}
tbody.innerHTML = files.map(f => `
<tr>
<td>
<div>${f.filename}</div>
<div class="file-key">${f.fileKey}</div>
</td>
<td>${formatSize(f.size)}</td>
<td class="actions">
<a href="/api/files/${f.fileKey}/download?key=${apiKey}" target="_blank">下载</a>
<button class="danger" onclick="deleteFile('${f.fileKey}')">删除</button>
</td>
</tr>
`).join('');
}
async function deleteFile(fileKey) {
if (!confirm('确定删除?')) return;
await request(`/api/files/${fileKey}`, { method: 'DELETE' });
loadFiles();
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB';
}
async function init() {
if (!apiKey) {
// 先检查是否已有 bootstrap key
try {
const res = await fetch('/api/files', {
headers: { 'X-API-Key': 'check' }
});
if (res.status === 401 || res.status === 404) {
document.getElementById('setup-card').classList.remove('hidden');
return;
}
} catch (e) {}
document.getElementById('setup-card').classList.remove('hidden');
return;
}
try {
const files = await request('/api/files');
if (!files) {
document.getElementById('setup-card').classList.remove('hidden');
return;
}
document.getElementById('main-card').classList.remove('hidden');
document.getElementById('upload-card').classList.remove('hidden');
document.getElementById('files-card').classList.remove('hidden');
document.getElementById('current-key').textContent = apiKey;
renderFiles(files);
} catch (e) {
document.getElementById('setup-card').classList.remove('hidden');
}
}
init();
</script>
</body>
</html>

34
run.js Normal file
View File

@@ -0,0 +1,34 @@
const express = require('express');
const { init } = require('./src/models');
const config = require('./config');
const fs = require('fs');
(async () => {
const dirs = [config.storage.filesDir, config.storage.uploadsDir];
for (const dir of dirs) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
const { sequelize } = await init();
await sequelize.sync();
console.log('Database synchronized');
const app = express();
app.use(express.json());
app.use(express.static('public'));
const routes = require('./src/routes');
app.use('/api', routes);
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
});
})().catch(e => {
console.error(e);
process.exit(1);
});

View File

@@ -1,21 +1,9 @@
const express = require('express');
const { init } = require('./models');
const config = require('../config');
const { sequelize } = require('./models');
const fs = require('fs');
const path = require('path');
const app = express();
app.use(express.json());
const routes = require('./routes');
app.use('/api', routes);
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
async function start() {
(async () => {
const dirs = [config.storage.filesDir, config.storage.uploadsDir];
for (const dir of dirs) {
if (!fs.existsSync(dir)) {
@@ -23,12 +11,28 @@ async function start() {
}
}
await sequelize.sync();
console.log('Database synchronized');
// 重新创建数据库以确保表结构正确
if (fs.existsSync(config.storage.databasePath)) {
fs.unlinkSync(config.storage.databasePath);
}
const { sequelize } = await init();
console.log('Database ready');
const app = express();
app.use(express.json());
app.use(express.static('public'));
const routes = require('./routes');
app.use('/api', routes);
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
});
}
start().catch(console.error);
})().catch(e => {
console.error(e);
process.exit(1);
});

36
src/index.js.bak Normal file
View File

@@ -0,0 +1,36 @@
const express = require('express');
const config = require('../config');
const { init } = require('./models');
const fs = require('fs');
const app = express();
app.use(express.json());
app.use(express.static('public'));
const routes = require('./routes');
app.use('/api', routes);
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
async function start() {
const dirs = [config.storage.filesDir, config.storage.uploadsDir];
for (const dir of dirs) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
const { sequelize } = await init();
await sequelize.sync();
console.log('Database synchronized');
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
});
}
start().catch(console.error);

View File

@@ -1,12 +1,20 @@
const { APIKey } = require('../models');
let models;
const setModels = (m) => { models = m; };
const getModels = () => models;
const authMiddleware = async (req, res, next) => {
const apiKey = req.headers['x-api-key'];
const apiKey = req.headers['x-api-key'] || req.query.key;
if (!apiKey) {
return res.status(401).json({ error: 'Missing X-API-Key header' });
}
const keyRecord = await APIKey.findOne({ where: { key: apiKey } });
if (!models) {
return res.status(500).json({ error: 'Models not initialized' });
}
const { APIKey } = models;
const keyRecord = APIKey.findOne({ where: { key: apiKey } });
if (!keyRecord) {
return res.status(401).json({ error: 'Invalid API Key' });
}
@@ -15,4 +23,4 @@ const authMiddleware = async (req, res, next) => {
next();
};
module.exports = authMiddleware;
module.exports = { authMiddleware, setModels, getModels };

View File

@@ -1,49 +1,64 @@
const { Sequelize, DataTypes } = require('sequelize');
const config = require('../../config');
const initSqlJs = require('sql.js');
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: config.storage.databasePath,
logging: false,
});
let sequelize;
let models;
const APIKey = sequelize.define('APIKey', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
key: { type: DataTypes.STRING(64), unique: true, allowNull: false },
name: { type: DataTypes.STRING, allowNull: false },
ownerId: { type: DataTypes.INTEGER, defaultValue: 0 },
});
const init = async () => {
if (models) return models;
const SQL = await initSqlJs();
sequelize = new Sequelize({
dialect: 'sqlite',
storage: config.storage.databasePath,
logging: false,
dialectModule: SQL,
});
const File = sequelize.define('File', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
fileKey: { type: DataTypes.STRING(64), unique: true, allowNull: false },
filename: { type: DataTypes.STRING, allowNull: false },
size: { type: DataTypes.INTEGER, allowNull: false },
mimeType: { type: DataTypes.STRING },
storagePath: { type: DataTypes.STRING, allowNull: false },
}, { tableName: 'files' });
const APIKey = sequelize.define('APIKey', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
key: { type: DataTypes.STRING(64), unique: true, allowNull: false },
name: { type: DataTypes.STRING, allowNull: false },
ownerId: { type: DataTypes.INTEGER, defaultValue: 0 },
});
const Chunk = sequelize.define('Chunk', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
uploadId: { type: DataTypes.STRING(36), allowNull: false },
chunkIndex: { type: DataTypes.INTEGER, allowNull: false },
size: { type: DataTypes.INTEGER, allowNull: false },
storedPath: { type: DataTypes.STRING, allowNull: false },
}, { tableName: 'chunks' });
const File = sequelize.define('File', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
fileKey: { type: DataTypes.STRING(64), unique: true, allowNull: false },
filename: { type: DataTypes.STRING, allowNull: false },
size: { type: DataTypes.INTEGER, allowNull: false },
mimeType: { type: DataTypes.STRING },
storagePath: { type: DataTypes.STRING, allowNull: false },
}, { tableName: 'files' });
const UploadSession = sequelize.define('UploadSession', {
id: { type: DataTypes.STRING(36), primaryKey: true },
filename: { type: DataTypes.STRING, allowNull: false },
totalSize: { type: DataTypes.INTEGER, allowNull: false },
chunkSize: { type: DataTypes.INTEGER, allowNull: false },
totalChunks: { type: DataTypes.INTEGER, allowNull: false },
mimeType: { type: DataTypes.STRING },
}, { tableName: 'upload_sessions' });
const Chunk = sequelize.define('Chunk', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
uploadId: { type: DataTypes.STRING(36), allowNull: false },
chunkIndex: { type: DataTypes.INTEGER, allowNull: false },
size: { type: DataTypes.INTEGER, allowNull: false },
storedPath: { type: DataTypes.STRING, allowNull: false },
}, { tableName: 'chunks' });
APIKey.hasMany(File, { foreignKey: 'ownerId' });
File.belongsTo(APIKey, { foreignKey: 'ownerId' });
const UploadSession = sequelize.define('UploadSession', {
id: { type: DataTypes.STRING(36), primaryKey: true },
filename: { type: DataTypes.STRING, allowNull: false },
totalSize: { type: DataTypes.INTEGER, allowNull: false },
chunkSize: { type: DataTypes.INTEGER, allowNull: false },
totalChunks: { type: DataTypes.INTEGER, allowNull: false },
mimeType: { type: DataTypes.STRING },
}, { tableName: 'upload_sessions' });
APIKey.hasMany(UploadSession, { foreignKey: 'ownerId' });
UploadSession.belongsTo(APIKey, { foreignKey: 'ownerId' });
APIKey.hasMany(File, { foreignKey: 'ownerId' });
File.belongsTo(APIKey, { foreignKey: 'ownerId' });
module.exports = { sequelize, APIKey, File, Chunk, UploadSession };
APIKey.hasMany(UploadSession, { foreignKey: 'ownerId' });
UploadSession.belongsTo(APIKey, { foreignKey: 'ownerId' });
models = { sequelize, APIKey, File, Chunk, UploadSession };
return models;
};
const getModels = () => models;
module.exports = { init, getModels };

134
src/models/sqlite.js Normal file
View File

@@ -0,0 +1,134 @@
const initSqlJs = require('sql.js');
const fs = require('fs');
let db;
const init = async (dbPath) => {
const SQL = await initSqlJs();
if (fs.existsSync(dbPath)) {
const fileBuffer = fs.readFileSync(dbPath);
db = new SQL.Database(fileBuffer);
} else {
db = new SQL.Database();
}
const save = () => {
const data = db.export();
const buffer = Buffer.from(data);
fs.writeFileSync(dbPath, buffer);
};
db.run('CREATE TABLE IF NOT EXISTS api_keys (id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT UNIQUE NOT NULL, name TEXT NOT NULL, ownerId INTEGER DEFAULT 0)');
db.run('CREATE TABLE IF NOT EXISTS files (id INTEGER PRIMARY KEY AUTOINCREMENT, fileKey TEXT UNIQUE NOT NULL, filename TEXT NOT NULL, size INTEGER NOT NULL, mimeType TEXT, storagePath TEXT NOT NULL, ownerId INTEGER)');
db.run('CREATE TABLE IF NOT EXISTS chunks (id INTEGER PRIMARY KEY AUTOINCREMENT, uploadId TEXT NOT NULL, chunkIndex INTEGER NOT NULL, size INTEGER NOT NULL, storedPath TEXT NOT NULL)');
db.run('CREATE TABLE IF NOT EXISTS upload_sessions (id TEXT PRIMARY KEY, filename TEXT NOT NULL, totalSize INTEGER NOT NULL, chunkSize INTEGER NOT NULL, totalChunks INTEGER NOT NULL, mimeType TEXT, ownerId INTEGER)');
save();
const APIKey = {
create: (data) => {
db.run('INSERT INTO api_keys (key, name, ownerId) VALUES (?, ?, ?)', [data.key, data.name, data.ownerId || 0]);
save();
const res = db.exec('SELECT last_insert_rowid()');
return { id: res[0]?.values[0]?.[0], ...data };
},
findOne: (opts) => {
const where = Object.entries(opts.where || {}).map(([k,v]) => k + "='" + v + "'").join(' AND ');
const res = db.exec('SELECT * FROM api_keys WHERE ' + where);
if (!res.length || !res[0].values.length) return null;
const row = res[0].values[0];
return { id: row[0], key: row[1], name: row[2], ownerId: row[3] };
},
findAll: (opts) => {
const where = opts.where ? 'WHERE ' + Object.entries(opts.where).map(([k,v]) => k + "='" + v + "'").join(' AND ') : '';
const res = db.exec('SELECT * FROM api_keys ' + where);
if (!res.length) return [];
return res[0].values.map(row => ({ id: row[0], key: row[1], name: row[2], ownerId: row[3] }));
},
count: () => {
const res = db.exec('SELECT COUNT(*) FROM api_keys');
return res[0]?.values[0]?.[0] || 0;
},
destroy: (opts) => {
const where = Object.entries(opts.where || {}).map(([k,v]) => k + "='" + v + "'").join(' AND ');
db.run('DELETE FROM api_keys WHERE ' + where);
save();
}
};
const File = {
create: (data) => {
db.run('INSERT INTO files (fileKey, filename, size, mimeType, storagePath, ownerId) VALUES (?, ?, ?, ?, ?, ?)',
[data.fileKey, data.filename, data.size, data.mimeType, data.storagePath, data.ownerId]);
save();
const res = db.exec('SELECT last_insert_rowid()');
return { id: res[0]?.values[0]?.[0], ...data };
},
findOne: (opts) => {
const where = Object.entries(opts.where || {}).map(([k,v]) => k + "='" + v + "'").join(' AND ');
const res = db.exec('SELECT * FROM files WHERE ' + where);
if (!res.length || !res[0].values.length) return null;
const row = res[0].values[0];
return { id: row[0], fileKey: row[1], filename: row[2], size: row[3], mimeType: row[4], storagePath: row[5], ownerId: row[6] };
},
findAll: (opts) => {
const where = opts.where ? 'WHERE ' + Object.entries(opts.where).map(([k,v]) => k + "='" + v + "'").join(' AND ') : '';
const res = db.exec('SELECT * FROM files ' + where);
if (!res.length) return [];
return res[0].values.map(row => ({ id: row[0], fileKey: row[1], filename: row[2], size: row[3], mimeType: row[4], storagePath: row[5], ownerId: row[6] }));
},
destroy: (opts) => {
const where = Object.entries(opts.where || {}).map(([k,v]) => k + "='" + v + "'").join(' AND ');
db.run('DELETE FROM files WHERE ' + where);
save();
}
};
const Chunk = {
create: (data) => {
db.run('INSERT INTO chunks (uploadId, chunkIndex, size, storedPath) VALUES (?, ?, ?, ?)',
[data.uploadId, data.chunkIndex, data.size, data.storedPath]);
save();
const res = db.exec('SELECT last_insert_rowid()');
return { id: res[0]?.values[0]?.[0], ...data };
},
findAll: (opts) => {
const where = opts.where ? 'WHERE ' + Object.entries(opts.where).map(([k,v]) => k + "='" + v + "'").join(' AND ') : '';
const order = opts.order ? 'ORDER BY ' + opts.order[0][0] + ' ' + opts.order[0][1] : '';
const res = db.exec('SELECT * FROM chunks ' + where + ' ' + order);
if (!res.length) return [];
return res[0].values.map(row => ({ id: row[0], uploadId: row[1], chunkIndex: row[2], size: row[3], storedPath: row[4] }));
},
destroy: (opts) => {
const where = opts.where ? 'WHERE ' + Object.entries(opts.where).map(([k,v]) => k + "='" + v + "'").join(' AND ') : '';
db.run('DELETE FROM chunks ' + where);
save();
}
};
const UploadSession = {
create: (data) => {
db.run('INSERT INTO upload_sessions (id, filename, totalSize, chunkSize, totalChunks, mimeType, ownerId) VALUES (?, ?, ?, ?, ?, ?, ?)',
[data.id, data.filename, data.totalSize, data.chunkSize, data.totalChunks, data.mimeType, data.ownerId]);
save();
return data;
},
findOne: (opts) => {
const where = Object.entries(opts.where || {}).map(([k,v]) => k + "='" + v + "'").join(' AND ');
const res = db.exec('SELECT * FROM upload_sessions WHERE ' + where);
if (!res.length || !res[0].values.length) return null;
const row = res[0].values[0];
return { id: row[0], filename: row[1], totalSize: row[2], chunkSize: row[3], totalChunks: row[4], mimeType: row[5], ownerId: row[6] };
},
destroy: (opts) => {
const where = opts.where ? 'WHERE ' + Object.entries(opts.where).map(([k,v]) => k + "='" + v + "'").join(' AND ') : '';
db.run('DELETE FROM upload_sessions ' + where);
save();
}
};
return { db, APIKey, File, Chunk, UploadSession };
};
module.exports = { init };

View File

@@ -1,8 +1,7 @@
const express = require('express');
const { v4: uuidv4 } = require('crypto-js');
const CryptoJS = require('crypto-js');
const { APIKey, File, Chunk, UploadSession } = require('../models');
const authMiddleware = require('../middleware/auth');
const { init } = require('../models/sqlite');
const { setModels, authMiddleware } = require('../middleware/auth');
const config = require('../../config');
const path = require('path');
const fs = require('fs');
@@ -11,15 +10,32 @@ const router = express.Router();
const getOwnerId = (key) => key.ownerId || key.id;
let models;
const getModels = () => models;
const initModels = async () => {
models = await init(config.storage.databasePath);
setModels(models);
};
initModels().catch(console.error);
router.post('/keys/bootstrap', async (req, res) => {
const count = await APIKey.count();
const { APIKey } = getModels();
const count = APIKey.count();
if (count > 0) {
return res.status(403).json({ error: 'Bootstrap not allowed' });
}
const { password, name } = req.body;
if (!config.admin.password || password !== config.admin.password) {
return res.status(401).json({ error: 'Invalid admin password' });
}
const key = CryptoJS.lib.WordArray.random(16).toString();
const name = req.body.name || 'Root';
const apiKey = await APIKey.create({ key, name, ownerId: 0 });
const keyName = name || 'Root';
const apiKey = APIKey.create({ key, name: keyName, ownerId: 0 });
const dir = path.join(config.storage.filesDir, 'root');
if (!fs.existsSync(dir)) {
@@ -32,16 +48,18 @@ router.post('/keys/bootstrap', async (req, res) => {
router.use(authMiddleware);
router.get('/keys', async (req, res) => {
const { APIKey } = getModels();
const ownerId = req.apiKey.ownerId || req.apiKey.id;
const keys = await APIKey.findAll({ where: { ownerId } });
const keys = APIKey.findAll({ where: { ownerId } });
res.json(keys);
});
router.post('/keys', async (req, res) => {
const { APIKey } = getModels();
const key = CryptoJS.lib.WordArray.random(16).toString();
const name = req.body.name || 'Unnamed';
const ownerId = getOwnerId(req.apiKey);
const apiKey = await APIKey.create({ key, name, ownerId });
const apiKey = APIKey.create({ key, name, ownerId });
const dir = ownerId === 0
? path.join(config.storage.filesDir, 'root')
@@ -54,24 +72,25 @@ router.post('/keys', async (req, res) => {
});
router.delete('/keys/:keyId', async (req, res) => {
const { APIKey } = getModels();
const ownerId = getOwnerId(req.apiKey);
const apiKey = await APIKey.findOne({
where: { id: req.params.keyId, ownerId }
});
const apiKey = APIKey.findOne({ where: { id: parseInt(req.params.keyId), ownerId } });
if (!apiKey) {
return res.status(404).json({ error: 'API Key not found' });
}
await apiKey.destroy();
APIKey.destroy({ where: { id: parseInt(req.params.keyId), ownerId } });
res.status(204).send();
});
router.get('/files', async (req, res) => {
const { File } = getModels();
const ownerId = req.apiKey.ownerId || req.apiKey.id;
const files = await File.findAll({ where: { ownerId } });
const files = File.findAll({ where: { ownerId } });
res.json(files);
});
router.post('/files', async (req, res) => {
const { File } = getModels();
const multer = require('multer');
const getDir = (key) => {
const oid = getOwnerId(key);
@@ -101,9 +120,9 @@ router.post('/files', async (req, res) => {
const { filename, size, mimeType, path: storagePath } = req.file;
const fileKey = path.basename(storagePath);
const file = await File.create({
const file = File.create({
fileKey,
filename: req.file.originalname,
filename: req.file.originalname,
size,
mimeType: mimeType || 'application/octet-stream',
storagePath,
@@ -115,10 +134,9 @@ filename: req.file.originalname,
});
router.get('/files/:fileKey', async (req, res) => {
const { File } = getModels();
const ownerId = getOwnerId(req.apiKey);
const file = await File.findOne({
where: { fileKey: req.params.fileKey, ownerId }
});
const file = File.findOne({ where: { fileKey: req.params.fileKey, ownerId } });
if (!file) {
return res.status(404).json({ error: 'File not found' });
}
@@ -126,10 +144,9 @@ router.get('/files/:fileKey', async (req, res) => {
});
router.get('/files/:fileKey/download', async (req, res) => {
const { File } = getModels();
const ownerId = getOwnerId(req.apiKey);
const file = await File.findOne({
where: { fileKey: req.params.fileKey, ownerId }
});
const file = File.findOne({ where: { fileKey: req.params.fileKey, ownerId } });
if (!file) {
return res.status(404).json({ error: 'File not found' });
}
@@ -137,10 +154,9 @@ router.get('/files/:fileKey/download', async (req, res) => {
});
router.get('/files/:fileKey/preview', async (req, res) => {
const { File } = getModels();
const ownerId = getOwnerId(req.apiKey);
const file = await File.findOne({
where: { fileKey: req.params.fileKey, ownerId }
});
const file = File.findOne({ where: { fileKey: req.params.fileKey, ownerId } });
if (!file) {
return res.status(404).json({ error: 'File not found' });
}
@@ -148,10 +164,9 @@ router.get('/files/:fileKey/preview', async (req, res) => {
});
router.delete('/files/:fileKey', async (req, res) => {
const { File } = getModels();
const ownerId = getOwnerId(req.apiKey);
const file = await File.findOne({
where: { fileKey: req.params.fileKey, ownerId }
});
const file = File.findOne({ where: { fileKey: req.params.fileKey, ownerId } });
if (!file) {
return res.status(404).json({ error: 'File not found' });
}
@@ -159,28 +174,28 @@ router.delete('/files/:fileKey', async (req, res) => {
if (fs.existsSync(file.storagePath)) {
fs.unlinkSync(file.storagePath);
}
await file.destroy();
File.destroy({ where: { fileKey: req.params.fileKey, ownerId } });
res.status(204).send();
});
router.post('/uploads/init', async (req, res) => {
const { UploadSession } = getModels();
const { filename, totalSize, chunkSize, totalChunks, mimeType } = req.body;
if (!filename || !totalSize || !totalChunks) {
return res.status(400).json({ error: 'Missing required fields' });
}
const ownerId = getOwnerId(req.apiKey);
const uploadId = CryptoJS.lib.WordArray.random(16).toString();
const session = await UploadSession.create({
const session = UploadSession.create({
id: uploadId,
filename,
totalSize,
chunkSize: chunkSize || config.upload.maxChunkSize,
totalChunks,
mimeType,
ownerId,
ownerId: getOwnerId(req.apiKey),
});
const dir = path.join(config.storage.uploadsDir, uploadId);
@@ -192,6 +207,7 @@ router.post('/uploads/init', async (req, res) => {
});
router.post('/uploads/:uploadId/chunk', async (req, res) => {
const { UploadSession, Chunk } = getModels();
const { uploadId } = req.params;
const chunkIndex = parseInt(req.query.chunkIndex || req.body.chunkIndex, 10);
@@ -199,9 +215,7 @@ router.post('/uploads/:uploadId/chunk', async (req, res) => {
return res.status(400).json({ error: 'Missing chunkIndex' });
}
const session = await UploadSession.findOne({
where: { id: uploadId, ownerId: getOwnerId(req.apiKey) }
});
const session = UploadSession.findOne({ where: { id: uploadId, ownerId: getOwnerId(req.apiKey) } });
if (!session) {
return res.status(404).json({ error: 'Upload session not found' });
}
@@ -230,7 +244,7 @@ router.post('/uploads/:uploadId/chunk', async (req, res) => {
const { size, path: storedPath } = req.file;
await Chunk.create({
Chunk.create({
uploadId,
chunkIndex,
size,
@@ -242,19 +256,15 @@ router.post('/uploads/:uploadId/chunk', async (req, res) => {
});
router.post('/uploads/:uploadId/complete', async (req, res) => {
const { UploadSession, Chunk, File } = getModels();
const { uploadId } = req.params;
const session = await UploadSession.findOne({
where: { id: uploadId, ownerId: getOwnerId(req.apiKey) }
});
const session = UploadSession.findOne({ where: { id: uploadId, ownerId: getOwnerId(req.apiKey) } });
if (!session) {
return res.status(404).json({ error: 'Upload session not found' });
}
const chunks = await Chunk.findAll({
where: { uploadId },
order: [['chunkIndex', 'ASC']],
});
const chunks = Chunk.findAll({ where: { uploadId }, order: [['chunkIndex', 'ASC']] });
if (chunks.length !== session.totalChunks) {
return res.status(400).json({
@@ -290,17 +300,17 @@ const session = await UploadSession.findOne({
}
fs.rmdirSync(path.join(config.storage.uploadsDir, uploadId));
const file = await File.create({
const file = File.create({
fileKey,
filename: session.filename,
size: session.totalSize,
mimeType: session.mimeType || 'application/octet-stream',
storagePath: finalPath,
ownerId: ownerId,
ownerId,
});
await session.destroy();
await Chunk.destroy({ where: { uploadId } });
UploadSession.destroy({ where: { id: uploadId } });
Chunk.destroy({ where: { uploadId } });
res.status(201).json(file);
});

65
start.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/bash
PORT=3000
STORAGE_DIR=""
ADMIN_PASSWORD=""
show_help() {
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -p, --port PORT 服务端口 (默认: 3000)"
echo " -s, --storage DIR 存储目录 (默认: ./storage)"
echo " -w, --password PASS 管理员密码"
echo " -h, --help 显示帮助"
}
while [[ $# -gt 0 ]]; do
case $1 in
-p|--port)
PORT="$2"
shift 2
;;
-s|--storage)
STORAGE_DIR="$2"
shift 2
;;
-w|--password)
ADMIN_PASSWORD="$2"
shift 2
;;
-h|--help)
show_help
exit 0
;;
*)
echo "未知参数: $1"
show_help
exit 1
;;
esac
done
# 构建命令
CMD="npm start"
ENV_VARS="PORT=$PORT"
if [ -n "$STORAGE_DIR" ]; then
ENV_VARS="$ENV_VARS STORAGE_DIR=$STORAGE_DIR"
fi
if [ -n "$ADMIN_PASSWORD" ]; then
ENV_VARS="$ENV_VARS ADMIN_PASSWORD=$ADMIN_PASSWORD"
fi
echo "启动服务..."
echo " 端口: $PORT"
if [ -n "$STORAGE_DIR" ]; then
echo " 存储: $STORAGE_DIR"
fi
echo " 命令: $ENV_VARS $CMD"
nohup sh -c "$ENV_VARS $CMD" > nohup.out 2>&1 &
echo "服务已启动 (PID: $!)"
echo "查看日志: tail -f nohup.out"

12
stop.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
PID=$(pgrep -f "node src/index.js")
if [ -z "$PID" ]; then
echo "服务未运行"
exit 1
fi
echo "停止服务 (PID: $PID)..."
kill $PID
echo "服务已停止"