This commit is contained in:
chamikaJ
2025-04-17 18:28:54 +05:30
parent f583291d8a
commit 8825b0410a
2837 changed files with 241385 additions and 127578 deletions

View File

@@ -94,6 +94,12 @@ export const PriorityColorCodes: { [x: number]: string; } = {
2: "#f37070"
};
export const PriorityColorCodesDark: { [x: number]: string; } = {
0: "#46D980",
1: "#FFC227",
2: "#FF4141"
};
export const TASK_STATUS_TODO_COLOR = "#a9a9a9";
export const TASK_STATUS_DOING_COLOR = "#70a6f3";
export const TASK_STATUS_DONE_COLOR = "#75c997";
@@ -110,10 +116,44 @@ export const TASK_DUE_NO_DUE_COLOR = "#a9a9a9";
export const DEFAULT_PAGE_SIZE = 20;
// S3 Credentials
export const REGION = process.env.AWS_REGION || "us-east-1";
export const BUCKET = process.env.AWS_BUCKET || "worklenz-bucket";
export const S3_URL = process.env.S3_URL || "http://minio:9000/worklenz-bucket";
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "minioadmin";
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "minioadmin";
// Azure Blob Storage Credentials
export const STORAGE_PROVIDER = process.env.STORAGE_PROVIDER || "s3";
export const AZURE_STORAGE_ACCOUNT_NAME = process.env.AZURE_STORAGE_ACCOUNT_NAME;
export const AZURE_STORAGE_CONTAINER = process.env.AZURE_STORAGE_CONTAINER;
export const AZURE_STORAGE_ACCOUNT_KEY = process.env.AZURE_STORAGE_ACCOUNT_KEY;
export const AZURE_STORAGE_URL = process.env.AZURE_STORAGE_URL;
export function getStorageUrl() {
if (STORAGE_PROVIDER === "azure") {
if (!AZURE_STORAGE_URL) {
console.warn("AZURE_STORAGE_URL is not defined, falling back to S3_URL");
return S3_URL;
}
// Return just the base Azure Blob Storage URL
// AZURE_STORAGE_URL should be in the format: https://storageaccountname.blob.core.windows.net
return `${AZURE_STORAGE_URL}/${AZURE_STORAGE_CONTAINER}`;
}
return S3_URL;
}
export const TASK_STATUS_COLOR_ALPHA = "69";
export const TASK_PRIORITY_COLOR_ALPHA = "69";
export const TEAM_MEMBER_TREE_MAP_COLOR_ALPHA = "40";
// LICENSING SERVER URLS
export const LOCAL_URL = "http://localhost:3001";
export const UAT_SERVER_URL = "https://uat.admin.worklenz.com";
export const DEV_SERVER_URL = "https://dev.admin.worklenz.com";
export const PRODUCTION_SERVER_URL = "https://admin.worklenz.com";
// *Sync with the client
export const PASSWORD_POLICY = "Minimum of 8 characters, with upper and lowercase and a number and a symbol.";

View File

