feat: 初始化对象存储服务

- 支持 API Key 认证
- 文件上传/下载/预览
- 大文件分片上传
- 支持自定义端口和存储目录
This commit is contained in:
Cuishibing
2026-04-25 23:09:43 +08:00
commit bd0c22cd73
9 changed files with 2485 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
storage/
*.sqlite
*.log
.env
.DS_Store

98
DESIGN.md Normal file
View File

@@ -0,0 +1,98 @@
# 对象存储服务
## 技术栈
- Node.js + Express.js
- SQLite (better-sqlite3) + Sequelize
- 本地文件系统存储
## 快速开始
```bash
npm install
npm start # 默认 3000 端口, ./storage 目录
PORT=8080 npm start # 指定端口
STORAGE_DIR=/data myoss # 指定存储目录
PORT=8080 STORAGE_DIR=/data myoss # 同时指定
```
## 使用方法
### 1. 创建首个 API Keybootstrap
```bash
curl -X POST http://localhost:3000/api/keys/bootstrap -H "Content-Type: application/json" -d '{"name":"root"}'
# 返回: {"key":"xxx","name":"root"}
```
### 2. 上传文件
```bash
curl -X POST http://localhost:3000/api/files \
-H "X-API-Key: YOUR_KEY" \
-F "file=@filename.ext"
```
### 3. 分片上传
```bash
# 初始化
curl -X POST http://localhost:3000/api/uploads/init \
-H "X-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"filename":"large.bin","totalSize":2097152,"totalChunks":2}'
# 上传分片 (每个 1MB)
curl -X POST "http://localhost:3000/api/uploads/UPLOAD_ID/chunk?chunkIndex=0" \
-H "X-API-Key: YOUR_KEY" \
-F "chunk=@chunk0.bin"
# 完成
curl -X POST http://localhost:3000/api/uploads/UPLOAD_ID/complete \
-H "X-API-Key: YOUR_KEY"
```
### 4. 文件操作
- `GET /api/files` - 列表
- `GET /api/files/:key` - 信息
- `GET /api/files/:key/download` - 下载
- `GET /api/files/:key/preview` - 预览
- `DELETE /api/files/:key` - 删除
## API 设计
### 认证 (API Key)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/keys/bootstrap | 初始化首个 Key |
| POST | /api/keys | 创建新 Key |
| GET | /api/keys | 列出所有 Key |
| DELETE | /api/keys/:keyId | 删除 API Key |
### 文件操作
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/files | 上传文件 |
| GET | /api/files | 文件列表 |
| GET | /api/files/:key | 获取文件信息 |
| GET | /api/files/:key/download | 下载链接 |
| GET | /api/files/:key/preview | 预览链接 |
| DELETE | /api/files/:key | 删除文件 |
### 分片上传
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/uploads/init | 初始化分片上传 |
| POST | /api/uploads/:uploadId/chunk | 上传分片 |
| POST | /api/uploads/:uploadId/complete | 完成上传 |
## 鉴权方式
- 通过 Header `X-API-Key` 传递 API Key
- 每个 API Key 关联独立的文件目录
## 数据模型
### APIKey
- id, key, name, created_at
### File
- id, key, filename, size, mime_type, owner_id, created_at
### Chunk (分片)
- id, upload_id, chunk_index, size, stored_path

12
config/index.js Normal file
View File

@@ -0,0 +1,12 @@
module.exports = {
port: process.env.PORT || 3000,
storage: {
baseDir: process.env.STORAGE_DIR || './storage',
get filesDir() { return this.baseDir + '/files'; },
get uploadsDir() { return this.baseDir + '/uploads'; },
get databasePath() { return this.baseDir + '/database.sqlite'; },
},
upload: {
maxChunkSize: 10 * 1024 * 1024, // 10MB
},
};

1940
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "myoss",
"version": "1.0.0",
"description": "对象存储服务",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"start:8080": "PORT=8080 node src/index.js"
},
"dependencies": {
"better-sqlite3": "^9.2.2",
"crypto-js": "^4.2.0",
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"sequelize": "^6.35.0",
"sqlite3": "^6.0.1",
"uuid": "^9.0.0"
}
}

34
src/index.js Normal file
View File

