godcrm/backend/routes/v3/files.js
GOD CRM Release 065f2edd23
Some checks are pending
CI / Lint / Typecheck / Test / Build (push) Waiting to run
CI / PostgreSQL Integration Tests (push) Waiting to run
GOD CRM — public scrubbed snapshot (2026-08-30)
Refresh of the open-core distribution from the private tree.

Included since the previous snapshot:
- Mail module (ADR-158/159/160/169): composer, labels, scheduling,
  attachments, reply-tokens, IMAP/SMTP bridge + migrations 079-083
- Crawler-readable SSR for /blog and public spaces (ADR-190):
  blogSeo, publicDocsSeo, per-space SEO prefs, blog index/post pages
- Registration policy + referral/promo settings (ADR-183/188)
- Message translation + language detection (ADR-185)
- Reddit connector for the agent-tool surface

Excised from the public distribution (unchanged policy): infrastructure
topology and host config, internal ops scripts, DB cleanup snapshots,
business documents, throwaway debug scripts, and two private product
lines (SC-SIM simulator, personal one-off tools). Real host addresses
are replaced with placeholders; credential-shaped literals are redacted.

Frontend build verified green on this tree.
2026-08-30 15:13:28 +03:00

676 lines
22 KiB
JavaScript

// API v3: Files Routes - File upload, download, management
/**
* @swagger
* components:
* schemas:
* File:
* type: object
* properties:
* id:
* type: integer
* file_id:
* type: string
* original_name:
* type: string
* mime_type:
* type: string
* size:
* type: integer
* space_id:
* type: integer
* url:
* type: string
*/
import express from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { dbAll, dbGet, dbRun } from '../../database/connection.js';
import { fileLogger } from '../../utils/logger.js';
import { success, created, notFound, badRequest, forbidden, error } from '../../utils/response.js';
const router = express.Router();
/**
* SEC-1: Check if user has access to space (owner or admin)
*/
async function checkSpaceAccess(spaceId, userId, userRole) {
if (!spaceId) return { allowed: true }; // Files without space are accessible
const space = await dbGet('SELECT * FROM spaces WHERE id = ?', [spaceId]);
if (!space) {
return { allowed: false, error: 'Space not found', status: 404 };
}
const isOwner = space.owner_id === userId;
const isAdmin = userRole === 'admin' || userRole === 'owner';
if (!isOwner && !isAdmin) {
return { allowed: false, error: 'Access denied to this space', status: 403 };
}
return { allowed: true, space };
}
// Base upload path
const UPLOAD_BASE_PATH = process.env.UPLOAD_PATH || '/var/lib/business-crm-data/uploads';
// ADR-098 / Bug fix: Use relative URLs for uploads to avoid domain mismatch (devcrm vs crm).
// The browser will resolve relative URLs against the current origin automatically.
// For absolute URLs needed by backend/AI agents, we read BASE_URL → APP_URL → fallback.
const BASE_URL = process.env.BASE_URL || process.env.APP_URL || 'https://crm.hltrn.cc';
// Ensure upload directories exist
const ensureDir = (dirPath) => {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
};
// Generate unique file ID
const generateFileId = () => {
return `file_${Date.now()}_${crypto.randomBytes(8).toString('hex')}`;
};
// Configure multer storage - use temp storage first
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// Use temp folder first, will move to correct folder after parsing body
const tempPath = path.join(UPLOAD_BASE_PATH, '.temp');
ensureDir(tempPath);
cb(null, tempPath);
},
filename: (req, file, cb) => {
const fileId = generateFileId();
const ext = path.extname(file.originalname);
const safeName = `${fileId}${ext}`;
req.generatedFileId = fileId;
req.generatedFileName = safeName;
cb(null, safeName);
}
});
// File filter - allow common file types
// SEC-3: Removed image/svg+xml and text/html to prevent XSS attacks
const fileFilter = (req, file, cb) => {
const allowedMimes = [
// Images (NO SVG - XSS risk)
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'image/heic', 'image/heif',
// Documents
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.spreadsheet',
// Ebooks
'application/epub+zip', 'application/x-mobipocket-ebook',
// Text (NO HTML - XSS risk)
'text/plain', 'text/csv', 'text/markdown',
// Archives
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
// Mobile apps
'application/vnd.android.package-archive', 'application/octet-stream',
// Code (text/css and text/javascript are safe as they're served as downloads)
'application/json', 'application/xml', 'text/javascript', 'text/css',
// 3D models
'model/stl', 'application/sla', 'application/vnd.ms-pki.stl',
'model/gltf-binary', 'model/gltf+json', 'application/octet-stream',
'model/obj', 'model/fbx'
];
if (allowedMimes.includes(file.mimetype)) {
return cb(null, true);
}
// ADR-194 B2: do NOT throw here. A thrown fileFilter error aborts multer on the
// FIRST bad file, so the response could only ever name one of them. Collect every
// rejection on the request instead; the route handler turns a non-empty list into
// a 400 that names them all. All-or-nothing is preserved there, not here — nothing
// is written to the DB when anything was rejected.
(req.rejectedFiles ||= []).push({
name: file.originalname,
type: file.mimetype,
reason: 'type_not_allowed'
});
fileLogger.warn(
{ name: file.originalname, mimetype: file.mimetype, userId: req.user?.id },
'Upload rejected: MIME type not allowed'
);
cb(null, false);
};
// Multer upload middleware
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 100 * 1024 * 1024, // 100MB max (temporarily raised for APK upload)
files: 10 // Max 10 files at once
}
});
/**
* POST /api/v3/files/upload
* Upload single or multiple files
* @swagger
* /api/v3/files/upload:
* post:
* summary: Upload files
* tags: [Files]
* security:
* - bearerAuth: []
* requestBody:
* content:
* multipart/form-data:
* schema:
* type: object
* properties:
* files:
* type: array
* items:
* type: string
* format: binary
* spaceId:
* type: integer
* responses:
* 201:
* description: Files uploaded
*/
// ADR-0016 §Phase 5: visibility enum mirrors fileGuard.VALID_VISIBILITY.
// Kept as a literal so the upload route doesn't import the middleware.
const UPLOAD_VISIBILITY_VALUES = ['private', 'internal', 'public'];
router.post('/files/upload', upload.array('files', 10), async (req, res) => {
try {
const { projectId, tableId, rowId, columnId, description } = req.body;
const spaceId = req.body.spaceId || req.body.space_id || req.query.spaceId || req.query.space_id || null;
const userId = req.user?.id || null;
// ADR-0016 §Phase 5: accept visibility from body or query. Default is
// 'internal' so chat attachments, agent generations and document attaches
// render in <img> for any logged-in user. Column-bound uploads are
// unaffected because fileGuard reads column.config.visibility first.
// Callers pass visibility:'private' (system snapshots) or 'public'
// explicitly when they want a different policy.
const rawVisibility = req.body.visibility || req.query.visibility || null;
if (rawVisibility && !UPLOAD_VISIBILITY_VALUES.includes(rawVisibility)) {
return badRequest(
res,
`Invalid visibility "${rawVisibility}". Must be one of: ${UPLOAD_VISIBILITY_VALUES.join(', ')}`
);
}
const visibility = rawVisibility || 'internal';
fileLogger.debug({ spaceId, projectId, userId, visibility }, 'Upload request received');
// ADR-194 B2: all-or-nothing, but loud. Anything rejected by fileFilter fails the
// whole request — drop the temp files multer already wrote for the accepted ones
// and answer with every refused file named. Checked before the "no files" branch:
// a batch of only-rejected files leaves req.files empty and must not be reported
// as "No files uploaded".
const rejected = req.rejectedFiles || [];
if (rejected.length > 0) {
for (const file of req.files || []) {
fs.rmSync(file.path, { force: true });
}
const message = rejected.length === 1
? `Файл «${rejected[0].name}» отклонён: тип ${rejected[0].type} не поддерживается`
: `Отклонено файлов: ${rejected.length}${rejected.map((f) => f.name).join(', ')}`;
// Same envelope as utils/response.js `error()`, plus `rejected` — the helper only
// exposes a `details` key and B3 renders the per-file list from `error.rejected`.
return res.status(400).json({
success: false,
error: { code: 'FILE_TYPE_NOT_ALLOWED', message, rejected },
timestamp: new Date().toISOString()
});
}
if (!req.files || req.files.length === 0) {
return badRequest(res, 'No files uploaded');
}
const uploadedFiles = [];
for (const file of req.files) {
const fileId = generateFileId();
// Determine target folder
let targetFolder = 'general';
if (spaceId) {
targetFolder = `spaces/${spaceId}`;
} else if (projectId) {
targetFolder = `projects/${projectId}`;
}
// Create target directory
const targetDir = path.join(UPLOAD_BASE_PATH, targetFolder);
ensureDir(targetDir);
// Move file from temp to target
const targetPath = path.join(targetDir, file.filename);
fs.renameSync(file.path, targetPath);
const relativePath = `${targetFolder}/${file.filename}`;
// Bug fix: Use relative URL so it works regardless of domain (devcrm vs crm).
// Frontend resolves against current origin; backend can prepend BASE_URL when needed.
const fileUrl = `/uploads/${relativePath}`;
fileLogger.debug({ targetPath, fileUrl }, 'File saved');
// Save to database
await dbRun(`
INSERT INTO files (
id, name, original_name, mime_type, size, path, url,
storage_provider_id, space_id, project_id, table_id, row_id, column_id,
uploaded_by, description, visibility, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`, [
fileId,
file.filename,
file.originalname,
file.mimetype,
file.size,
targetPath,
fileUrl,
'local',
spaceId || null,
projectId || null,
tableId || null,
rowId || null,
columnId || null,
userId,
description || null,
visibility
]);
uploadedFiles.push({
id: fileId,
name: file.filename,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
url: fileUrl,
spaceId: spaceId || null,
projectId: projectId || null
});
}
fileLogger.info({ count: uploadedFiles.length, userId }, 'Files uploaded');
success(res, uploadedFiles.length === 1 ? uploadedFiles[0] : uploadedFiles);
} catch (err) {
// ADR-194 B2: the bare { err } this used to carry said nothing about who
// uploaded what — which is why the 500s of 2026-08-15 stayed undiagnosable.
fileLogger.error(
{
err,
userId: req.user?.id,
files: (req.files || []).map((f) => ({ name: f.originalname, type: f.mimetype, size: f.size }))
},
'Upload failed'
);
error(res, 'UPLOAD_FAILED', err.message, 500);
}
});
/**
* GET /api/v3/files
* List files with filters
* @swagger
* /api/v3/files:
* get:
* summary: List files
* tags: [Files]
* security:
* - bearerAuth: []
* parameters:
* - in: query
* name: spaceId
* schema:
* type: integer
* - in: query
* name: limit
* schema:
* type: integer
* responses:
* 200:
* description: List of files
*/
router.get('/files', async (req, res) => {
try {
const { spaceId, projectId, tableId, rowId, page = 1, limit = 50 } = req.query;
const offset = (Number(page) - 1) * Number(limit);
const userId = req.user?.id;
const userRole = req.user?.role;
// SEC-1: Verify user has access to space
if (spaceId) {
const access = await checkSpaceAccess(spaceId, userId, userRole);
if (!access.allowed) {
return error(res, 'ACCESS_DENIED', access.error, access.status);
}
}
let whereClause = '1=1';
const params = [];
if (spaceId) {
whereClause += ' AND space_id = ?';
params.push(spaceId);
}
if (projectId) {
whereClause += ' AND project_id = ?';
params.push(projectId);
}
if (tableId) {
whereClause += ' AND table_id = ?';
params.push(tableId);
}
if (rowId) {
whereClause += ' AND row_id = ?';
params.push(rowId);
}
const files = await dbAll(`
SELECT
f.*,
u.name as uploaded_by_name
FROM files f
LEFT JOIN users u ON f.uploaded_by = u.id
WHERE ${whereClause}
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?
`, [...params, Number(limit), offset]);
const countResult = await dbGet(`
SELECT COUNT(*) as total FROM files WHERE ${whereClause}
`, params);
success(res, {
files,
pagination: {
page: Number(page),
limit: Number(limit),
total: countResult?.total || 0,
pages: Math.ceil((countResult?.total || 0) / Number(limit))
}
});
} catch (err) {
fileLogger.error({ err }, 'GET /files error');
error(res, 'FILES_FETCH_FAILED', err.message, 500);
}
});
/**
* GET /api/v3/files/:fileId
* Get single file info
* @swagger
* /api/v3/files/{fileId}:
* get:
* summary: Get file by ID
* tags: [Files]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: fileId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: File details
* 404:
* description: File not found
*/
router.get('/files/:fileId', async (req, res) => {
try {
const { fileId } = req.params;
const userId = req.user?.id;
const userRole = req.user?.role;
const file = await dbGet(`
SELECT
f.*,
u.name as uploaded_by_name
FROM files f
LEFT JOIN users u ON f.uploaded_by = u.id
WHERE f.id = ?
`, [fileId]);
if (!file) {
return notFound(res, 'File not found');
}
// SEC-1: Verify user has access to file's space
if (file.space_id) {
const access = await checkSpaceAccess(file.space_id, userId, userRole);
if (!access.allowed) {
return error(res, 'ACCESS_DENIED', access.error, access.status);
}
}
success(res, file);
} catch (err) {
fileLogger.error({ err, fileId: req.params.fileId }, 'GET /files/:fileId error');
error(res, 'FILE_FETCH_FAILED', err.message, 500);
}
});
/**
* DELETE /api/v3/files/:fileId
* Delete a file
* @swagger
* /api/v3/files/{fileId}:
* delete:
* summary: Delete a file
* tags: [Files]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: fileId
* required: true
* schema:
* type: string
* responses:
* 200:
* description: File deleted
* 404:
* description: File not found
*/
router.delete('/files/:fileId', async (req, res) => {
try {
const { fileId } = req.params;
const userId = req.user?.id;
const userRole = req.user?.role;
const file = await dbGet('SELECT * FROM files WHERE id = ?', [fileId]);
if (!file) {
return notFound(res, 'File');
}
// SEC-1: Verify user has access to file's space
if (file.space_id) {
const access = await checkSpaceAccess(file.space_id, userId, userRole);
if (!access.allowed) {
return error(res, 'ACCESS_DENIED', access.error, access.status);
}
}
// Delete physical file
if (file.path && fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
// Delete from database
await dbRun('DELETE FROM files WHERE id = ?', [fileId]);
fileLogger.info({ fileId, originalName: file.original_name }, 'File deleted');
success(res, { message: 'File deleted successfully' });
} catch (err) {
fileLogger.error({ err, fileId: req.params.fileId }, 'DELETE /files/:fileId error');
error(res, 'FILE_DELETE_FAILED', err.message, 500);
}
});
/**
* GET /api/v3/storage-providers
* List all storage providers
*/
router.get('/storage-providers', async (req, res) => {
try {
const providers = await dbAll('SELECT * FROM storage_providers ORDER BY is_default DESC, name ASC');
// Parse config JSON
providers.forEach(p => {
if (p.config && typeof p.config === 'string') {
try {
p.config = JSON.parse(p.config);
} catch (e) {
p.config = {};
}
}
});
success(res, providers);
} catch (err) {
fileLogger.error({ err }, 'GET /storage-providers error');
error(res, 'PROVIDERS_FETCH_FAILED', err.message, 500);
}
});
/**
* POST /api/v3/storage-providers
* Add new storage provider (S3, Google Drive, etc.)
*/
router.post('/storage-providers', async (req, res) => {
try {
const { id, name, type, config, isDefault } = req.body;
if (!id || !name || !type) {
return badRequest(res, 'id, name, and type are required');
}
// If setting as default, unset other defaults
if (isDefault) {
await dbRun('UPDATE storage_providers SET is_default = 0');
}
await dbRun(`
INSERT INTO storage_providers (id, name, type, is_default, is_enabled, config, created_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, datetime('now'), datetime('now'))
`, [id, name, type, isDefault ? 1 : 0, JSON.stringify(config || {})]);
created(res, { id, name, type, message: 'Storage provider created successfully' });
} catch (err) {
fileLogger.error({ err }, 'POST /storage-providers error');
error(res, 'PROVIDER_CREATE_FAILED', err.message, 500);
}
});
/**
* PUT /api/v3/storage-providers/:providerId
* Update storage provider
*/
router.put('/storage-providers/:providerId', async (req, res) => {
try {
const { providerId } = req.params;
const { name, config, isDefault, isEnabled } = req.body;
const provider = await dbGet('SELECT * FROM storage_providers WHERE id = ?', [providerId]);
if (!provider) {
return notFound(res, 'Storage provider');
}
// If setting as default, unset other defaults
if (isDefault) {
await dbRun('UPDATE storage_providers SET is_default = 0');
}
await dbRun(`
UPDATE storage_providers
SET name = ?, config = ?, is_default = ?, is_enabled = ?, updated_at = datetime('now')
WHERE id = ?
`, [
name ?? provider.name,
JSON.stringify(config ?? JSON.parse(provider.config || '{}')),
isDefault !== undefined ? (isDefault ? 1 : 0) : provider.is_default,
isEnabled !== undefined ? (isEnabled ? 1 : 0) : provider.is_enabled,
providerId
]);
success(res, { message: 'Storage provider updated successfully' });
} catch (err) {
fileLogger.error({ err, providerId: req.params.providerId }, 'PUT /storage-providers error');
error(res, 'PROVIDER_UPDATE_FAILED', err.message, 500);
}
});
/**
* DELETE /api/v3/storage-providers/:providerId
* Delete storage provider (except local)
*/
router.delete('/storage-providers/:providerId', async (req, res) => {
try {
const { providerId } = req.params;
if (providerId === 'local') {
return badRequest(res, 'Cannot delete local storage provider');
}
// Check if any files use this provider
const filesCount = await dbGet(
'SELECT COUNT(*) as count FROM files WHERE storage_provider_id = ?',
[providerId]
);
if (filesCount?.count > 0) {
return badRequest(res, `Cannot delete: ${filesCount.count} files are using this storage provider`);
}
await dbRun('DELETE FROM storage_providers WHERE id = ?', [providerId]);
success(res, { message: 'Storage provider deleted successfully' });
} catch (err) {
fileLogger.error({ err, providerId: req.params.providerId }, 'DELETE /storage-providers error');
error(res, 'PROVIDER_DELETE_FAILED', err.message, 500);
}
});
// ─── Multer error handler ──────────────────────────────────────
// Multer limit violations (size / count / unexpected field) surface as MulterError.
// Without this handler they bubble to the global errorHandler which defaults to 500.
// We intercept them here and return 400 Bad Request instead.
// ADR-194 B2: MIME rejections no longer arrive here — fileFilter collects them and
// the upload handler answers 400 itself, so it can name every rejected file.
router.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
fileLogger.warn({ code: err.code, field: err.field, userId: req.user?.id }, 'Upload rejected by multer');
if (err.code === 'LIMIT_FILE_SIZE') {
return badRequest(res, 'File too large. Maximum size: 100 MB.');
}
if (err.code === 'LIMIT_FILE_COUNT') {
return badRequest(res, 'Too many files. Maximum: 10 files at once.');
}
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
return badRequest(res, 'Unexpected file field. Use "files".');
}
return badRequest(res, `Upload error: ${err.message}`);
}
// ADR-194 B2: anything else becomes a 500 in the global handler with no trace of
// the route that produced it. Leave a line before handing it on.
fileLogger.error({ err, path: req.path, userId: req.user?.id }, 'Files router middleware error');
next(err);
});
export default router;