kapi-mvc-blank 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +11 -0
- package/README.md +1147 -0
- package/compare-versions.js +52 -0
- package/package.json +44 -0
- package/prisma/migrations/20260116084200_init/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +25 -0
- package/prisma.config.ts +14 -0
- package/src/__tests__/user.controller.test.js +16 -0
- package/src/app.js +121 -0
- package/src/config/swagger.js +30 -0
- package/src/jest.config.cjs +10 -0
- package/src/server.js +10 -0
- package/src/services/jwt.service.js +7 -0
- package/src/services/prisma.service.js +26 -0
- package/src/utils/logger.js +21 -0
- package/src/v1/controllers/auth.controller.js +19 -0
- package/src/v1/controllers/upload.controller.js +15 -0
- package/src/v1/controllers/user.controller.js +16 -0
- package/src/v1/middlewares/auth.middleware.js +19 -0
- package/src/v1/middlewares/role.middleware.js +8 -0
- package/src/v1/models/user.model.js +6 -0
- package/src/v1/routes/auth.routes.js +9 -0
- package/src/v1/routes/upload.routes.js +80 -0
- package/src/v1/routes/user.routes.js +49 -0
- package/src/v1/services/jwt.service.js +7 -0
- package/test-api/index.php +47 -0
- package/test-endpoints.js +51 -0
- package/test-setup.js +21 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import http from 'http';
|
|
3
|
+
|
|
4
|
+
function testEndpoint(path) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
const options = {
|
|
7
|
+
hostname: 'localhost',
|
|
8
|
+
port: 3000,
|
|
9
|
+
path,
|
|
10
|
+
method: 'GET'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const req = http.request(options, (res) => {
|
|
14
|
+
let data = '';
|
|
15
|
+
|
|
16
|
+
res.on('data', (chunk) => {
|
|
17
|
+
data += chunk;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
res.on('end', () => {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(data);
|
|
23
|
+
resolve(parsed);
|
|
24
|
+
} catch {
|
|
25
|
+
resolve(data);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
req.on('error', (error) => {
|
|
31
|
+
resolve({ error: error.message });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
req.end();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log('🧪 Testing API v1 vs v2 comparison...\n');
|
|
39
|
+
|
|
40
|
+
// Test v1
|
|
41
|
+
console.log('📊 API v1 Response (/api/v1/users/info):');
|
|
42
|
+
const v1Info = await testEndpoint('/api/v1/users/info');
|
|
43
|
+
console.log(JSON.stringify(v1Info, null, 2));
|
|
44
|
+
|
|
45
|
+
// Test v2
|
|
46
|
+
console.log('\n📊 API v2 Response (/api/v2/users/info):');
|
|
47
|
+
const v2Info = await testEndpoint('/api/v2/users/info');
|
|
48
|
+
console.log(JSON.stringify(v2Info, null, 2));
|
|
49
|
+
|
|
50
|
+
console.log('\n✅ Comparison completed!');
|
|
51
|
+
console.log('✓ v2 includes: api_version, os, arch, hostname');
|
|
52
|
+
process.exit(0);
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kapi-mvc-blank",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node src/server.js",
|
|
8
|
+
"dev": "nodemon src/server.js",
|
|
9
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
10
|
+
"test:watch": "jest --watchAll"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"api",
|
|
14
|
+
"node",
|
|
15
|
+
"prisma",
|
|
16
|
+
"mvc"
|
|
17
|
+
],
|
|
18
|
+
"author": "@kferrandonFulbert",
|
|
19
|
+
"license": "ISC",
|
|
20
|
+
"description": "Framework MVC pour démarrer rapidement un projet d'API avec Node.js et Prisma (avec exemples d'authentification, upload de fichiers, documentation Swagger, logs avec Pino, etc.)",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@prisma/adapter-mariadb": "^7.2.0",
|
|
23
|
+
"@prisma/client": "^7.2.0",
|
|
24
|
+
"bcryptjs": "^3.0.3",
|
|
25
|
+
"cors": "^2.8.5",
|
|
26
|
+
"dotenv": "^17.2.3",
|
|
27
|
+
"express": "^5.2.1",
|
|
28
|
+
"express-rate-limit": "^8.2.1",
|
|
29
|
+
"jsonwebtoken": "^9.0.3",
|
|
30
|
+
"multer": "^1.4.5-lts.1",
|
|
31
|
+
"pino": "^10.1.0",
|
|
32
|
+
"pino-http": "^11.0.0",
|
|
33
|
+
"rad-api": "^1.0.3",
|
|
34
|
+
"swagger-jsdoc": "^6.2.8",
|
|
35
|
+
"swagger-ui-express": "^4.6.3"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^25.0.8",
|
|
39
|
+
"jest": "^30.2.0",
|
|
40
|
+
"nodemon": "^3.1.11",
|
|
41
|
+
"prisma": "^7.2.0",
|
|
42
|
+
"supertest": "^7.2.2"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- CreateTable
|
|
2
|
+
CREATE TABLE `User` (
|
|
3
|
+
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
4
|
+
`email` VARCHAR(191) NOT NULL,
|
|
5
|
+
`name` VARCHAR(191) NULL,
|
|
6
|
+
|
|
7
|
+
UNIQUE INDEX `User_email_key`(`email`),
|
|
8
|
+
PRIMARY KEY (`id`)
|
|
9
|
+
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
10
|
+
|
|
11
|
+
-- CreateTable
|
|
12
|
+
CREATE TABLE `Post` (
|
|
13
|
+
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
|
14
|
+
`title` VARCHAR(191) NOT NULL,
|
|
15
|
+
`content` VARCHAR(191) NULL,
|
|
16
|
+
`published` BOOLEAN NOT NULL DEFAULT false,
|
|
17
|
+
`authorId` INTEGER NOT NULL,
|
|
18
|
+
|
|
19
|
+
PRIMARY KEY (`id`)
|
|
20
|
+
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
21
|
+
|
|
22
|
+
-- AddForeignKey
|
|
23
|
+
ALTER TABLE `Post` ADD CONSTRAINT `Post_authorId_fkey` FOREIGN KEY (`authorId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
generator client {
|
|
2
|
+
provider = "prisma-client-js"
|
|
3
|
+
output = "../generated/prisma"
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
datasource db {
|
|
7
|
+
provider = "mysql"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
model User {
|
|
12
|
+
id Int @id @default(autoincrement())
|
|
13
|
+
email String @unique
|
|
14
|
+
name String?
|
|
15
|
+
posts Post[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
model Post {
|
|
19
|
+
id Int @id @default(autoincrement())
|
|
20
|
+
title String
|
|
21
|
+
content String?
|
|
22
|
+
published Boolean @default(false)
|
|
23
|
+
author User @relation(fields: [authorId], references: [id])
|
|
24
|
+
authorId Int
|
|
25
|
+
}
|
package/prisma.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// This file was generated by Prisma, and assumes you have installed the following:
|
|
2
|
+
// npm install --save-dev prisma dotenv
|
|
3
|
+
import "dotenv/config";
|
|
4
|
+
import { defineConfig } from "prisma/config";
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
schema: "prisma/schema.prisma",
|
|
8
|
+
migrations: {
|
|
9
|
+
path: "prisma/migrations",
|
|
10
|
+
},
|
|
11
|
+
datasource: {
|
|
12
|
+
url: process.env["DATABASE_URL"],
|
|
13
|
+
},
|
|
14
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import app, { initializeRoutes } from '../app.js';
|
|
3
|
+
|
|
4
|
+
describe('User Controller', () => {
|
|
5
|
+
beforeAll(async () => {
|
|
6
|
+
await initializeRoutes();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('GET /api/v1/users/info returns 200 and msg', async () => {
|
|
10
|
+
const res = await request(app)
|
|
11
|
+
.get('/api/v1/users/info')
|
|
12
|
+
.expect(200);
|
|
13
|
+
|
|
14
|
+
expect(res.body).toEqual({ msg: 'ok' });
|
|
15
|
+
});
|
|
16
|
+
});
|
package/src/app.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { dirname } from 'node:path';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { pinoMiddleware } from './utils/logger.js';
|
|
9
|
+
import { rateLimit } from 'express-rate-limit'
|
|
10
|
+
import swaggerUi from 'swagger-ui-express';
|
|
11
|
+
import swaggerSpec from './config/swagger.js';
|
|
12
|
+
import { initializePrisma } from './services/prisma.service.js';
|
|
13
|
+
|
|
14
|
+
dotenv.config();
|
|
15
|
+
|
|
16
|
+
// Initialize Prisma globally
|
|
17
|
+
initializePrisma();
|
|
18
|
+
|
|
19
|
+
const app = express();
|
|
20
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
21
|
+
const __dirname = dirname(__filename);
|
|
22
|
+
|
|
23
|
+
const UPLOAD_DIR = process.env.UPLOAD_DIR || 'uploads';
|
|
24
|
+
|
|
25
|
+
const limiter = rateLimit({
|
|
26
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
27
|
+
limit: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes).
|
|
28
|
+
standardHeaders: 'draft-8', // draft-6: `RateLimit-*` headers; draft-7 & draft-8: combined `RateLimit` header
|
|
29
|
+
legacyHeaders: false, // Disable the `X-RateLimit-*` headers.
|
|
30
|
+
ipv6Subnet: 56, // Set to 60 or 64 to be less aggressive, or 52 or 48 to be more aggressive
|
|
31
|
+
})
|
|
32
|
+
app.use(limiter);
|
|
33
|
+
|
|
34
|
+
app.use(cors());
|
|
35
|
+
app.use(pinoMiddleware);
|
|
36
|
+
app.use(express.json());
|
|
37
|
+
|
|
38
|
+
// Serve uploaded files statically
|
|
39
|
+
app.use(`/${UPLOAD_DIR}`, express.static(UPLOAD_DIR));
|
|
40
|
+
|
|
41
|
+
// Swagger UI
|
|
42
|
+
app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
|
43
|
+
app.get('/docs.json', (req, res) => res.json(swaggerSpec));
|
|
44
|
+
|
|
45
|
+
// Export function to initialize routes
|
|
46
|
+
export async function initializeRoutes() {
|
|
47
|
+
const versionsDir = path.join(__dirname, './');
|
|
48
|
+
console.log(versionsDir);
|
|
49
|
+
const versionDirs = fs.readdirSync(versionsDir).filter(file => {
|
|
50
|
+
const fullPath = path.join(versionsDir, file);
|
|
51
|
+
return fs.statSync(fullPath).isDirectory() && /^v\d+$/.test(file);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
// Sort versions numerically (v1, v2, v3, etc.)
|
|
56
|
+
versionDirs.sort((a, b) => {
|
|
57
|
+
const numA = Number.parseInt(a.slice(1), 10);
|
|
58
|
+
const numB = Number.parseInt(b.slice(1), 10);
|
|
59
|
+
return numA - numB;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Load routes for each version
|
|
63
|
+
for (const version of versionDirs) {
|
|
64
|
+
const versionPath = path.join(__dirname, version);
|
|
65
|
+
const routesPath = path.join(versionPath, 'routes');
|
|
66
|
+
|
|
67
|
+
if (fs.existsSync(routesPath)) {
|
|
68
|
+
const routeFiles = fs.readdirSync(routesPath).filter(f => f.endsWith('.routes.js'));
|
|
69
|
+
console.log(routeFiles);
|
|
70
|
+
for (const routeFile of routeFiles) {
|
|
71
|
+
let routeName = routeFile.replace('.routes.js', ''); // e.g., 'auth', 'user', 'upload'
|
|
72
|
+
|
|
73
|
+
// Pluralize route names for better REST convention
|
|
74
|
+
if (routeName === 'user') routeName = 'users';
|
|
75
|
+
if (routeName === 'citation') routeName = 'citations';
|
|
76
|
+
if (routeName === 'upload') routeName = 'uploads';
|
|
77
|
+
|
|
78
|
+
const routePath = path.join(routesPath, routeFile);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const routeModule = await import(`file://${routePath}`);
|
|
82
|
+
const router = routeModule.default;
|
|
83
|
+
|
|
84
|
+
// Register route: /api/v1/auth, /api/v1/users, /api/v1/uploads, etc.
|
|
85
|
+
const apiPath = `/api/${version}/${routeName}`;
|
|
86
|
+
app.use(apiPath, router);
|
|
87
|
+
console.log(`✓ Route registered: ${apiPath}`);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`✗ Failed to load route ${routePath}:`, err.message);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Error handler — normalize multer and validation errors
|
|
97
|
+
app.use((err, req, res, next) => {
|
|
98
|
+
// Multer-specific errors
|
|
99
|
+
if (err?.name === 'MulterError') {
|
|
100
|
+
const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
|
|
101
|
+
req.log?.warn({ err }, 'Multer error');
|
|
102
|
+
return res.status(status).json({ error: err.message, code: err.code });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Errors thrown by validators / custom errors
|
|
106
|
+
if (err && (err.message || err.status)) {
|
|
107
|
+
const status = Number.isInteger(err.status) && err.status >= 400 ? err.status : 400;
|
|
108
|
+
req.log?.warn({ err }, 'Request error');
|
|
109
|
+
return res.status(status).json({ error: err.message || 'Bad Request' });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Fallback to 500
|
|
113
|
+
if (err) {
|
|
114
|
+
req.log?.error({ err }, 'Unhandled error');
|
|
115
|
+
return res.status(500).json({ error: 'Internal Server Error' });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return next();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
export default app;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import swaggerJsdoc from 'swagger-jsdoc';
|
|
2
|
+
|
|
3
|
+
const options = {
|
|
4
|
+
definition: {
|
|
5
|
+
openapi: '3.0.0',
|
|
6
|
+
info: {
|
|
7
|
+
title: 'API documentation',
|
|
8
|
+
version: process.env.npm_package_version || '1.0.0',
|
|
9
|
+
description: 'MVC API documentation blank template'
|
|
10
|
+
},
|
|
11
|
+
servers: [
|
|
12
|
+
{ url: '/api/v1' },
|
|
13
|
+
{ url: '/api/v2' }
|
|
14
|
+
],
|
|
15
|
+
components: {
|
|
16
|
+
securitySchemes: {
|
|
17
|
+
bearerAuth: {
|
|
18
|
+
type: 'http',
|
|
19
|
+
scheme: 'bearer',
|
|
20
|
+
bearerFormat: 'JWT'
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
apis: ['./src/v*/routes/*.js', './src/v*/controllers/*.js']
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const swaggerSpec = swaggerJsdoc(options);
|
|
29
|
+
|
|
30
|
+
export default swaggerSpec;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
testEnvironment: 'node',
|
|
3
|
+
extensionsToTreatAsEsm: ['.js'],
|
|
4
|
+
transform: {},
|
|
5
|
+
testMatch: ['**/__tests__/**/*.test.js'],
|
|
6
|
+
moduleNameMapper: {
|
|
7
|
+
'^(\\.{1,2}/.*)\\.js$': '$1'
|
|
8
|
+
},
|
|
9
|
+
transformIgnorePatterns: ['node_modules/(?!(supertest|.*\\.mjs$))']
|
|
10
|
+
};
|
package/src/server.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
|
|
2
|
+
import { PrismaClient } from '../../generated/prisma/client.js';
|
|
3
|
+
|
|
4
|
+
let prisma;
|
|
5
|
+
|
|
6
|
+
export function initializePrisma() {
|
|
7
|
+
const maria = new PrismaMariaDb({
|
|
8
|
+
host: process.env.DATABASE_HOST,
|
|
9
|
+
port: process.env.DATABASE_PORT,
|
|
10
|
+
user: process.env.DATABASE_USER,
|
|
11
|
+
password: process.env.DATABASE_PASSWORD,
|
|
12
|
+
database: process.env.DATABASE_NAME
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
prisma = new PrismaClient({ adapter: maria });
|
|
16
|
+
return prisma;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getPrisma() {
|
|
20
|
+
if (!prisma) {
|
|
21
|
+
throw new Error('Prisma has not been initialized. Call initializePrisma() first.');
|
|
22
|
+
}
|
|
23
|
+
return prisma;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default prisma;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import pino from 'pino';
|
|
4
|
+
import pinoHttp from 'pino-http';
|
|
5
|
+
|
|
6
|
+
const LOG_DIR = process.env.LOG_DIR || 'logs';
|
|
7
|
+
const LOG_FILE = process.env.LOG_FILE || 'app.log';
|
|
8
|
+
|
|
9
|
+
// Ensure log directory exists
|
|
10
|
+
if (!fs.existsSync(LOG_DIR)) {
|
|
11
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const logPath = path.join(LOG_DIR, LOG_FILE);
|
|
15
|
+
const destination = pino.destination(logPath);
|
|
16
|
+
|
|
17
|
+
const logger = pino({ level: process.env.LOG_LEVEL || 'info' }, destination);
|
|
18
|
+
|
|
19
|
+
const pinoMiddleware = pinoHttp({ logger });
|
|
20
|
+
|
|
21
|
+
export { logger, pinoMiddleware };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import bcrypt from 'bcryptjs';
|
|
2
|
+
import { generateToken } from '../services/jwt.service.js';
|
|
3
|
+
|
|
4
|
+
// this is an example controller for handling authentication (register, login)
|
|
5
|
+
export const register = async (req, res) => {
|
|
6
|
+
// TODO : enregistrer l'utilisateur
|
|
7
|
+
res.json({ message: 'Register OK' });
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const login = async (req, res) => {
|
|
11
|
+
// TODO : vérifier email + mot de passe
|
|
12
|
+
|
|
13
|
+
const token = generateToken({
|
|
14
|
+
id: 1,
|
|
15
|
+
role: 'user'
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
res.json({ token });
|
|
19
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// this is an exemple controller for handling file uploads
|
|
2
|
+
export const uploadFile = (req, res) => {
|
|
3
|
+
|
|
4
|
+
if (!req.file) {
|
|
5
|
+
req.log?.warn("No file uploaded");
|
|
6
|
+
return res.status(400).json({ error: 'No file uploaded' });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Log upload
|
|
10
|
+
req.log?.info({ filename: req.file.filename, size: req.file.size }, 'file uploaded');
|
|
11
|
+
|
|
12
|
+
const uploadDir = process.env.UPLOAD_DIR || 'uploads';
|
|
13
|
+
const fileUrl = `${req.protocol}://${req.get('host')}/${uploadDir}/${req.file.filename}`;
|
|
14
|
+
res.status(201).json({ filename: req.file.filename, url: fileUrl });
|
|
15
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// this is an example controller for handling user profile and info endpoints
|
|
2
|
+
export const profile = (req, res) => {
|
|
3
|
+
req.log?.info({ userId: req.user?.id }, 'profile requested');
|
|
4
|
+
res.json({
|
|
5
|
+
id: req.user.id,
|
|
6
|
+
role: req.user.role
|
|
7
|
+
});
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const info = (req, res) => {
|
|
11
|
+
req.log?.info('info endpoint');
|
|
12
|
+
res.status(200);
|
|
13
|
+
res.json({
|
|
14
|
+
msg: "ok"
|
|
15
|
+
});
|
|
16
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
2
|
+
|
|
3
|
+
export const authMiddleware = (req, res, next) => {
|
|
4
|
+
const authHeader = req.headers.authorization;
|
|
5
|
+
|
|
6
|
+
if (!authHeader) {
|
|
7
|
+
return res.status(401).json({ error: 'Token missing' });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const token = authHeader.split(' ')[1];
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
14
|
+
req.user = decoded;
|
|
15
|
+
next();
|
|
16
|
+
} catch {
|
|
17
|
+
res.status(401).json({ error: 'Invalid token' });
|
|
18
|
+
}
|
|
19
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import multer from 'multer';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { uploadFile } from '../controllers/upload.controller.js';
|
|
6
|
+
|
|
7
|
+
const router = Router();
|
|
8
|
+
|
|
9
|
+
const uploadDir = process.env.UPLOAD_DIR || 'uploads';
|
|
10
|
+
const MAX_SIZE = parseInt(process.env.UPLOAD_MAX_SIZE || String(5 * 1024 * 1024), 10); // default 5MB
|
|
11
|
+
|
|
12
|
+
// Ensure upload directory exists
|
|
13
|
+
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
14
|
+
|
|
15
|
+
const storage = multer.diskStorage({
|
|
16
|
+
destination: (req, file, cb) => cb(null, uploadDir),
|
|
17
|
+
filename: (req, file, cb) => {
|
|
18
|
+
const ext = path.extname(file.originalname);
|
|
19
|
+
const name = `${Date.now()}-${Math.random().toString(36).slice(2,8)}${ext}`;
|
|
20
|
+
cb(null, name);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const imageFileFilter = (req, file, cb) => {
|
|
25
|
+
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
|
26
|
+
if (allowed.includes(file.mimetype)) cb(null, true);
|
|
27
|
+
else cb(new Error('Only image files are allowed'), false);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const upload = multer({ storage, fileFilter: imageFileFilter, limits: { fileSize: MAX_SIZE } });
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @openapi
|
|
34
|
+
* /uploads:
|
|
35
|
+
* post:
|
|
36
|
+
* summary: Upload an image file
|
|
37
|
+
* consumes:
|
|
38
|
+
* - multipart/form-data
|
|
39
|
+
* requestBody:
|
|
40
|
+
* content:
|
|
41
|
+
* multipart/form-data:
|
|
42
|
+
* schema:
|
|
43
|
+
* type: object
|
|
44
|
+
* properties:
|
|
45
|
+
* file:
|
|
46
|
+
* type: string
|
|
47
|
+
* format: binary
|
|
48
|
+
* responses:
|
|
49
|
+
* 201:
|
|
50
|
+
* description: File uploaded
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const storageWithName = multer.diskStorage({
|
|
54
|
+
destination: (req, file, cb) => cb(null, uploadDir),
|
|
55
|
+
filename: (req, file, cb) => {
|
|
56
|
+
const requested = req.params?.name || '';
|
|
57
|
+
// sanitize base name and keep or infer extension
|
|
58
|
+
const safeBase = path.basename(requested, path.extname(requested)).replace(/[^a-zA-Z0-9-_\.]/g, '-');
|
|
59
|
+
const extFromReq = path.extname(requested);
|
|
60
|
+
const ext = extFromReq || path.extname(file.originalname) || '';
|
|
61
|
+
const name = safeBase ? `${safeBase}${ext}` : `${Date.now()}-${Math.random().toString(36).slice(2,8)}${ext}`;
|
|
62
|
+
cb(null, name);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const uploadWithName = multer({ storage: storageWithName, fileFilter: imageFileFilter, limits: { fileSize: MAX_SIZE } });
|
|
67
|
+
|
|
68
|
+
router.post('/:name', (req, res, next) => {
|
|
69
|
+
const handler = uploadWithName.fields([{ name: 'file', maxCount: 1 }, { name: 'image', maxCount: 1 }]);
|
|
70
|
+
handler(req, res, (err) => {
|
|
71
|
+
if (err) return next(err);
|
|
72
|
+
if (!req.file && req.files) {
|
|
73
|
+
if (req.files.file && req.files.file.length) req.file = req.files.file[0];
|
|
74
|
+
else if (req.files.image && req.files.image.length) req.file = req.files.image[0];
|
|
75
|
+
}
|
|
76
|
+
uploadFile(req, res, next);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export default router;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @openapi
|
|
3
|
+
* /users/profile:
|
|
4
|
+
* get:
|
|
5
|
+
* summary: Get current user profile
|
|
6
|
+
* security:
|
|
7
|
+
* - bearerAuth: []
|
|
8
|
+
* responses:
|
|
9
|
+
* 200:
|
|
10
|
+
* description: User profile
|
|
11
|
+
* content:
|
|
12
|
+
* application/json:
|
|
13
|
+
* schema:
|
|
14
|
+
* type: object
|
|
15
|
+
* properties:
|
|
16
|
+
* id:
|
|
17
|
+
* type: string
|
|
18
|
+
* role:
|
|
19
|
+
* type: string
|
|
20
|
+
* 401:
|
|
21
|
+
* description: Unauthorized
|
|
22
|
+
*/
|
|
23
|
+
import { Router } from 'express';
|
|
24
|
+
import { profile, info } from '../controllers/user.controller.js';
|
|
25
|
+
import { authMiddleware } from '../middlewares/auth.middleware.js';
|
|
26
|
+
|
|
27
|
+
const router = Router();
|
|
28
|
+
|
|
29
|
+
router.get('/profile', authMiddleware, profile);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @openapi
|
|
33
|
+
* /users/info:
|
|
34
|
+
* get:
|
|
35
|
+
* summary: Simple info endpoint
|
|
36
|
+
* responses:
|
|
37
|
+
* 200:
|
|
38
|
+
* description: OK
|
|
39
|
+
* content:
|
|
40
|
+
* application/json:
|
|
41
|
+
* schema:
|
|
42
|
+
* type: object
|
|
43
|
+
* properties:
|
|
44
|
+
* msg:
|
|
45
|
+
* type: string
|
|
46
|
+
*/
|
|
47
|
+
router.get('/info', info);
|
|
48
|
+
|
|
49
|
+
export default router;
|