@@ -0,0 +1,34 @@
const express = require('express');
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() {
const dirs = [config.storage.filesDir, config.storage.uploadsDir];
for (const dir of dirs) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
await sequelize.sync();
console.log('Database synchronized');
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
});
}
start().catch(console.error);

18
src/middleware/auth.js Normal file
View File

@@ -0,0 +1,18 @@
const { APIKey } = require('../models');
const authMiddleware = async (req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'Missing X-API-Key header' });
}
const keyRecord = await APIKey.findOne({ where: { key: apiKey } });
if (!keyRecord) {
return res.status(401).json({ error: 'Invalid API Key' });
}
req.apiKey = keyRecord;
next();
};
module.exports = authMiddleware;

49
src/models/index.js Normal file
View File

@@ -0,0 +1,49 @@
const { Sequelize, DataTypes } = require('sequelize');
const config = require('../../config');
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: config.storage.databasePath,
logging: false,
});
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 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 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 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(File, { foreignKey: 'ownerId' });
File.belongsTo(APIKey, { foreignKey: 'ownerId' });
APIKey.hasMany(UploadSession, { foreignKey: 'ownerId' });
UploadSession.belongsTo(APIKey, { foreignKey: 'ownerId' });
module.exports = { sequelize, APIKey, File, Chunk, UploadSession };

308
src/routes/index.js Normal file
View File