@@ -1,13 +1,33 @@
// csp.ts
const policies = {
"default-src": ["'self'"],
"script-src": [
"'self'",
"data:",
"'unsafe-inline'",
"'unsafe-eval'", // Required for React development tools
"https://*.googletagmanager.com",
"https://*.clarity.ms",
"https://js-na1.hs-scripts.com",
"https://js.hs-analytics.net",
"https://js.usemessages.com",
"https://*.tiny.cloud",
"https://js.hs-banner.com",
"https://js.hscollectedforms.net",
"https://cdn.paddle.com",
"https://sandbox-cdn.paddle.com",
"https://*.hubspot.com",
"https://connect.facebook.net",
"https://js.hs-scripts.com",
"https://www.google.com",
"https://www.gstatic.com",
"https://www.gstatic.com/recaptcha/",
"https://www.google.com/recaptcha/",
"localhost:3000", // React development server
"localhost:*" // For webpack-dev-server
],
"media-src": [
"'self'",
"https://s3.us-west-2.amazonaws.com"
],
"style-src": [
@@ -17,40 +37,116 @@ const policies = {
"https://cdnjs.cloudflare.com",
"https://fonts.googleapis.com",
"https://*.tiny.cloud",
"https://cdn.paddle.com",
"https://sandbox-cdn.paddle.com"
],
"font-src": [
"'self'",
"data:",
"https://fonts.gstatic.com",
"https://cdnjs.cloudflare.com",
"https://cdn.paddle.com",
"https://sandbox-cdn.paddle.com"
],
"worker-src": [
"'self'",
"blob:" // For React web workers
],
"worker-src": ["'self'"],
"connect-src": [
"'self'",
"data",
"data:",
"ws:", // For WebSocket connections
"wss:", // For secure WebSocket connections
"https://react.worklenz.com",
"https://v2.worklenz.com",
"https://dev.worklenz.com",
"https://js.hs-analytics.net",
"https://js.usemessages.com",
"https://js.hs-banner.com",
"https://js-na1.hs-scripts.com",
"https://*.googletagmanager.com",
"https://cdnjs.cloudflare.com",
"https://fonts.googleapis.com",
"https://fonts.gstatic.com",
"https://www.google-analytics.com",
"https://*.clarity.ms",
"https://*.hubspot.com",
"https://worklenz.s3.amazonaws.com",
"https://s3.us-west-2.amazonaws.com",
"https://s3.scriptcdn.net",
"https://*.mixpanel.com",
"https://*.tiny.cloud",
"https://*.tinymce.com",
"https://js.hscollectedforms.net",
"https://forms.hsforms.com",
"https://api-js.mixpanel.com",
"https://forms.hscollectedforms.net",
"https://cdn.paddle.com",
"https://sandbox-cdn.paddle.com",
"wss://uat.app.worklenz.com",
"wss://app.worklenz.com",
"https://*.hsforms.com",
"https://www.facebook.com",
"https://js.hs-scripts.com",
"https://connect.facebook.net",
"https://www.google.com",
"https://www.gstatic.com",
"localhost:*" // For development API calls
],
"img-src": [
"'self'",
"data:",
"blob:", // For React image processing
"https://worklenz.s3.amazonaws.com",
"https://s3.us-west-2.amazonaws.com",
"https://track.hubspot.com",
"https://forms.hsforms.com",
"https://*.tinymce.com",
"https://cdn.paddle.com",
"https://sandbox-cdn.paddle.com",
"https://*.hsforms.com",
"https://*.clarity.ms",
"https://www.facebook.com"
],
"frame-src": ["https://docs.google.com"],
"frame-ancestors": ["'none'"],
"frame-src": [
"'self'",
"https://app.hubspot.com",
"https://sandbox-buy.paddle.com",
"https://buy.paddle.com",
"https://docs.google.com",
"https://www.google.com",
"https://www.gstatic.com/recaptcha/",
"https://www.google.com/recaptcha/"
],
"frame-ancestors": ["'self'", "https://www.google.com"],
"object-src": ["'none'"],
"report-to": [`https://${process.env.HOSTNAME}/-/csp`]
};
const policyString = Object.entries(policies)
// Helper function to conditionally add development-specific policies
const addDevPolicies = (currentPolicies: typeof policies) => {
if (process.env.NODE_ENV !== "production") {
return {
...currentPolicies,
"script-src": [
...(currentPolicies["script-src"] || []),
"'unsafe-eval'", // Required for React development tools
"localhost:*"
],
"connect-src": [
...(currentPolicies["connect-src"] || []),
"ws://localhost:*", // For webpack-dev-server HMR
"http://localhost:*" // For local development
]
};
}
return currentPolicies;
};
const finalPolicies = addDevPolicies(policies);
const policyString = Object.entries(finalPolicies)
.map(([key, value]) => `${key} ${value.join(" ")}`)
.join("; ");
export const CSP_POLICIES = policyString;
export const CSP_POLICIES = policyString;

View File

@@ -4,14 +4,14 @@ import {sendEmail} from "./email";
import {sanitize} from "./utils";
import FileConstants from "./file-constants";
const HOSTNAME = process.env.HOSTNAME || "worklenz.com";
const FRONTEND_URL = process.env.FRONTEND_URL || "worklenz.com";
export function sendWelcomeEmail(email: string, name: string) {
let content = FileConstants.getEmailTemplate(IEmailTemplateType.Welcome) as string;
if (!content) return;
content = content.replace("[VAR_USER_NAME]", sanitize(name));
content = content.replace("[VAR_HOSTNAME]", sanitize(HOSTNAME));
content = content.replace("[VAR_HOSTNAME]", sanitize(FRONTEND_URL));
sendEmail({
to: [email],
@@ -38,7 +38,7 @@ export function sendJoinTeamInvitation(myName: string, teamName: string, teamId:
content = content.replace("[VAR_USER_NAME]", sanitize(userName));
content = content.replace("[VAR_TEAM_NAME]", sanitize(teamName));
content = content.replace("[VAR_HOSTNAME]", sanitize(HOSTNAME));
content = content.replace("[VAR_HOSTNAME]", sanitize(FRONTEND_URL));
content = content.replace("[VAR_TEAM_ID]", sanitize(teamId));
content = content.replace("[VAR_USER_ID]", sanitize(userId));
content = content.replace("[PROJECT_ID]", projectId ? sanitize(projectId as string) : "");
@@ -58,7 +58,7 @@ export function sendRegisterAndJoinTeamInvitation(myName: string, userName: stri
content = content.replace("[VAR_USER_ID]", sanitize(userId));
content = content.replace("[VAR_USER_NAME]", sanitize(userName));
content = content.replace("[VAR_TEAM_NAME]", sanitize(teamName));
content = content.replace("[VAR_HOSTNAME]", sanitize(HOSTNAME));
content = content.replace("[VAR_HOSTNAME]", sanitize(FRONTEND_URL));
content = content.replace("[VAR_TEAM_ID]", sanitize(teamId));
content = content.replace("[PROJECT_ID]", projectId ? sanitize(projectId as string) : "");
@@ -73,7 +73,7 @@ export function sendResetEmail(toEmail: string, user_id: string, hash: string) {
let content = FileConstants.getEmailTemplate(IEmailTemplateType.ResetPassword) as string;
if (!content) return;
content = content.replace("[VAR_HOSTNAME]", sanitize(HOSTNAME));
content = content.replace("[VAR_HOSTNAME]", sanitize(FRONTEND_URL));
content = content.replace("[VAR_USER_ID]", sanitize(user_id));
content = content.replace("[VAR_HASH]", hash);

View File

@@ -79,7 +79,7 @@ export async function sendEmail(email: IEmail): Promise<string | null> {
}
}
},
Source: process.env.SOURCE_EMAIL // Ex: Worklenz <noreply@worklenz.com>
Source: "Worklenz <noreply@worklenz.com>"
});
const res = await sesClient.send(command);

View File

@@ -0,0 +1,150 @@
import axios from "axios";
import jwt, {Secret} from "jsonwebtoken";
import {isLocalServer, isTestServer, log_error} from "./utils";
import {LOCAL_URL, PRODUCTION_SERVER_URL, UAT_SERVER_URL} from "./constants";
const local = isLocalServer() ? LOCAL_URL : PRODUCTION_SERVER_URL;
const serverUrl = isTestServer() ? UAT_SERVER_URL : local;
const jwtSecret: Secret = process.env.JWT_SECRET ?? "";
export async function generatePayLinkRequest(teamMemberData: any, plan: string, owner_id = "", user_id = "") {
try {
const token = jwt.sign({serverId: "01"}, jwtSecret, {expiresIn: "1h"});
const res = await axios.post(
`${serverUrl}/paddle-secure/generate-pay-link`,
{ plan, quantity: teamMemberData.user_count, customer_email: teamMemberData.email, owner_id, user_id},
{headers: {Authorization: `Bearer ${token}`}});
return res.data;
} catch (error: any) {
if (error?.isAxiosError) {
log_error(error?.response?.data || error);
} else {
log_error(error);
}
}
}
/**
* update the users of the current subscription plan for the organization
* @param subscription_id
* @param quantity
* @returns whether to continue adding the user to the organization
*/
export async function updateUsers(subscription_id: string, quantity: number) {
try {
const token = jwt.sign({serverId: "01"}, jwtSecret, {expiresIn: "1h"});
const res = await axios.post(
`${serverUrl}/paddle-secure/update-subscription-quantity`,
{quantity, subscription_id},
{headers: {Authorization: `Bearer ${token}`}});
return res.data;
} catch (error: any) {
if (error?.isAxiosError) {
log_error(error?.response?.data || error);
} else {
log_error(error);
}
}
}
/**
* update the users of the current subscription plan for the organization
* @param subscription_id
* @param quantity
* @returns whether to continue adding the user to the organization
*/
export async function addModifier(subscription_id: string) {
try {
const token = jwt.sign({serverId: "01"}, jwtSecret, {expiresIn: "1h"});
const res = await axios.post(
`${serverUrl}/paddle-secure/purchase-storage`,
{subscription_id},
{headers: {Authorization: `Bearer ${token}`}});
return res.data;
} catch (error: any) {
if (error?.isAxiosError) {
log_error(error?.response?.data || error);
} else {
log_error(error);
}
}
}
/**
* update the users of the current subscription plan for the organization
* @param subscription_id
* @param quantity
* @returns whether to continue adding the user to the organization
*/
export async function changePlan(plan_id: string, subscription_id: string) {
try {
const token = jwt.sign({serverId: "01"}, jwtSecret, {expiresIn: "1h"});
const res = await axios.post(
`${serverUrl}/paddle-secure/change-plan`,
{plan_id, subscription_id},
{headers: {Authorization: `Bearer ${token}`}});
return res.data;
} catch (error: any) {
if (error?.isAxiosError) {
log_error(error?.response?.data || error);
} else {
log_error(error);
}
}
}
/**
* update the users of the current subscription plan for the organization
* @param subscription_id
* @returns
*/
export async function cancelSubscription(subscription_id: string, user_id: string) {
try {
const token = jwt.sign({serverId: "01"}, jwtSecret, {expiresIn: "1h"});
const res = await axios.post(
`${serverUrl}/paddle-secure/cancel-subscription`,
{subscription_id, user_id},
{headers: {Authorization: `Bearer ${token}`}});
return res.data;
} catch (error: any) {
if (error?.isAxiosError) {
log_error(error?.response?.data || error);
} else {
log_error(error);
}
}
}
/**
* update the users of the current subscription plan for the organization
* @param subscription_id
* @returns
*/
export async function pauseOrResumeSubscription(subscription_id: string, user_id: string, pause: boolean) {
try {
const token = jwt.sign({serverId: "01"}, jwtSecret, {expiresIn: "1h"});
const res = await axios.post(
`${serverUrl}/paddle-secure/pause-subscription`,
{subscription_id, user_id, pause},
{headers: {Authorization: `Bearer ${token}`}});
return res.data;
} catch (error: any) {
if (error?.isAxiosError) {
log_error(error?.response?.data || error);
} else {
log_error(error);
}
}
}

View File

@@ -0,0 +1,104 @@
import db from "../config/db";
import { log_error } from "./utils";
export async function getTeamMemberCount(userId: string) {
if (!userId) return;
const q = `SELECT (COUNT(*)::INT) AS user_count,
(SELECT SUM(team_members_limit)::INT FROM licensing_coupon_codes WHERE redeemed_by = $1) AS free_count,
(SELECT email FROM users WHERE id = $1) AS email
FROM (SELECT DISTINCT email
FROM team_member_info_view tmiv
WHERE tmiv.team_id IN
(SELECT id
FROM teams
WHERE teams.user_id = $1)) AS total`;
const result = await db.query(q, [userId]);
const [data] = result.rows;
data.user_count = data.user_count - data.free_count;
return data;
}
export async function getActiveTeamMemberCount(userId: string) {
if (!userId) return;
const q = `SELECT (COUNT(*)::INT) AS user_count,
(SELECT SUM(team_members_limit)::INT FROM licensing_coupon_codes WHERE redeemed_by = $1) AS free_count,
(SELECT email FROM users WHERE id = $1) AS email
FROM (
SELECT DISTINCT tmiv.email FROM team_member_info_view tmiv
JOIN teams t ON tmiv.team_id = t.id
JOIN team_members tm ON tmiv.team_member_id = tm.id
WHERE t.user_id = $1 AND tm.active is true
) AS total;`;
const result = await db.query(q, [userId]);
const [data] = result.rows;
data.user_count = data.user_count - data.free_count;
return data;
}
export async function checkTeamSubscriptionStatus(team_id: string) {
try {
const q = `SELECT trial_expire_date,
subscription_status,
subscription_id,
quantity::INT,
(SELECT key FROM sys_license_types WHERE id = ud.license_type_id) AS subscription_type,
(SELECT EXISTS(SELECT id FROM licensing_custom_subs lcs WHERE lcs.user_id = ud.user_id)) AS is_custom,
(SELECT EXISTS(SELECT id FROM licensing_credit_subs lcs WHERE lcs.user_id = ud.user_id)) AS is_credit,
(SELECT EXISTS(SELECT id FROM licensing_coupon_codes WHERE redeemed_by = ud.user_id)) AS is_ltd,
(SELECT SUM(team_members_limit) FROM licensing_coupon_codes WHERE redeemed_by = ud.user_id) AS ltd_users,
(SELECT COUNT(DISTINCT email)
FROM team_member_info_view tmiv
WHERE tmiv.team_id IN
(SELECT id
FROM teams
WHERE teams.user_id = ud.user_id)) AS current_count
FROM organizations ud
LEFT JOIN licensing_user_subscriptions lus ON lus.user_id = ud.user_id
WHERE ud.user_id = (SELECT user_id FROM teams WHERE id = $1);`;
const result = await db.query(q, [team_id]);
const [data] = result.rows;
return data;
} catch (error) {
log_error(error);
return;
}
}
export async function getFreePlanSettings() {
const q = `
SELECT projects_limit, team_member_limit, free_tier_storage
FROM licensing_settings;`;
const result = await db.query(q);
const [data] = result.rows;
return data;
}
export async function getOwnerIdByTeam(teamId: string) {
const owner_id_q = `SELECT user_id FROM teams WHERE id = $1;`;
const result = await db.query(owner_id_q, [teamId]);
const [data] = result.rows;
return data?.user_id;
}
export async function getCurrentProjectsCount(owner_id: string) {
const projects_counts_q = `SELECT COUNT(*)
FROM projects
WHERE team_id IN (SELECT id FROM teams WHERE owner_id = $1);`;
const result = await db.query(projects_counts_q, [owner_id]);
const [data] = result.rows;
return data?.count;
}
export async function getUsedStorage(owner_id: string) {
const storage_q = `SELECT (COALESCE(SUM(size), 0)) AS used_storage
FROM task_attachments
WHERE team_id IN (SELECT id FROM teams WHERE user_id = $1);`;
const result = await db.query(storage_q, [owner_id]);
const [data] = result.rows;
return data?.used_storage;
}

View File

@@ -9,20 +9,16 @@ import {
S3Client
} from "@aws-sdk/client-s3";
import {isProduction, isTestServer, log_error} from "./utils";
import {BUCKET, REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_URL} from "./constants";
import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
import mime from "mime";
const {BUCKET, REGION, S3_URL, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY} = process.env;
if (!S3_ACCESS_KEY_ID || !S3_SECRET_ACCESS_KEY) {
log_error("Invalid S3_ACCESS_KEY_ID or S3_SECRET_ACCESS_KEY. Please check .env file.");
}
const s3Client = new S3Client({
region: REGION,
credentials: {
accessKeyId: S3_ACCESS_KEY_ID as string,
secretAccessKey: S3_SECRET_ACCESS_KEY as string,
accessKeyId: S3_ACCESS_KEY_ID || "",
secretAccessKey: S3_SECRET_ACCESS_KEY || "",
}
});
@@ -36,6 +32,10 @@ export function getKey(teamId: string, projectId: string, attachmentId: string,
return path.join(getRootDir(), teamId, projectId, `${attachmentId}.${type}`).replace(/\\/g, "/");
}
export function getTaskAttachmentKey(teamId: string, projectId: string, taskId: string, commentId: string, attachmentId: string, type: string) {
return path.join(getRootDir(), teamId, projectId, taskId, commentId, `${attachmentId}.${type}`).replace(/\\/g, "/");
}
export function getAvatarKey(userId: string, type: string) {
return path.join("avatars", getRootDir(), `${userId}.${type}`).replace(/\\/g, "/");
}

View File

@@ -4,12 +4,21 @@ import {isProduction, log_error} from "./utils";
export async function send_to_slack(error: any) {
if (!isProduction()) return;
if (!process.env.SLACK_WEBHOOK) return;
try {
const url = process.env.SLACK_WEBHOOK;
const blocks = [];
const title = error.message || "Error";
// Extract stack trace information
const obj: any = {};
Error.captureStackTrace(obj, error);
const traceStack = obj.stack;
const errorStack = traceStack.split("\n");
const errorOrigin = errorStack[3]?.trim() || "Unknown origin";
// Add error title
blocks.push({
"type": "header",
"text": {
@@ -19,6 +28,7 @@ export async function send_to_slack(error: any) {
}
});
// Add error details
blocks.push({
type: "section",
text: {
@@ -27,6 +37,20 @@ export async function send_to_slack(error: any) {
}
});
// Add stack trace origin
blocks.push({
type: "section",
text: {
type: "mrkdwn",
text: `\`\`\`\n${JSON.stringify({errorOrigin})}\`\`\``
}
});
// Add divider
blocks.push({
"type": "divider"
});
const request = {blocks};
await axios.post(url, JSON.stringify(request));
} catch (e) {

View File

@@ -0,0 +1,387 @@
import path from "path";
import {
DeleteObjectCommand,
DeleteObjectCommandInput,
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
PutObjectCommandInput,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import {
BlobServiceClient,
StorageSharedKeyCredential,
ContainerClient,
BlockBlobClient,
generateBlobSASQueryParameters,
BlobSASPermissions,
} from "@azure/storage-blob";
// Import mime type library
const mimeTypes = require("mime");
import { isProduction, isTestServer, log_error } from "./utils";
import {
AZURE_STORAGE_ACCOUNT_KEY,
AZURE_STORAGE_ACCOUNT_NAME,
AZURE_STORAGE_CONTAINER,
AZURE_STORAGE_URL,
BUCKET,
REGION,
S3_ACCESS_KEY_ID,
S3_SECRET_ACCESS_KEY,
S3_URL,
STORAGE_PROVIDER,
} from "./constants";
// Parse the endpoint URL from S3_URL if it exists
const getEndpointFromUrl = () => {
try {
if (!S3_URL) return undefined;
// Extract the endpoint URL (e.g., http://minio:9000 from http://minio:9000/bucket)
const url = new URL(S3_URL);
return `${url.protocol}//${url.host}`;
} catch (error) {
console.warn("Error parsing S3_URL:", error);
return undefined;
}
};
// Initialize S3 Client with support for MinIO
const s3Client = new S3Client({
region: REGION,
credentials: {
accessKeyId: S3_ACCESS_KEY_ID || "",
secretAccessKey: S3_SECRET_ACCESS_KEY || "",
},
endpoint: getEndpointFromUrl(),
forcePathStyle: true, // Required for MinIO
});
// Log the storage configuration
console.log(`Storage provider initialized: ${STORAGE_PROVIDER}`);
console.log(`Using endpoint: ${getEndpointFromUrl() || "AWS default"}`);
console.log(`Bucket: ${BUCKET}`);
// Initialize Azure Blob Storage Client
let azureBlobServiceClient: BlobServiceClient | null = null;
let azureContainerClient: ContainerClient | null = null;
if (STORAGE_PROVIDER === "azure") {
try {
if (!AZURE_STORAGE_ACCOUNT_NAME || !AZURE_STORAGE_ACCOUNT_KEY) {
console.error("Azure Blob Storage credentials are missing");
} else {
const sharedKeyCredential = new StorageSharedKeyCredential(
AZURE_STORAGE_ACCOUNT_NAME,
AZURE_STORAGE_ACCOUNT_KEY
);
azureBlobServiceClient = new BlobServiceClient(
`https://${AZURE_STORAGE_ACCOUNT_NAME}.blob.core.windows.net`,
sharedKeyCredential
);
const containerName = AZURE_STORAGE_CONTAINER || "ifinitycdn";
azureContainerClient = azureBlobServiceClient.getContainerClient(containerName);
console.log(`Azure Blob Storage initialized with account: ${AZURE_STORAGE_ACCOUNT_NAME}, container: ${containerName}`);
}
} catch (error) {
console.error("Failed to initialize Azure Blob Storage:", error);
}
}
export function getRootDir() {
if (isTestServer()) return "test-server";
if (isProduction()) return "secure";
return "local-server";
}
export function getKey(
teamId: string,
projectId: string,
attachmentId: string,
type: string
) {
const keyPath = path
.join(getRootDir(), teamId, projectId, `${attachmentId}.${type}`)
.replace(/\\/g, "/");
return keyPath;
}
export function getTaskAttachmentKey(
teamId: string,
projectId: string,
taskId: string,
commentId: string,
attachmentId: string,
type: string
) {
const keyPath = path
.join(
getRootDir(),
teamId,
projectId,
taskId,
commentId,
`${attachmentId}.${type}`
)
.replace(/\\/g, "/");
return keyPath;
}
export function getAvatarKey(userId: string, type: string) {
const keyPath = path
.join("avatars", getRootDir(), `${userId}.${type}`)
.replace(/\\/g, "/");
return keyPath;
}
async function uploadBufferToS3(
buffer: Buffer,
type: string,
location: string
): Promise<string | null> {
try {
const bucketParams: PutObjectCommandInput = {
Bucket: BUCKET,
Key: location,
Body: buffer,
ContentEncoding: "base64",
ContentType: type,
};
await s3Client.send(new PutObjectCommand(bucketParams));
// Create proper URL depending on whether we're using S3 or MinIO
const endpointUrl = getEndpointFromUrl();
if (endpointUrl) {
// For MinIO or custom S3 endpoint
return `${endpointUrl}/${BUCKET}/${location}`;
}
// For standard AWS S3
return `${S3_URL}/${location}`;
} catch (error) {
log_error(error);
return null;
}
}
async function uploadBufferToAzure(
buffer: Buffer,
type: string,
location: string
): Promise<string | null> {
try {
if (!azureContainerClient) {
throw new Error("Azure Blob Storage not configured properly");
}
const blobClient = azureContainerClient.getBlockBlobClient(location);
await blobClient.uploadData(buffer, {
blobHTTPHeaders: {
blobContentType: type,
},
});
// Format URL with container name in the path
const containerName = AZURE_STORAGE_CONTAINER || "ifinitycdn";
return `${AZURE_STORAGE_URL}/${containerName}/${location}`;
} catch (error) {
log_error(error);
return null;
}
}
export async function uploadBuffer(
buffer: Buffer,
type: string,
location: string
): Promise<string | null> {
if (STORAGE_PROVIDER === "azure") {
return uploadBufferToAzure(buffer, type, location);
}
return uploadBufferToS3(buffer, type, location);
}
export async function uploadBase64(base64Data: string, location: string) {
try {
const buffer = Buffer.from(
base64Data.replace(/^data:(.*?);base64,/, ""),
"base64"
);
const type = base64Data.split(";")[0].split(":")[1] || null;
if (!type) return null;
return await uploadBuffer(buffer, type, location);
} catch (error) {
log_error(error);
return null;
}
}
async function deleteObjectFromS3(key: string) {
try {
const input: DeleteObjectCommandInput = {
Bucket: BUCKET,
Key: key,
};
return await s3Client.send(new DeleteObjectCommand(input));
} catch (error) {
log_error(error);
return null;
}
}
async function deleteObjectFromAzure(key: string) {
try {
if (!azureContainerClient) {
throw new Error("Azure Blob Storage not configured properly");
}
const blobClient = azureContainerClient.getBlockBlobClient(key);
return await blobClient.delete();
} catch (error) {
log_error(error);
return null;
}
}
export async function deleteObject(key: string) {
if (STORAGE_PROVIDER === "azure") {
return deleteObjectFromAzure(key);
}
return deleteObjectFromS3(key);
}
async function calculateStorageS3(prefix: string) {
try {
let totalSize = 0;
let continuationToken;
let response: any | null = null;
do {
const command: any = new ListObjectsV2Command({
Bucket: BUCKET,
Prefix: `${getRootDir()}/${prefix}`,
ContinuationToken: continuationToken,
});
response = await s3Client.send(command);
if (response?.Contents) {
for (const obj of response.Contents) {
totalSize += obj.Size;
}
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
return totalSize;
} catch (error) {
log_error(error);
return 0;
}
}
async function calculateStorageAzure(prefix: string) {
try {
if (!azureContainerClient) {
throw new Error("Azure Blob Storage not configured properly");
}
let totalSize = 0;
const fullPrefix = `${getRootDir()}/${prefix}`;
// List all blobs with the specified prefix
for await (const blob of azureContainerClient.listBlobsFlat({
prefix: fullPrefix,
})) {
if (blob.properties.contentLength) {
totalSize += blob.properties.contentLength;
}
}
return totalSize;
} catch (error) {
log_error(error);
return 0;
}
}
export async function calculateStorage(prefix: string) {
if (STORAGE_PROVIDER === "azure") {
return calculateStorageAzure(prefix);
}
return calculateStorageS3(prefix);
}
async function createPresignedUrlWithS3Client(key: string, file: string) {
const fileExtension = path.extname(key).toLowerCase();
const contentType = mimeTypes.lookup(fileExtension);
const command = new GetObjectCommand({
Bucket: BUCKET,
Key: key,
ResponseContentType: `${contentType}`,
ResponseContentDisposition: `attachment; filename=${file}`,
});
return getSignedUrl(s3Client, command, { expiresIn: 3600 });
}
async function createPresignedUrlWithAzureClient(key: string, file: string) {
try {
if (
!azureContainerClient ||
!AZURE_STORAGE_ACCOUNT_NAME ||
!AZURE_STORAGE_ACCOUNT_KEY
) {
throw new Error("Azure Blob Storage not configured properly");
}
const blobClient = azureContainerClient.getBlockBlobClient(key);
// Create a SAS token that's valid for one hour
const sharedKeyCredential = new StorageSharedKeyCredential(
AZURE_STORAGE_ACCOUNT_NAME,
AZURE_STORAGE_ACCOUNT_KEY
);
const fileExtension = path.extname(key).toLowerCase();
const contentType = mimeTypes.lookup(fileExtension);
const containerName = AZURE_STORAGE_CONTAINER || "ifinitycdn";
const sasOptions = {
containerName,
blobName: key,
permissions: BlobSASPermissions.parse("r"), // Read permission
startsOn: new Date(),
expiresOn: new Date(new Date().valueOf() + 3600 * 1000),
contentDisposition: `attachment; filename=${file}`,
contentType: contentType || undefined,
};
const sasToken = generateBlobSASQueryParameters(
sasOptions,
sharedKeyCredential
).toString();
// Generate URL with container name in the path
return `${AZURE_STORAGE_URL}/${containerName}/${key}?${sasToken}`;
} catch (error) {
log_error(error);
return null;
}
}
export async function createPresignedUrlWithClient(key: string, file: string) {
if (STORAGE_PROVIDER === "azure") {
return createPresignedUrlWithAzureClient(key, file);
}
return createPresignedUrlWithS3Client(key, file);
}

View File

@@ -7,6 +7,7 @@ import { customAlphabet } from "nanoid";
import { AvatarNamesMap, NumbersColorMap, WorklenzColorCodes } from "./constants";
import { send_to_slack } from "./slack";
import { IActivityLogChangeType } from "../services/activity-logs/interfaces";
import { IRecurringSchedule } from "../interfaces/recurring-tasks";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const error_codes = require("./postgresql-error-codes");
@@ -43,8 +44,8 @@ export function isTestServer() {
/** Returns true if localhost:3000 or localhost:4200 */
export function isLocalServer() {
const hostname = process.env.HOSTNAME;
return hostname === "localhost:4200" || hostname === "localhost:3000" || hostname === "127.0.0.1:3000";
const frontendUrl = process.env.FRONTEND_URL;
return frontendUrl === "localhost:5173" || frontendUrl === "localhost:4200" || frontendUrl === "localhost:3000" || frontendUrl === "127.0.0.1:3000";
}
/** Returns true of isLocal or isTest server */
@@ -215,3 +216,62 @@ export function formatLogText(log: { log_type: IActivityLogChangeType; }) {
if (log.log_type === IActivityLogChangeType.DELETE) return "removed a ";
return log.log_type;
}
// Calculate the next start date based on the recurring schedule
export function calculateNextEndDate(schedule: IRecurringSchedule, lastDate: moment.Moment): moment.Moment {
const nextDate = moment(lastDate);
switch (schedule.schedule_type) {
case "daily":
return nextDate.add(1, "day");
case "weekly":
if (schedule.days_of_week && schedule.days_of_week.length > 0) {
let daysAdded = 0;
do {
nextDate.add(1, "day");
daysAdded++;
} while (!schedule.days_of_week.includes(nextDate.day()) && daysAdded < 7);
} else {
nextDate.add(1, "week");
}
return nextDate;
case "monthly":
if (schedule.date_of_month) {
nextDate.add(1, "month").date(schedule.date_of_month);
} else if (schedule.day_of_month && schedule.week_of_month) {
nextDate.add(1, "month").startOf("month").day(schedule.day_of_month);
nextDate.add(schedule.week_of_month - 1, "weeks");
} else {
nextDate.add(1, "month");
}
return nextDate;
case "yearly":
return nextDate.add(1, "year");
case "every_x_days":
return nextDate.add(schedule.interval_days || 1, "days");
case "every_x_weeks":
return nextDate.add(schedule.interval_weeks || 1, "weeks");
case "every_x_months":
return nextDate.add(schedule.interval_months || 1, "months");
default:
throw new Error(`Invalid schedule type: ${schedule.schedule_type}`);
}
}
export function calculateNextEndDates(schedule: IRecurringSchedule, lastEndDate: moment.Moment, count: number): moment.Moment[] {
const endDates: moment.Moment[] = [];
let currentDate = moment(lastEndDate);
for (let i = 0; i < count; i++) {
currentDate = calculateNextEndDate(schedule, currentDate);
endDates.push(moment(currentDate));
}
return endDates;
}
export function megabytesToBytes(megabytes: number): number {
return megabytes * 1024 * 1024; // 1 MB = 1024 KB = 1024 * 1024 bytes
}