init
This commit is contained in:
@@ -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.";
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
150
worklenz-backend/src/shared/paddle-requests.ts
Normal file
150
worklenz-backend/src/shared/paddle-requests.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
104
worklenz-backend/src/shared/paddle-utils.ts
Normal file
104
worklenz-backend/src/shared/paddle-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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, "/");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
387
worklenz-backend/src/shared/storage.ts
Normal file
387
worklenz-backend/src/shared/storage.ts
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user