Migrated to using Bun instead of Node and PNPM. This also brings in a Devcontainer which enables quick and easy development of the project. Additionally this adds connectivity to S3 (with a default Minio server pre-created) this enables Files to be uploaded against Mantises. There's also a new Internal Notes feature to store arbitrary text notes against a Mantis.
This commit is contained in:
parent
80ca48be70
commit
3b846b8c8e
23 changed files with 3210 additions and 6490 deletions
|
@ -1,13 +1,15 @@
|
|||
import express from 'express';
|
||||
import { PrismaClient } from '@prisma/client'; // Import Prisma Client
|
||||
import { v4 as uuidv4 } from 'uuid'; // Import uuid for unique filenames
|
||||
import { getMantisSettings, saveTicketToDatabase } from '../services/mantisDownloader.js';
|
||||
import axios from 'axios';
|
||||
import reader from '@kenjiuno/msgreader';
|
||||
import MsgReader from '@kenjiuno/msgreader';
|
||||
import { askGemini } from '../utils/gemini.js';
|
||||
import { usernameMap } from '../services/mantisSummarizer.js';
|
||||
const MsgReader = reader.default;
|
||||
import { getS3Client } from '../utils/s3.js';
|
||||
import { getUserById } from './auth.js';
|
||||
|
||||
import prisma from '../database.js';
|
||||
|
||||
const prisma = new PrismaClient(); // Instantiate Prisma Client
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to fetch distinct values
|
||||
|
@ -186,11 +188,18 @@ router.get('/:id', async(req, res) =>
|
|||
|
||||
try
|
||||
{
|
||||
const issue = await prisma.mantisIssue.findUnique({
|
||||
let issue = await prisma.mantisIssue.findUnique({ // Changed const to let
|
||||
where: { id: issueId },
|
||||
include: {
|
||||
comments: { // Include comments
|
||||
orderBy: { createdAt: 'asc' }, // Keep original order for comments unless preference changes it
|
||||
include: { attachments: true } // And include attachments for each comment
|
||||
},
|
||||
files: { // Include user-uploaded files
|
||||
orderBy: { uploadedAt: 'desc' }
|
||||
},
|
||||
notes: { // Include internal notes
|
||||
orderBy: { createdAt: 'desc' } // Show newest notes first
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -198,22 +207,50 @@ router.get('/:id', async(req, res) =>
|
|||
if (!issue)
|
||||
{
|
||||
//Try to download the issue from Mantis
|
||||
const data = await saveTicketToDatabase(issueId);
|
||||
let data;
|
||||
try
|
||||
{
|
||||
data = await saveTicketToDatabase(issueId);
|
||||
}
|
||||
catch (downloadError)
|
||||
{
|
||||
console.error(`Error downloading ticket ${issueId} from Mantis:`, downloadError.message);
|
||||
// Don't immediately return 404, maybe it exists locally but download failed
|
||||
}
|
||||
|
||||
|
||||
if (!data)
|
||||
{
|
||||
return res.status(404).json({ error: 'Mantis issue not found' });
|
||||
// Check if it exists locally even if download failed or wasn't attempted
|
||||
const localIssue = await prisma.mantisIssue.findUnique({ where: { id: issueId } });
|
||||
if (!localIssue)
|
||||
{
|
||||
return res.status(404).json({ error: 'Mantis issue not found locally or via download.' });
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the issue again from the database
|
||||
const issue = await prisma.mantisIssue.findUnique({
|
||||
// Fetch the issue again from the database (it might have been created by saveTicketToDatabase)
|
||||
issue = await prisma.mantisIssue.findUnique({ // Assign to issue
|
||||
where: { id: issueId },
|
||||
include: {
|
||||
comments: { // Include comments
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { attachments: true } // And include attachments for each comment
|
||||
},
|
||||
files: { // Include user-uploaded files
|
||||
orderBy: { uploadedAt: 'desc' }
|
||||
},
|
||||
notes: { // Include internal notes
|
||||
orderBy: { createdAt: 'desc' }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check again if issue is still null after attempting download/check
|
||||
if (!issue)
|
||||
{
|
||||
return res.status(404).json({ error: 'Mantis issue not found after attempting download/check.' });
|
||||
}
|
||||
}
|
||||
|
||||
res.json(issue);
|
||||
|
@ -225,6 +262,191 @@ router.get('/:id', async(req, res) =>
|
|||
}
|
||||
});
|
||||
|
||||
// REMOVE OLD UPLOAD ROUTE
|
||||
// POST /mantis/:id/files - Upload a file for a Mantis issue
|
||||
// router.post('/:id/files', upload.single('file'), async(req, res) => { ... });
|
||||
|
||||
|
||||
// NEW ROUTE: Generate Presigned URL for Upload
|
||||
router.post('/:id/files/presign', async(req, res) =>
|
||||
{
|
||||
const { id } = req.params;
|
||||
const issueId = parseInt(id, 10);
|
||||
const { filename, filetype } = req.body; // Expect filename and filetype from client
|
||||
|
||||
if (isNaN(issueId))
|
||||
{
|
||||
return res.status(400).json({ error: 'Invalid issue ID format' });
|
||||
}
|
||||
if (!filename || !filetype)
|
||||
{
|
||||
return res.status(400).json({ error: 'Missing filename or filetype in request body' });
|
||||
}
|
||||
|
||||
// Sanitize filename (optional, but recommended)
|
||||
const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const fileKey = `mantis/${issueId}/${uuidv4()}/${safeFilename}`; // Unique key
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Check if the Mantis issue exists
|
||||
const issueExists = await prisma.mantisIssue.findUnique({
|
||||
where: { id: issueId },
|
||||
select: { id: true } // Only select id for efficiency
|
||||
});
|
||||
|
||||
if (!issueExists)
|
||||
{
|
||||
return res.status(404).json({ error: 'Mantis issue not found' });
|
||||
}
|
||||
|
||||
const s3Client = await getS3Client();
|
||||
|
||||
// 2. Generate a presigned URL for the file upload
|
||||
const presignedUrl = await s3Client.file(fileKey).presign({
|
||||
method: 'PUT',
|
||||
type: filetype, // Use the provided filetype
|
||||
expiresIn: 3600 // URL expires in 1 hour
|
||||
});
|
||||
|
||||
// 3. Return the URL and the key to the client
|
||||
res.status(200).json({ presignedUrl, fileKey });
|
||||
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error generating presigned URL for Mantis issue ${issueId}:`, error);
|
||||
res.status(500).json({ error: `Failed to generate presigned URL. ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// NEW ROUTE: Confirm Upload and Save Metadata
|
||||
router.post('/:id/files/confirm', async(req, res) =>
|
||||
{
|
||||
const { id } = req.params;
|
||||
const issueId = parseInt(id, 10);
|
||||
|
||||
const user = await getUserById(req.session.loggedInUserId);
|
||||
const { fileKey, filename, mimeType, size /*, description */ } = req.body; // Expect details from client
|
||||
|
||||
if (isNaN(issueId))
|
||||
{
|
||||
return res.status(400).json({ error: 'Invalid issue ID format' });
|
||||
}
|
||||
if (!fileKey || !filename || !mimeType || size === undefined)
|
||||
{
|
||||
return res.status(400).json({ error: 'Missing required file details (fileKey, filename, mimeType, size)' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Optional: Verify file exists in S3 (requires HEAD request capability in S3 client or separate SDK call)
|
||||
// const s3Client = await getS3Client();
|
||||
// const s3file = s3Client.file(fileKey);
|
||||
// try {
|
||||
// await s3file.head(); // Or equivalent method to check existence/metadata
|
||||
// } catch (s3Error) {
|
||||
// console.error(`File not found in S3 or error checking: ${fileKey}`, s3Error);
|
||||
// return res.status(400).json({ error: 'File upload confirmation failed: File not found in S3.' });
|
||||
// }
|
||||
|
||||
// Save file metadata to database
|
||||
const mantisFile = await prisma.mantisFile.create({
|
||||
data: {
|
||||
mantisIssueId: issueId,
|
||||
filename: filename,
|
||||
fileKey: fileKey,
|
||||
mimeType: mimeType,
|
||||
size: parseInt(size, 10), // Ensure size is an integer
|
||||
uploadedBy: user?.username || 'unknown', // Get username from authenticated user
|
||||
// description: description || null,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(mantisFile); // Return the created DB record
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error confirming upload for Mantis issue ${issueId}, fileKey ${fileKey}:`, error);
|
||||
// If the error is due to duplicate fileKey or other constraint, handle appropriately
|
||||
if (error.code === 'P2002')
|
||||
{ // Example: Prisma unique constraint violation
|
||||
return res.status(409).json({ error: 'File metadata already exists or conflict.' });
|
||||
}
|
||||
res.status(500).json({ error: `Failed to confirm upload. ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// GET /mantis/:id/files - List files for a Mantis issue
|
||||
router.get('/:id/files', async(req, res) =>
|
||||
{
|
||||
const { id } = req.params;
|
||||
const issueId = parseInt(id, 10);
|
||||
|
||||
if (isNaN(issueId))
|
||||
{
|
||||
return res.status(400).json({ error: 'Invalid issue ID format' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const files = await prisma.mantisFile.findMany({
|
||||
where: { mantisIssueId: issueId },
|
||||
orderBy: { uploadedAt: 'desc' },
|
||||
});
|
||||
res.json(files);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error fetching files for Mantis issue ${issueId}:`, error);
|
||||
res.status(500).json({ error: 'Failed to fetch files' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /mantis/files/:fileId/download - Download a specific file
|
||||
router.get('/files/:fileId/download', async(req, res) =>
|
||||
{
|
||||
const { fileId } = req.params;
|
||||
const id = parseInt(fileId, 10);
|
||||
|
||||
if (isNaN(id))
|
||||
{
|
||||
return res.status(400).json({ error: 'Invalid file ID format' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const fileRecord = await prisma.mantisFile.findUnique({
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
if (!fileRecord)
|
||||
{
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
const s3Client = await getS3Client();
|
||||
|
||||
const presignedUrl = await s3Client.file(fileRecord.fileKey).presign({
|
||||
method: 'GET',
|
||||
expiresIn: 3600 // URL expires in 1 hour
|
||||
});
|
||||
|
||||
|
||||
//Redirect to the presigned URL
|
||||
res.redirect(307, presignedUrl);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error preparing file download for ID ${id}:`, error);
|
||||
if (!res.headersSent)
|
||||
{
|
||||
res.status(500).json({ error: `Failed to download file. ${error.message}` });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.get('/attachment/:ticketId/:attachmentId', async(req, res) =>
|
||||
{
|
||||
|
@ -247,7 +469,7 @@ router.get('/attachment/:ticketId/:attachmentId', async(req, res) =>
|
|||
}
|
||||
|
||||
const buffer = Buffer.from(attachment.content, 'base64');
|
||||
res.setHeader('Content-Type', attachment.content_type);
|
||||
res.setHeader('Content-Type', attachment.content_type); // Use content_type from Mantis API
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${attachment.filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
res.send(buffer);
|
||||
|
@ -255,7 +477,15 @@ router.get('/attachment/:ticketId/:attachmentId', async(req, res) =>
|
|||
catch (error)
|
||||
{
|
||||
console.error('Error fetching Mantis attachment:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch Mantis attachment' });
|
||||
// Check if the error is from Axios and has a response status
|
||||
if (error.response && error.response.status === 404)
|
||||
{
|
||||
res.status(404).json({ error: 'Attachment not found on Mantis server' });
|
||||
}
|
||||
else
|
||||
{
|
||||
res.status(500).json({ error: 'Failed to fetch Mantis attachment' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -281,17 +511,22 @@ router.get('/msg-extract/:ticketId/:attachmentId', async(req, res) =>
|
|||
|
||||
const buffer = Buffer.from(attachment.content, 'base64');
|
||||
|
||||
console.log(MsgReader);
|
||||
|
||||
const reader = new MsgReader(buffer);
|
||||
const msg = reader.getFileData();
|
||||
const msgReader = new MsgReader(buffer);
|
||||
const msg = msgReader.getFileData();
|
||||
|
||||
res.status(200).json(msg);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching Mantis attachment:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch Mantis attachment' });
|
||||
console.error('Error fetching or parsing Mantis MSG attachment:', error.message);
|
||||
if (error.response && error.response.status === 404)
|
||||
{
|
||||
res.status(404).json({ error: 'Attachment not found on Mantis server' });
|
||||
}
|
||||
else
|
||||
{
|
||||
res.status(500).json({ error: 'Failed to fetch or parse Mantis MSG attachment' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -300,6 +535,12 @@ router.get('/msg-extract/:ticketId/:attachmentId/:innerAttachmentId', async(req,
|
|||
const { url, headers } = await getMantisSettings();
|
||||
|
||||
const { ticketId, attachmentId, innerAttachmentId } = req.params;
|
||||
const innerIndex = parseInt(innerAttachmentId, 10); // Ensure index is a number
|
||||
|
||||
if (isNaN(innerIndex))
|
||||
{
|
||||
return res.status(400).json({ error: 'Invalid inner attachment ID format' });
|
||||
}
|
||||
|
||||
const attachmentUrl = `${url}/issues/${ticketId}/files/${attachmentId}`;
|
||||
|
||||
|
@ -320,25 +561,55 @@ router.get('/msg-extract/:ticketId/:attachmentId/:innerAttachmentId', async(req,
|
|||
const reader = new MsgReader(buffer);
|
||||
const msg = reader.getFileData();
|
||||
|
||||
// Find the inner attachment
|
||||
const innerAttachment = msg.attachments[innerAttachmentId];
|
||||
|
||||
if (!innerAttachment)
|
||||
// Find the inner attachment by index
|
||||
if (!msg || !msg.attachments || innerIndex < 0 || innerIndex >= msg.attachments.length)
|
||||
{
|
||||
return res.status(404).json({ error: 'Inner attachment not found' });
|
||||
return res.status(404).json({ error: 'Inner attachment not found at the specified index' });
|
||||
}
|
||||
const innerAttachment = msg.attachments[innerIndex];
|
||||
|
||||
|
||||
const attachmentData = reader.getAttachment(innerAttachment); // Or reader.getAttachment(innerIndex) if that's the API
|
||||
|
||||
// Assuming attachmentData.content is base64 encoded if it's binary
|
||||
// Check the structure of attachmentData - it might already be a buffer
|
||||
let innerBuffer;
|
||||
if (Buffer.isBuffer(attachmentData.content))
|
||||
{
|
||||
innerBuffer = attachmentData.content;
|
||||
}
|
||||
else if (typeof attachmentData.content === 'string')
|
||||
{
|
||||
// Attempt base64 decoding if it's a string, might need adjustment based on actual content
|
||||
innerBuffer = Buffer.from(attachmentData.content, 'base64');
|
||||
}
|
||||
else
|
||||
{
|
||||
console.error('Unexpected inner attachment content type:', typeof attachmentData.content);
|
||||
return res.status(500).json({ error: 'Could not process inner attachment content' });
|
||||
}
|
||||
|
||||
const attachmentData = reader.getAttachment(innerAttachment);
|
||||
// Determine Content-Type if possible, otherwise use a default
|
||||
// The msgreader library might provide a mime type, check innerAttachment properties
|
||||
const mimeType = innerAttachment.mimeType || 'application/octet-stream'; // Example fallback
|
||||
|
||||
const innerBuffer = Buffer.from(attachmentData.content, 'base64');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${innerAttachment.fileName}"`);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${innerAttachment.fileName}"`); // Use fileName from inner attachment
|
||||
res.setHeader('Content-Length', innerBuffer.length);
|
||||
|
||||
res.status(200).send(innerBuffer);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching Mantis attachment:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch Mantis attachment' });
|
||||
console.error('Error fetching or processing inner MSG attachment:', error.message);
|
||||
if (error.response && error.response.status === 404)
|
||||
{
|
||||
res.status(404).json({ error: 'Outer attachment not found on Mantis server' });
|
||||
}
|
||||
else
|
||||
{
|
||||
res.status(500).json({ error: 'Failed to fetch or process inner MSG attachment' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -356,6 +627,7 @@ router.get('/stats/issues', async(req, res) =>
|
|||
startDate.setHours(0, 0, 0, 0); // Start of the day
|
||||
|
||||
// Query for daily counts of issues created in the last 7 days
|
||||
// Ensure table and column names match Prisma schema (case-sensitive in raw queries sometimes)
|
||||
const dailyIssues = await prisma.$queryRaw`
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
|
@ -370,7 +642,14 @@ router.get('/stats/issues', async(req, res) =>
|
|||
date ASC
|
||||
`;
|
||||
|
||||
res.status(200).json(dailyIssues);
|
||||
// Convert count to number as BigInt might not serialize correctly
|
||||
const result = dailyIssues.map(row => ({
|
||||
date: row.date,
|
||||
count: Number(row.count) // Convert BigInt to Number
|
||||
}));
|
||||
|
||||
|
||||
res.status(200).json(result);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
|
@ -407,7 +686,13 @@ router.get('/stats/comments', async(req, res) =>
|
|||
date ASC
|
||||
`;
|
||||
|
||||
res.status(200).json(dailyComments);
|
||||
// Convert count to number as BigInt might not serialize correctly
|
||||
const result = dailyComments.map(row => ({
|
||||
date: row.date,
|
||||
count: Number(row.count) // Convert BigInt to Number
|
||||
}));
|
||||
|
||||
res.status(200).json(result);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
|
@ -419,13 +704,21 @@ router.get('/stats/comments', async(req, res) =>
|
|||
router.get('/summary/:ticketId', async(req, res) =>
|
||||
{
|
||||
const { ticketId } = req.params;
|
||||
const id = parseInt(ticketId, 10);
|
||||
|
||||
if (isNaN(id))
|
||||
{
|
||||
return res.status(400).json({ error: 'Invalid ticket ID format' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const ticket = await prisma.mantisIssue.findUnique({
|
||||
where: { id: parseInt(ticketId, 10) },
|
||||
where: { id: id },
|
||||
include: {
|
||||
comments: true,
|
||||
comments: {
|
||||
orderBy: { createdAt: 'asc' } // Ensure comments are ordered for summary
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -443,7 +736,7 @@ router.get('/summary/:ticketId', async(req, res) =>
|
|||
});
|
||||
|
||||
//Ask Gemini to summarize the ticket
|
||||
const summary = await askGemini(`Please summarize the following Mantis ticket in the form of a markdown list of bullet points formatted as "[Date] Point" (ensure a newline between each point, format the date as DD/MM/YYY and surround it with square brackets "[]"). Then after your summary, list any outstanding actions as a markdown list in the format "[Name] Action" (again surrounding the name with square brackets).
|
||||
const summary = await askGemini(`Please summarize the following Mantis ticket in the form of a markdown list of bullet points formatted as "[Date] Point" (ensure a newline between each point, format the date as DD/MM/YYYY and surround it with square brackets "[]"). Then after your summary, list any outstanding actions as a markdown list in the format "[Name] Action" (again surrounding the name with square brackets).
|
||||
Output a heading 6 "Summary:", a newline, the summary, then two newlines, a heading 6 "Actions:" then the actions. Do not wrap the output in a code block.\n\n### Ticket Data ###\n\n` + JSON.stringify(ticket, null, 2));
|
||||
res.status(200).json({ summary });
|
||||
}
|
||||
|
@ -454,4 +747,56 @@ router.get('/summary/:ticketId', async(req, res) =>
|
|||
}
|
||||
});
|
||||
|
||||
// NEW ROUTE: Add an internal note to a Mantis issue
|
||||
router.post('/:id/notes', async(req, res) =>
|
||||
{
|
||||
const { id } = req.params;
|
||||
const issueId = parseInt(id, 10);
|
||||
const { content } = req.body;
|
||||
const user = await getUserById(req.session.loggedInUserId); // Assumes user is logged in
|
||||
|
||||
if (isNaN(issueId))
|
||||
{
|
||||
return res.status(400).json({ error: 'Invalid issue ID format' });
|
||||
}
|
||||
if (!content)
|
||||
{
|
||||
return res.status(400).json({ error: 'Note content cannot be empty' });
|
||||
}
|
||||
if (!user)
|
||||
{
|
||||
return res.status(401).json({ error: 'User not authenticated' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Check if the Mantis issue exists
|
||||
const issueExists = await prisma.mantisIssue.findUnique({
|
||||
where: { id: issueId },
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
if (!issueExists)
|
||||
{
|
||||
return res.status(404).json({ error: 'Mantis issue not found' });
|
||||
}
|
||||
|
||||
// 2. Create the new note
|
||||
const newNote = await prisma.mantisNote.create({
|
||||
data: {
|
||||
mantisIssueId: issueId,
|
||||
content: content,
|
||||
createdBy: user.username, // Store the username of the creator
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(newNote); // Return the created note
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error adding note to Mantis issue ${issueId}:`, error);
|
||||
res.status(500).json({ error: `Failed to add note. ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
|
@ -36,10 +36,16 @@ dotenv.config();
|
|||
|
||||
const httpLogger = pinoHttp({ logger });
|
||||
|
||||
// Define host and port with defaults
|
||||
const HOST = process.env.HOST || '0.0.0.0'; // Listen on all interfaces by default
|
||||
const PORT = parseInt(process.env.BACKEND_PORT || '9101', 10);
|
||||
const FRONTEND_PORT = parseInt(process.env.FRONTEND_PORT || '9100', 10);
|
||||
|
||||
// Define Relying Party details (Update with your actual details)
|
||||
export const rpID = process.env.NODE_ENV === 'production' ? 'stylepoint.uk' : 'localhost';
|
||||
export const rpName = 'StylePoint';
|
||||
export const origin = process.env.NODE_ENV === 'production' ? `https://${rpID}` : `http://${rpID}:9000`;
|
||||
// Use the configured PORT for the origin URL
|
||||
export const origin = process.env.NODE_ENV === 'production' ? `https://${rpID}` : `http://${rpID}:${FRONTEND_PORT}`;
|
||||
|
||||
export const challengeStore = new Map();
|
||||
|
||||
|
@ -130,9 +136,10 @@ if (process.env.PROD)
|
|||
|
||||
app.use(express.static('public', { index: false }));
|
||||
|
||||
app.listen(8000, () =>
|
||||
app.listen(PORT, HOST, () =>
|
||||
{
|
||||
logger.info('Server is running on http://localhost:8000');
|
||||
// Use the configured HOST and PORT in the log message
|
||||
logger.info(`Server is running on http://${HOST}:${PORT}`);
|
||||
|
||||
setupMantisDownloader();
|
||||
});
|
166
src-server/utils/s3.js
Normal file
166
src-server/utils/s3.js
Normal file
|
@ -0,0 +1,166 @@
|
|||
import { S3Client } from 'bun';
|
||||
|
||||
import { getSetting } from './settings';
|
||||
|
||||
let s3Client = null;
|
||||
|
||||
export async function getS3Client()
|
||||
{
|
||||
if (s3Client)
|
||||
{
|
||||
return s3Client;
|
||||
}
|
||||
|
||||
const s3AccessKey = await getSetting('S3_ACCESS_KEY_ID');
|
||||
const s3SecretKey = await getSetting('S3_SECRET_ACCESS_KEY');
|
||||
const s3Endpoint = await getSetting('S3_ENDPOINT');
|
||||
const s3Bucket = await getSetting('S3_BUCKET_NAME');
|
||||
|
||||
if (s3AccessKey && s3SecretKey && s3Endpoint && s3Bucket)
|
||||
{
|
||||
s3Client = new S3Client({
|
||||
endpoint: s3Endpoint,
|
||||
accessKeyId: s3AccessKey,
|
||||
secretAccessKey: s3SecretKey,
|
||||
bucket: s3Bucket,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Error('S3 settings are not configured properly.');
|
||||
}
|
||||
return s3Client;
|
||||
}
|
||||
/* S3Client documentation
|
||||
|
||||
Working with S3 Files
|
||||
|
||||
The file method in S3Client returns a lazy reference to a file on S3.
|
||||
|
||||
// A lazy reference to a file on S3
|
||||
const s3file: S3File = client.file("123.json");
|
||||
|
||||
Like Bun.file(path), the S3Client's file method is synchronous. It does zero network requests until you call a method that depends on a network request.
|
||||
Reading files from S3
|
||||
|
||||
If you've used the fetch API, you're familiar with the Response and Blob APIs. S3File extends Blob. The same methods that work on Blob also work on S3File.
|
||||
|
||||
// Read an S3File as text
|
||||
const text = await s3file.text();
|
||||
|
||||
// Read an S3File as JSON
|
||||
const json = await s3file.json();
|
||||
|
||||
// Read an S3File as an ArrayBuffer
|
||||
const buffer = await s3file.arrayBuffer();
|
||||
|
||||
// Get only the first 1024 bytes
|
||||
const partial = await s3file.slice(0, 1024).text();
|
||||
|
||||
// Stream the file
|
||||
const stream = s3file.stream();
|
||||
for await (const chunk of stream) {
|
||||
console.log(chunk);
|
||||
}
|
||||
|
||||
Memory optimization
|
||||
|
||||
Methods like text(), json(), bytes(), or arrayBuffer() avoid duplicating the string or bytes in memory when possible.
|
||||
|
||||
If the text happens to be ASCII, Bun directly transfers the string to JavaScriptCore (the engine) without transcoding and without duplicating the string in memory. When you use .bytes() or .arrayBuffer(), it will also avoid duplicating the bytes in memory.
|
||||
|
||||
These helper methods not only simplify the API, they also make it faster.
|
||||
Writing & uploading files to S3
|
||||
|
||||
Writing to S3 is just as simple.
|
||||
|
||||
// Write a string (replacing the file)
|
||||
await s3file.write("Hello World!");
|
||||
|
||||
// Write a Buffer (replacing the file)
|
||||
await s3file.write(Buffer.from("Hello World!"));
|
||||
|
||||
// Write a Response (replacing the file)
|
||||
await s3file.write(new Response("Hello World!"));
|
||||
|
||||
// Write with content type
|
||||
await s3file.write(JSON.stringify({ name: "John", age: 30 }), {
|
||||
type: "application/json",
|
||||
});
|
||||
|
||||
// Write using a writer (streaming)
|
||||
const writer = s3file.writer({ type: "application/json" });
|
||||
writer.write("Hello");
|
||||
writer.write(" World!");
|
||||
await writer.end();
|
||||
|
||||
// Write using Bun.write
|
||||
await Bun.write(s3file, "Hello World!");
|
||||
|
||||
Working with large files (streams)
|
||||
|
||||
Bun automatically handles multipart uploads for large files and provides streaming capabilities. The same API that works for local files also works for S3 files.
|
||||
|
||||
// Write a large file
|
||||
const bigFile = Buffer.alloc(10 * 1024 * 1024); // 10MB
|
||||
const writer = s3file.writer({
|
||||
// Automatically retry on network errors up to 3 times
|
||||
retry: 3,
|
||||
|
||||
// Queue up to 10 requests at a time
|
||||
queueSize: 10,
|
||||
|
||||
// Upload in 5 MB chunks
|
||||
partSize: 5 * 1024 * 1024,
|
||||
});
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await writer.write(bigFile);
|
||||
}
|
||||
await writer.end();
|
||||
|
||||
Presigning URLs
|
||||
|
||||
When your production service needs to let users upload files to your server, it's often more reliable for the user to upload directly to S3 instead of your server acting as an intermediary.
|
||||
|
||||
To facilitate this, you can presign URLs for S3 files. This generates a URL with a signature that allows a user to securely upload that specific file to S3, without exposing your credentials or granting them unnecessary access to your bucket.
|
||||
|
||||
The default behaviour is to generate a GET URL that expires in 24 hours. Bun attempts to infer the content type from the file extension. If inference is not possible, it will default to application/octet-stream.
|
||||
|
||||
import { s3 } from "bun";
|
||||
|
||||
// Generate a presigned URL that expires in 24 hours (default)
|
||||
const download = s3.presign("my-file.txt"); // GET, text/plain, expires in 24 hours
|
||||
|
||||
const upload = s3.presign("my-file", {
|
||||
expiresIn: 3600, // 1 hour
|
||||
method: "PUT",
|
||||
type: "application/json", // No extension for inferring, so we can specify the content type to be JSON
|
||||
});
|
||||
|
||||
// You can call .presign() if on a file reference, but avoid doing so
|
||||
// unless you already have a reference (to avoid memory usage).
|
||||
const myFile = s3.file("my-file.txt");
|
||||
const presignedFile = myFile.presign({
|
||||
expiresIn: 3600, // 1 hour
|
||||
});
|
||||
|
||||
Setting ACLs
|
||||
|
||||
To set an ACL (access control list) on a presigned URL, pass the acl option:
|
||||
|
||||
const url = s3file.presign({
|
||||
acl: "public-read",
|
||||
expiresIn: 3600,
|
||||
});
|
||||
|
||||
You can pass any of the following ACLs:
|
||||
ACL Explanation
|
||||
"public-read" The object is readable by the public.
|
||||
"private" The object is readable only by the bucket owner.
|
||||
"public-read-write" The object is readable and writable by the public.
|
||||
"authenticated-read" The object is readable by the bucket owner and authenticated users.
|
||||
"aws-exec-read" The object is readable by the AWS account that made the request.
|
||||
"bucket-owner-read" The object is readable by the bucket owner.
|
||||
"bucket-owner-full-control" The object is readable and writable by the bucket owner.
|
||||
"log-delivery-write" The object is writable by AWS services used for log delivery.
|
||||
*/
|
|
@ -7,8 +7,6 @@ export async function getSetting(key)
|
|||
select: { value: true }
|
||||
});
|
||||
|
||||
console.log(`getSetting(${key})`, setting);
|
||||
|
||||
return setting?.value ? JSON.parse(setting.value) : null;
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue