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

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;