feat: 初始化对象存储服务
- 支持 API Key 认证 - 文件上传/下载/预览 - 大文件分片上传 - 支持自定义端口和存储目录
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
storage/
|
||||
*.sqlite
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
98
DESIGN.md
Normal file
98
DESIGN.md
Normal 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 Key(bootstrap)
|
||||
```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
12
config/index.js
Normal 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
1940
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal 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
34
src/index.js
Normal 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
18
src/middleware/auth.js
Normal 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
49
src/models/index.js
Normal 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
308
src/routes/index.js
Normal 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;
|
||||
Reference in New Issue
Block a user