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