mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
chore: relocate backend into apps/api ahead of monorepo merge
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: task_download.controller.js (client)
|
||||
* Type of Program: Controller
|
||||
* Description: Proxies file downloads for task completion attachments through
|
||||
* the backend, so the raw Garage/S3 URL is never exposed to the
|
||||
* browser. Sets Content-Disposition: attachment with the original
|
||||
* filename.
|
||||
*
|
||||
* storage_key is DERIVED from file_url at request time (no schema
|
||||
* change needed) by stripping the known S3_PUBLIC_URL + bucket
|
||||
* prefix, since both are constants defined in s3.service.js / .env.
|
||||
*
|
||||
* Route: GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/:completionId/files/:fileId/download
|
||||
*
|
||||
* Access: only the completion's owner (req.user.user_id === completion.user_id)
|
||||
* can download — admin downloads go through a separate admin route.
|
||||
*
|
||||
* Author: Kenneth Obsequio (@lash0000)
|
||||
* Date Created: Jun. 15, 2026
|
||||
***********************************************************************************************************************************************************************/
|
||||
const { Task, TaskList, TaskListGroup } = require('../../models/task/task.mdl');
|
||||
const { TaskCompletion, TaskCompletionFile } = require('../../models/task/task_completion.mdl');
|
||||
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
|
||||
const { getObjectStream } = require('../../services/s3.service');
|
||||
const R = require('../../utils/response.util');
|
||||
|
||||
// ─── Helper: verify user is a member of the group ─────────────────────────────
|
||||
const isMember = async (userId, groupId) => {
|
||||
const membership = await mdl_UserGroupMembers.findOne({
|
||||
where: { user_id: userId, group_id: groupId, deletedAt: null },
|
||||
});
|
||||
return !!membership;
|
||||
};
|
||||
|
||||
// ─── Helper: derive S3 storage_key from a public file_url ─────────────────────
|
||||
// Strips "{S3_PUBLIC_URL}/{S3_BUCKET}/" prefix, leaving e.g. "images/uuid.jpg"
|
||||
const deriveStorageKey = (fileUrl) => {
|
||||
const publicUrl = (process.env.S3_PUBLIC_URL || '').replace(/\/$/, '');
|
||||
const bucket = process.env.S3_BUCKET;
|
||||
const prefix = `${publicUrl}/${bucket}/`;
|
||||
|
||||
if (fileUrl && fileUrl.startsWith(prefix)) {
|
||||
return fileUrl.slice(prefix.length);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── STREAM FILE (inline preview — no Content-Disposition: attachment) ────────
|
||||
// =============================================================================
|
||||
//
|
||||
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions/:completionId/files/:fileId/stream
|
||||
//
|
||||
// Used by FilePreview.jsx for <img>/<video>/<audio>/<iframe> src — proxies the
|
||||
// object inline so the raw Garage/S3 URL never appears, but does NOT force
|
||||
// download (no Content-Disposition: attachment).
|
||||
|
||||
exports.streamCompletionFile = async (req, res) => {
|
||||
try {
|
||||
const { groupId, taskListId, taskId, completionId, fileId } = req.params;
|
||||
|
||||
const member = await isMember(req.user.user_id, groupId);
|
||||
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
|
||||
|
||||
const task = await Task.findOne({
|
||||
where: { task_id: taskId, task_list_id: taskListId },
|
||||
include: [{
|
||||
model: TaskList,
|
||||
as: 'taskList',
|
||||
required: true,
|
||||
include: [{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
where: { group_id: groupId },
|
||||
required: true,
|
||||
attributes: [],
|
||||
through: { model: TaskListGroup, attributes: [] },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
|
||||
|
||||
const completion = await TaskCompletion.findOne({
|
||||
where: { completion_id: completionId, task_id: taskId, user_id: req.user.user_id },
|
||||
});
|
||||
if (!completion) return R.error(res, 'Completion not found.', 404);
|
||||
|
||||
const file = await TaskCompletionFile.findOne({
|
||||
where: { file_id: fileId, completion_id: completionId },
|
||||
});
|
||||
if (!file) return R.error(res, 'File not found.', 404);
|
||||
|
||||
const storageKey = deriveStorageKey(file.file_url);
|
||||
if (!storageKey) {
|
||||
return R.error(res, 'This file cannot be previewed (unrecognized storage URL).', 422);
|
||||
}
|
||||
|
||||
const { stream, contentType, contentLength } = await getObjectStream(storageKey);
|
||||
|
||||
res.setHeader('Content-Type', contentType || file.mime_type || 'application/octet-stream');
|
||||
if (contentLength) res.setHeader('Content-Length', contentLength);
|
||||
// No Content-Disposition — browser renders inline based on Content-Type
|
||||
|
||||
stream.pipe(res);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][STREAM COMPLETION FILE]', err);
|
||||
return R.error(res, 'Could not load file.', 500);
|
||||
}
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ── DOWNLOAD FILE ──────────────────────────────────────────────────────────────
|
||||
// =============================================================================
|
||||
|
||||
exports.downloadCompletionFile = async (req, res) => {
|
||||
try {
|
||||
const { groupId, taskListId, taskId, completionId, fileId } = req.params;
|
||||
|
||||
// ── Validate member ───────────────────────────────────────────────────
|
||||
const member = await isMember(req.user.user_id, groupId);
|
||||
if (!member) return R.error(res, 'Group not found or you are not a member.', 403);
|
||||
|
||||
// ── Validate task belongs to task list + group ────────────────────────
|
||||
const task = await Task.findOne({
|
||||
where: { task_id: taskId, task_list_id: taskListId },
|
||||
include: [{
|
||||
model: TaskList,
|
||||
as: 'taskList',
|
||||
required: true,
|
||||
include: [{
|
||||
model: mdl_UserGroups,
|
||||
as: 'groups',
|
||||
where: { group_id: groupId },
|
||||
required: true,
|
||||
attributes: [],
|
||||
through: { model: TaskListGroup, attributes: [] },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
if (!task) return R.error(res, 'Task not found or not accessible.', 404);
|
||||
|
||||
// ── Validate completion belongs to this user + task ───────────────────
|
||||
const completion = await TaskCompletion.findOne({
|
||||
where: { completion_id: completionId, task_id: taskId, user_id: req.user.user_id },
|
||||
});
|
||||
if (!completion) return R.error(res, 'Completion not found.', 404);
|
||||
|
||||
// ── Validate file belongs to completion ───────────────────────────────
|
||||
const file = await TaskCompletionFile.findOne({
|
||||
where: { file_id: fileId, completion_id: completionId },
|
||||
});
|
||||
if (!file) return R.error(res, 'File not found.', 404);
|
||||
|
||||
// ── Derive storage_key from file_url ──────────────────────────────────
|
||||
const storageKey = deriveStorageKey(file.file_url);
|
||||
if (!storageKey) {
|
||||
return R.error(res, 'This file cannot be downloaded (unrecognized storage URL).', 422);
|
||||
}
|
||||
|
||||
// ── Stream from S3/Garage ──────────────────────────────────────────────
|
||||
const { stream, contentType, contentLength } = await getObjectStream(storageKey);
|
||||
|
||||
res.setHeader('Content-Type', contentType || file.mime_type || 'application/octet-stream');
|
||||
if (contentLength) res.setHeader('Content-Length', contentLength);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(file.file_name)}"`);
|
||||
|
||||
stream.pipe(res);
|
||||
} catch (err) {
|
||||
console.error('[CLIENT][DOWNLOAD COMPLETION FILE]', err);
|
||||
return R.error(res, 'Could not download file.', 500);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user