@@ -0,0 +1,308 @@
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 config = require('../../config');
const path = require('path');
const fs = require('fs');
const router = express.Router();
const getOwnerId = (key) => key.ownerId || key.id;
router.post('/keys/bootstrap', async (req, res) => {
const count = await APIKey.count();
if (count > 0) {
return res.status(403).json({ error: 'Bootstrap not allowed' });
}
const key = CryptoJS.lib.WordArray.random(16).toString();
const name = req.body.name || 'Root';
const apiKey = await APIKey.create({ key, name, ownerId: 0 });
const dir = path.join(config.storage.filesDir, 'root');
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
res.status(201).json({ key: apiKey.key, name: apiKey.name });
});
router.use(authMiddleware);
router.get('/keys', async (req, res) => {
const ownerId = req.apiKey.ownerId || req.apiKey.id;
const keys = await APIKey.findAll({ where: { ownerId } });
res.json(keys);
});
router.post('/keys', async (req, res) => {
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 dir = ownerId === 0
? path.join(config.storage.filesDir, 'root')
: path.join(config.storage.filesDir, apiKey.id.toString());
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
res.status(201).json(apiKey);
});
router.delete('/keys/:keyId', async (req, res) => {
const ownerId = getOwnerId(req.apiKey);
const apiKey = await APIKey.findOne({
where: { id: req.params.keyId, ownerId }
});
if (!apiKey) {
return res.status(404).json({ error: 'API Key not found' });
}
await apiKey.destroy();
res.status(204).send();
});
router.get('/files', async (req, res) => {
const ownerId = req.apiKey.ownerId || req.apiKey.id;
const files = await File.findAll({ where: { ownerId } });
res.json(files);
});
router.post('/files', async (req, res) => {
const multer = require('multer');
const getDir = (key) => {
const oid = getOwnerId(key);
return oid === 0
? path.join(config.storage.filesDir, 'root')
: path.join(config.storage.filesDir, key.id.toString());
};
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const dir = getDir(req.apiKey);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
cb(null, dir);
},
filename: (req, file, cb) => {
const fileKey = CryptoJS.lib.WordArray.random(8).toString();
cb(null, fileKey);
},
});
const upload = multer({ storage });
upload.single('file')(req, res, async (err) => {
if (err) return res.status(400).json({ error: err.message });
const { filename, size, mimeType, path: storagePath } = req.file;
const fileKey = path.basename(storagePath);
const file = await File.create({
fileKey,
filename: req.file.originalname,
size,
mimeType: mimeType || 'application/octet-stream',
storagePath,
ownerId: getOwnerId(req.apiKey),
});
res.status(201).json(file);
});
});
router.get('/files/:fileKey', async (req, res) => {
const ownerId = getOwnerId(req.apiKey);
const file = await File.findOne({
where: { fileKey: req.params.fileKey, ownerId }
});
if (!file) {
return res.status(404).json({ error: 'File not found' });
}
res.json(file);
});
router.get('/files/:fileKey/download', async (req, res) => {
const ownerId = getOwnerId(req.apiKey);
const file = await File.findOne({
where: { fileKey: req.params.fileKey, ownerId }
});
if (!file) {
return res.status(404).json({ error: 'File not found' });
}
res.download(file.storagePath, file.filename);
});
router.get('/files/:fileKey/preview', async (req, res) => {
const ownerId = getOwnerId(req.apiKey);
const file = await File.findOne({
where: { fileKey: req.params.fileKey, ownerId }
});
if (!file) {
return res.status(404).json({ error: 'File not found' });
}
res.sendFile(file.storagePath);
});
router.delete('/files/:fileKey', async (req, res) => {
const ownerId = getOwnerId(req.apiKey);
const file = await File.findOne({
where: { fileKey: req.params.fileKey, ownerId }
});
if (!file) {
return res.status(404).json({ error: 'File not found' });
}
if (fs.existsSync(file.storagePath)) {
fs.unlinkSync(file.storagePath);
}
await file.destroy();
res.status(204).send();
});
router.post('/uploads/init', async (req, res) => {
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({
id: uploadId,
filename,
totalSize,
chunkSize: chunkSize || config.upload.maxChunkSize,
totalChunks,
mimeType,
ownerId,
});
const dir = path.join(config.storage.uploadsDir, uploadId);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
res.status(201).json({ uploadId });
});
router.post('/uploads/:uploadId/chunk', async (req, res) => {
const { uploadId } = req.params;
const chunkIndex = parseInt(req.query.chunkIndex || req.body.chunkIndex, 10);
if (isNaN(chunkIndex)) {
return res.status(400).json({ error: 'Missing chunkIndex' });
}
const session = await UploadSession.findOne({
where: { id: uploadId, ownerId: getOwnerId(req.apiKey) }
});
if (!session) {
return res.status(404).json({ error: 'Upload session not found' });
}
if (chunkIndex < 0 || chunkIndex >= session.totalChunks) {
return res.status(400).json({ error: 'Invalid chunkIndex' });
}
const multer = require('multer');
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const dir = path.join(config.storage.uploadsDir, uploadId);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
cb(null, dir);
},
filename: (req, file, cb) => {
cb(null, chunkIndex.toString());
},
});
const upload = multer({ storage });
upload.single('chunk')(req, res, async (err) => {
if (err) return res.status(400).json({ error: err.message });
const { size, path: storedPath } = req.file;
await Chunk.create({
uploadId,
chunkIndex,
size,
storedPath,
});
res.status(201).json({ chunkIndex, size });
});
});
router.post('/uploads/:uploadId/complete', async (req, res) => {
const { uploadId } = req.params;
const session = await 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']],
});
if (chunks.length !== session.totalChunks) {
return res.status(400).json({
error: 'Chunk count mismatch',
expected: session.totalChunks,
received: chunks.length,
});
}
const fileKey = CryptoJS.lib.WordArray.random(8).toString();
const ownerId = getOwnerId(req.apiKey);
const finalDir = ownerId === 0
? path.join(config.storage.filesDir, 'root')
: path.join(config.storage.filesDir, req.apiKey.id.toString());
const finalPath = path.join(finalDir, fileKey);
if (!fs.existsSync(finalDir)) {
fs.mkdirSync(finalDir, { recursive: true });
}
const writeStream = fs.createWriteStream(finalPath);
for (const chunk of chunks) {
const chunkData = fs.readFileSync(chunk.storedPath);
writeStream.write(chunkData);
}
writeStream.end();
await new Promise((resolve) => writeStream.on('finish', resolve));
for (const chunk of chunks) {
fs.unlinkSync(chunk.storedPath);
}
fs.rmdirSync(path.join(config.storage.uploadsDir, uploadId));
const file = await File.create({
fileKey,
filename: session.filename,
size: session.totalSize,
mimeType: session.mimeType || 'application/octet-stream',
storagePath: finalPath,
ownerId: ownerId,
});
await session.destroy();
await Chunk.destroy({ where: { uploadId } });
res.status(201).json(file);
});
module.exports = router;